History

[codex] add diagnostics resource history (#2685)

Julius Marminge committed 6 months ago

Commit 9e632f51 parent 4120e9415 files · +1075 −13Browse files

Unified · Split

15 files
  1. apps/server/src/diagnostics/ProcessDiagnostics.ts +4 −4
  2. apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +231 −0
  3. apps/server/src/diagnostics/ProcessResourceMonitor.ts +299 −0
  4. apps/server/src/observability/RpcInstrumentation.ts +1 −0
  5. apps/server/src/server.test.ts +17 −0
  6. apps/server/src/server.ts +2 −0
  7. apps/server/src/ws.ts +10 −0
  8. apps/web/src/components/settings/DiagnosticsSettings.tsx +335 −8
  9. apps/web/src/components/settings/SettingsPanels.browser.tsx +18 −0
  10. apps/web/src/lib/processDiagnosticsState.ts +76 −1
  11. apps/web/src/localApi.ts +4 −0
  12. apps/web/src/rpc/wsRpcClient.ts +9 −0
  13. packages/contracts/src/ipc.ts +5 −0
  14. packages/contracts/src/rpc.ts +12 −0
  15. packages/contracts/src/server.ts +52 −0
apps/server/src/diagnostics/ProcessDiagnostics.ts+4−4
@@ -15,7 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 14 unmodified lines
1515
1616 import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1717
18−interface ProcessRow {
18+export interface ProcessRow {
1919 readonly pid: number;
2020 readonly ppid: number;
2121 readonly pgid: number | null;
@@ -186,7 +186,7 @@ function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { · 164 unmodified lines
186186 }
187187 }
188188
189−function buildDescendantEntries(
189+export function buildDescendantEntries(
190190 rows: ReadonlyArray<ProcessRow>,
191191 serverPid: number,
192192 ): ReadonlyArray<ServerProcessDiagnosticsEntry> {
@@ -230,7 +230,7 @@ function buildDescendantEntries( · 37 unmodified lines
230230 return entries;
231231 }
232232
233−function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {
233+export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {
234234 if (row.ppid !== serverPid) return false;
235235
236236 const command = row.command.trim();
@@ -370,7 +370,7 @@ function readWindowsProcessRows(): Effect.Effect< · 133 unmodified lines
370370 );
371371 }
372372
373−const readProcessRows = (platform = process.platform) =>
373+export const readProcessRows = (platform = process.platform) =>
374374 platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
375375
376376 export function aggregateProcessDiagnostics(input: {
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+231−0
@@ -0,0 +1,231 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as DateTime from "effect/DateTime";
3+import * as Effect from "effect/Effect";
4+import * as Option from "effect/Option";
5+
6+import {
7+ aggregateProcessResourceHistory,
8+ collectMonitoredSamples,
9+} from "./ProcessResourceMonitor.ts";
10+
11+describe("ProcessResourceMonitor", () => {
12+ it.effect("samples the server root process and descendants", () =>
13+ Effect.sync(() => {
14+ const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15+ const samples = collectMonitoredSamples({
16+ serverPid: 100,
17+ sampledAt,
18+ sampledAtMs: DateTime.toEpochMillis(sampledAt),
19+ rows: [
20+ {
21+ pid: 100,
22+ ppid: 1,
23+ pgid: 100,
24+ status: "S",
25+ cpuPercent: 2,
26+ rssBytes: 1_000,
27+ elapsed: "01:00",
28+ command: "t3 server",
29+ },
30+ {
31+ pid: 101,
32+ ppid: 100,
33+ pgid: 100,
34+ status: "S",
35+ cpuPercent: 10,
36+ rssBytes: 2_000,
37+ elapsed: "00:20",
38+ command: "codex app-server",
39+ },
40+ {
41+ pid: 102,
42+ ppid: 101,
43+ pgid: 100,
44+ status: "R",
45+ cpuPercent: 50,
46+ rssBytes: 3_000,
47+ elapsed: "00:05",
48+ command: "rg needle",
49+ },
50+ {
51+ pid: 200,
52+ ppid: 1,
53+ pgid: 200,
54+ status: "R",
55+ cpuPercent: 99,
56+ rssBytes: 9_000,
57+ elapsed: "00:05",
58+ command: "unrelated",
59+ },
60+ ],
61+ });
62+
63+ expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]);
64+ expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]);
65+ expect(samples[0]?.isServerRoot).toBe(true);
66+ expect(samples[1]?.isServerRoot).toBe(false);
67+ }),
68+ );
69+
70+ it.effect("rolls samples up by process and CPU time", () =>
71+ Effect.sync(() => {
72+ const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
73+ const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
74+ const samples = [
75+ ...collectMonitoredSamples({
76+ serverPid: 100,
77+ sampledAt: firstAt,
78+ sampledAtMs: DateTime.toEpochMillis(firstAt),
79+ rows: [
80+ {
81+ pid: 100,
82+ ppid: 1,
83+ pgid: 100,
84+ status: "S",
85+ cpuPercent: 10,
86+ rssBytes: 1_000,
87+ elapsed: "01:00",
88+ command: "t3 server",
89+ },
90+ ],
91+ }),
92+ ...collectMonitoredSamples({
93+ serverPid: 100,
94+ sampledAt: secondAt,
95+ sampledAtMs: DateTime.toEpochMillis(secondAt),
96+ rows: [
97+ {
98+ pid: 100,
99+ ppid: 1,
100+ pgid: 100,
101+ status: "S",
102+ cpuPercent: 30,
103+ rssBytes: 2_000,
104+ elapsed: "01:05",
105+ command: "t3 server",
106+ },
107+ ],
108+ }),
109+ ];
110+
111+ const result = aggregateProcessResourceHistory({
112+ samples,
113+ readAt: secondAt,
114+ readAtMs: DateTime.toEpochMillis(secondAt),
115+ windowMs: 60_000,
116+ bucketMs: 10_000,
117+ lastError: null,
118+ });
119+
120+ expect(Option.isNone(result.error)).toBe(true);
121+ expect(result.topProcesses).toHaveLength(1);
122+ expect(result.topProcesses[0]?.avgCpuPercent).toBe(20);
123+ expect(result.topProcesses[0]?.maxCpuPercent).toBe(30);
124+ expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2);
125+ expect(result.totalCpuSecondsApprox).toBe(2);
126+ expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true);
127+ }),
128+ );
129+
130+ it.effect("keeps a process grouped when elapsed time drifts between samples", () =>
131+ Effect.sync(() => {
132+ const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
133+ const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
134+ const samples = [
135+ ...collectMonitoredSamples({
136+ serverPid: 100,
137+ sampledAt: firstAt,
138+ sampledAtMs: DateTime.toEpochMillis(firstAt),
139+ rows: [
140+ {
141+ pid: 100,
142+ ppid: 1,
143+ pgid: 100,
144+ status: "S",
145+ cpuPercent: 1,
146+ rssBytes: 1_000,
147+ elapsed: "01:00",
148+ command: "t3 server",
149+ },
150+ ],
151+ }),
152+ ...collectMonitoredSamples({
153+ serverPid: 100,
154+ sampledAt: secondAt,
155+ sampledAtMs: DateTime.toEpochMillis(secondAt),
156+ rows: [
157+ {
158+ pid: 100,
159+ ppid: 1,
160+ pgid: 100,
161+ status: "S",
162+ cpuPercent: 2,
163+ rssBytes: 2_000,
164+ elapsed: "01:06",
165+ command: "t3 server",
166+ },
167+ ],
168+ }),
169+ ];
170+
171+ const result = aggregateProcessResourceHistory({
172+ samples,
173+ readAt: secondAt,
174+ readAtMs: DateTime.toEpochMillis(secondAt),
175+ windowMs: 60_000,
176+ bucketMs: 10_000,
177+ lastError: null,
178+ });
179+
180+ expect(result.topProcesses).toHaveLength(1);
181+ expect(result.topProcesses[0]?.isServerRoot).toBe(true);
182+ expect(result.topProcesses[0]?.sampleCount).toBe(2);
183+ expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000);
184+ }),
185+ );
186+
187+ it.effect("returns all process summaries in the selected window", () =>
188+ Effect.sync(() => {
189+ const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
190+ const samples = collectMonitoredSamples({
191+ serverPid: 100,
192+ sampledAt,
193+ sampledAtMs: DateTime.toEpochMillis(sampledAt),
194+ rows: [
195+ {
196+ pid: 100,
197+ ppid: 1,
198+ pgid: 100,
199+ status: "S",
200+ cpuPercent: 1,
201+ rssBytes: 1_000,
202+ elapsed: "01:00",
203+ command: "t3 server",
204+ },
205+ ...Array.from({ length: 35 }, (_, index) => ({
206+ pid: 200 + index,
207+ ppid: index === 0 ? 100 : 199 + index,
208+ pgid: 100,
209+ status: "S",
210+ cpuPercent: 35 - index,
211+ rssBytes: 2_000 + index,
212+ elapsed: "00:10",
213+ command: `worker ${index}`,
214+ })),
215+ ],
216+ });
217+
218+ const result = aggregateProcessResourceHistory({
219+ samples,
220+ readAt: sampledAt,
221+ readAtMs: DateTime.toEpochMillis(sampledAt),
222+ windowMs: 60_000,
223+ bucketMs: 10_000,
224+ lastError: null,
225+ });
226+
227+ expect(result.topProcesses).toHaveLength(36);
228+ expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);
229+ }),
230+ );
231+});
apps/server/src/diagnostics/ProcessResourceMonitor.ts+299−0
@@ -0,0 +1,299 @@
1+import type {
2+ ServerProcessResourceHistoryBucket,
3+ ServerProcessResourceHistoryInput,
4+ ServerProcessResourceHistoryResult,
5+ ServerProcessResourceHistorySummary,
6+} from "@t3tools/contracts";
7+import * as Context from "effect/Context";
8+import * as DateTime from "effect/DateTime";
9+import * as Effect from "effect/Effect";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as Ref from "effect/Ref";
13+import { ChildProcessSpawner } from "effect/unstable/process";
14+
15+import {
16+ buildDescendantEntries,
17+ isDiagnosticsQueryProcess,
18+ type ProcessRow,
19+ readProcessRows,
20+} from "./ProcessDiagnostics.ts";
21+
22+const SAMPLE_INTERVAL_MS = 5_000;
23+const RETENTION_MS = 60 * 60_000;
24+const MAX_RETAINED_SAMPLES = 20_000;
25+
26+export interface ProcessResourceSample {
27+ readonly sampledAt: DateTime.Utc;
28+ readonly sampledAtMs: number;
29+ readonly processKey: string;
30+ readonly pid: number;
31+ readonly ppid: number;
32+ readonly command: string;
33+ readonly cpuPercent: number;
34+ readonly rssBytes: number;
35+ readonly depth: number;
36+ readonly isServerRoot: boolean;
37+}
38+
39+interface MonitorState {
40+ readonly samples: ReadonlyArray<ProcessResourceSample>;
41+ readonly lastError: string | null;
42+}
43+
44+export interface ProcessResourceMonitorShape {
45+ readonly readHistory: (
46+ input: ServerProcessResourceHistoryInput,
47+ ) => Effect.Effect<ServerProcessResourceHistoryResult>;
48+}
49+
50+export class ProcessResourceMonitor extends Context.Service<
51+ ProcessResourceMonitor,
52+ ProcessResourceMonitorShape
53+>()("t3/diagnostics/ProcessResourceMonitor") {}
54+
55+function dateTimeFromMillis(ms: number): DateTime.Utc {
56+ return DateTime.makeUnsafe(ms);
57+}
58+
59+function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string {
60+ return `${row.pid}:${row.command}`;
61+}
62+
63+function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null {
64+ return rows.find((row) => row.pid === serverPid) ?? null;
65+}
66+
67+export function collectMonitoredSamples(input: {
68+ readonly rows: ReadonlyArray<ProcessRow>;
69+ readonly serverPid: number;
70+ readonly sampledAt: DateTime.Utc;
71+ readonly sampledAtMs: number;
72+}): ReadonlyArray<ProcessResourceSample> {
73+ const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));
74+ const root = findServerRootRow(rows, input.serverPid);
75+ const descendants = buildDescendantEntries(rows, input.serverPid);
76+ const samples: ProcessResourceSample[] = [];
77+
78+ if (root) {
79+ samples.push({
80+ sampledAt: input.sampledAt,
81+ sampledAtMs: input.sampledAtMs,
82+ processKey: sampleKey(root),
83+ pid: root.pid,
84+ ppid: root.ppid,
85+ command: root.command,
86+ cpuPercent: root.cpuPercent,
87+ rssBytes: root.rssBytes,
88+ depth: 0,
89+ isServerRoot: true,
90+ });
91+ }
92+
93+ for (const process of descendants) {
94+ samples.push({
95+ sampledAt: input.sampledAt,
96+ sampledAtMs: input.sampledAtMs,
97+ processKey: sampleKey(process),
98+ pid: process.pid,
99+ ppid: process.ppid,
100+ command: process.command,
101+ cpuPercent: process.cpuPercent,
102+ rssBytes: process.rssBytes,
103+ depth: process.depth + 1,
104+ isServerRoot: false,
105+ });
106+ }
107+
108+ return samples;
109+}
110+
111+function trimSamples(
112+ samples: ReadonlyArray<ProcessResourceSample>,
113+ nowMs: number,
114+): ReadonlyArray<ProcessResourceSample> {
115+ const minSampledAtMs = nowMs - RETENTION_MS;
116+ const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
117+ return retained.length <= MAX_RETAINED_SAMPLES
118+ ? retained
119+ : retained.slice(retained.length - MAX_RETAINED_SAMPLES);
120+}
121+
122+function summarizeProcesses(
123+ samples: ReadonlyArray<ProcessResourceSample>,
124+): ReadonlyArray<ServerProcessResourceHistorySummary> {
125+ const groups = new Map<string, ProcessResourceSample[]>();
126+ for (const sample of samples) {
127+ const processSamples = groups.get(sample.processKey) ?? [];
128+ processSamples.push(sample);
129+ groups.set(sample.processKey, processSamples);
130+ }
131+
132+ return [...groups.entries()]
133+ .map(([processKey, processSamples]) => {
134+ const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
135+ const first = sorted[0]!;
136+ const latest = sorted[sorted.length - 1]!;
137+ const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0);
138+ const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent));
139+ const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes));
140+ const cpuSecondsApprox = sorted.reduce(
141+ (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
142+ 0,
143+ );
144+
145+ return {
146+ processKey,
147+ pid: latest.pid,
148+ ppid: latest.ppid,
149+ command: latest.command,
150+ depth: latest.depth,
151+ isServerRoot: latest.isServerRoot,
152+ firstSeenAt: first.sampledAt,
153+ lastSeenAt: latest.sampledAt,
154+ currentCpuPercent: latest.cpuPercent,
155+ avgCpuPercent: cpuPercentTotal / sorted.length,
156+ maxCpuPercent,
157+ cpuSecondsApprox,
158+ currentRssBytes: latest.rssBytes,
159+ maxRssBytes,
160+ sampleCount: sorted.length,
161+ } satisfies ServerProcessResourceHistorySummary;
162+ })
163+ .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox);
164+}
165+
166+function buildBuckets(input: {
167+ readonly samples: ReadonlyArray<ProcessResourceSample>;
168+ readonly nowMs: number;
169+ readonly windowMs: number;
170+ readonly bucketMs: number;
171+}): ReadonlyArray<ServerProcessResourceHistoryBucket> {
172+ const bucketMs = Math.max(1_000, input.bucketMs);
173+ const windowStartMs = input.nowMs - input.windowMs;
174+ const buckets: ServerProcessResourceHistoryBucket[] = [];
175+
176+ for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) {
177+ const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs);
178+ const bucketSamples = input.samples.filter(
179+ (sample) =>
180+ sample.sampledAtMs >= startedAtMs &&
181+ (endedAtMs === input.nowMs
182+ ? sample.sampledAtMs <= endedAtMs
183+ : sample.sampledAtMs < endedAtMs),
184+ );
185+ const samplesByRead = new Map<number, ProcessResourceSample[]>();
186+ for (const sample of bucketSamples) {
187+ const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? [];
188+ samplesAtTime.push(sample);
189+ samplesByRead.set(sample.sampledAtMs, samplesAtTime);
190+ }
191+
192+ const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({
193+ cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0),
194+ rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0),
195+ processCount: samplesAtTime.length,
196+ }));
197+ const avgCpuPercent =
198+ readTotals.length === 0
199+ ? 0
200+ : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length;
201+
202+ buckets.push({
203+ startedAt: dateTimeFromMillis(startedAtMs),
204+ endedAt: dateTimeFromMillis(endedAtMs),
205+ avgCpuPercent,
206+ maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0,
207+ maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0,
208+ maxProcessCount: readTotals.length
209+ ? Math.max(...readTotals.map((read) => read.processCount))
210+ : 0,
211+ });
212+ }
213+
214+ return buckets;
215+}
216+
217+export function aggregateProcessResourceHistory(input: {
218+ readonly samples: ReadonlyArray<ProcessResourceSample>;
219+ readonly readAt: DateTime.Utc;
220+ readonly readAtMs: number;
221+ readonly windowMs: number;
222+ readonly bucketMs: number;
223+ readonly lastError: string | null;
224+}): ServerProcessResourceHistoryResult {
225+ const windowMs = Math.max(1_000, input.windowMs);
226+ const bucketMs = Math.max(1_000, input.bucketMs);
227+ const minSampledAtMs = input.readAtMs - windowMs;
228+ const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
229+ const topProcesses = summarizeProcesses(samples);
230+ const totalCpuSecondsApprox = samples.reduce(
231+ (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
232+ 0,
233+ );
234+
235+ return {
236+ readAt: input.readAt,
237+ windowMs,
238+ bucketMs,
239+ sampleIntervalMs: SAMPLE_INTERVAL_MS,
240+ retainedSampleCount: input.samples.length,
241+ totalCpuSecondsApprox,
242+ buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }),
243+ topProcesses,
244+ error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(),
245+ };
246+}
247+
248+export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
249+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
250+ const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null });
251+
252+ const sampleOnce = Effect.gen(function* () {
253+ const sampledAt = yield* DateTime.now;
254+ const sampledAtMs = DateTime.toEpochMillis(sampledAt);
255+ const rows = yield* readProcessRows().pipe(
256+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
257+ );
258+ const samples = collectMonitoredSamples({
259+ rows,
260+ serverPid: process.pid,
261+ sampledAt,
262+ sampledAtMs,
263+ });
264+ yield* Ref.update(state, (current) => ({
265+ samples: trimSamples([...current.samples, ...samples], sampledAtMs),
266+ lastError: null,
267+ }));
268+ }).pipe(
269+ Effect.catch((error: unknown) =>
270+ Ref.update(state, (current) => ({
271+ ...current,
272+ lastError: error instanceof Error ? error.message : "Failed to sample process resources.",
273+ })),
274+ ),
275+ );
276+
277+ yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe(
278+ Effect.forkScoped,
279+ );
280+
281+ const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>
282+ Effect.gen(function* () {
283+ const readAt = yield* DateTime.now;
284+ const readAtMs = DateTime.toEpochMillis(readAt);
285+ const current = yield* Ref.get(state);
286+ return aggregateProcessResourceHistory({
287+ samples: current.samples,
288+ readAt,
289+ readAtMs,
290+ windowMs: input.windowMs,
291+ bucketMs: input.bucketMs,
292+ lastError: current.lastError,
293+ });
294+ });
295+
296+ return ProcessResourceMonitor.of({ readHistory });
297+});
298+
299+export const layer = Layer.effect(ProcessResourceMonitor, make());
apps/server/src/observability/RpcInstrumentation.ts+1−0
@@ -18,6 +18,7 @@ const DEFAULT_RPC_SPAN_ATTRIBUTES = { · 17 unmodified lines
1818 const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet<string> = new Set([
1919 WS_METHODS.serverGetTraceDiagnostics,
2020 WS_METHODS.serverGetProcessDiagnostics,
21+ WS_METHODS.serverGetProcessResourceHistory,
2122 WS_METHODS.serverSignalProcess,
2223 ]);
2324
apps/server/src/server.test.ts+17−0
@@ -115,6 +115,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe · 114 unmodified lines
115115 import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
116116 import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
117117 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
118+import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
118119 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
119120 import * as Data from "effect/Data";
120121
@@ -564,6 +565,22 @@ const buildAppUnderTest = (options?: { · 443 unmodified lines
564565 }),
565566 }),
566567 ),
568+ Layer.provide(
569+ Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({
570+ readHistory: (input) =>
571+ Effect.succeed({
572+ readAt: TEST_EPOCH,
573+ windowMs: input.windowMs,
574+ bucketMs: input.bucketMs,
575+ sampleIntervalMs: 5_000,
576+ retainedSampleCount: 0,
577+ totalCpuSecondsApprox: 0,
578+ buckets: [],
579+ topProcesses: [],
580+ error: Option.none(),
581+ }),
582+ }),
583+ ),
567584 Layer.provide(
568585 Layer.mock(TraceDiagnostics.TraceDiagnostics)({
569586 read: () =>
apps/server/src/server.ts+2−0
@@ -76,6 +76,7 @@ import { · 75 unmodified lines
7676 import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
7777 import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
7878 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
79+import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
7980 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
8081 import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
8182 import {
@@ -280,6 +281,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 198 unmodified lines
280281 const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
281282 // Misc.
282283 Layer.provideMerge(ProcessDiagnostics.layer),
284+ Layer.provideMerge(ProcessResourceMonitor.layer),
283285 Layer.provideMerge(TraceDiagnostics.layer),
284286 Layer.provideMerge(AnalyticsServiceLayerLive),
285287 Layer.provideMerge(ExternalLauncher.layer),
apps/server/src/ws.ts+10−0
@@ -66,6 +66,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit · 65 unmodified lines
6666 import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
6767 import { ServerAuth } from "./auth/Services/ServerAuth.ts";
6868 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
69+import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
6970 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
7071 import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
7172 import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
@@ -193,6 +194,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 121 unmodified lines
193194 const bootstrapCredentials = yield* BootstrapCredentialService;
194195 const sessions = yield* SessionCredentialService;
195196 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
197+ const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
196198 const serverCommandId = (tag: string) =>
197199 CommandId.make(`server:${tag}:${crypto.randomUUID()}`);
198200
@@ -909,6 +911,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 710 unmodified lines
909911 observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, {
910912 "rpc.aggregate": "server",
911913 }),
914+ [WS_METHODS.serverGetProcessResourceHistory]: (input) =>
915+ observeRpcEffect(
916+ WS_METHODS.serverGetProcessResourceHistory,
917+ processResourceMonitor.readHistory(input),
918+ {
919+ "rpc.aggregate": "server",
920+ },
921+ ),
912922 [WS_METHODS.serverSignalProcess]: (input) =>
913923 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
914924 "rpc.aggregate": "server",
apps/web/src/components/settings/DiagnosticsSettings.tsx+335−8
@@ -8,7 +8,11 @@ import { · 7 unmodified lines
88 RefreshCwIcon,
99 } from "lucide-react";
1010 import { useCallback, useMemo, useState, type ReactNode } from "react";
11−import type { ServerProcessDiagnosticsEntry, ServerProcessSignal } from "@t3tools/contracts";
11+import type {
12+ ServerProcessDiagnosticsEntry,
13+ ServerProcessResourceHistorySummary,
14+ ServerProcessSignal,
15+} from "@t3tools/contracts";
1216 import * as DateTime from "effect/DateTime";
1317 import * as Option from "effect/Option";
1418
@@ -17,7 +21,10 @@ import { cn } from "../../lib/utils"; · 2 unmodified lines
1721 import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
1822 import { formatRelativeTime } from "../../timestampFormat";
1923 import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState";
20−import { useProcessDiagnostics } from "../../lib/processDiagnosticsState";
24+import {
25+ useProcessDiagnostics,
26+ useProcessResourceHistory,
27+} from "../../lib/processDiagnosticsState";
2128 import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState";
2229 import { Button } from "../ui/button";
2330 import { ScrollArea } from "../ui/scroll-area";
@@ -309,7 +316,7 @@ function ProcessNameCell({ · 285 unmodified lines
309316 return (
310317 <div
311318 className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
312− style={{ paddingLeft: `${Math.min(process.depth, 8) * 16}px` }}
319+ style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }}
313320 >
314321 {hasChildren ? (
315322 <button
@@ -324,7 +331,17 @@ function ProcessNameCell({ · 8 unmodified lines
324331 <span className="size-5 shrink-0" aria-hidden="true" />
325332 )}
326333 <span className="size-1.5 shrink-0 rounded-full bg-emerald-500/80" />
327− <span className="min-w-0 truncate font-medium text-foreground">{name}</span>
334+ <Tooltip>
335+ <TooltipTrigger
336+ render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
337+ />
338+ <TooltipPopup
339+ side="top"
340+ className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
341+ >
342+ {process.command}
343+ </TooltipPopup>
344+ </Tooltip>
328345 </div>
329346 );
330347 }
@@ -422,7 +439,7 @@ function ProcessDiagnosticsTable({ · 91 unmodified lines
422439 chainVerticalScroll
423440 scrollFade
424441 hideScrollbars
425− className="w-full max-w-full rounded-none border-t border-border/60"
442+ className="max-h-[min(64vh,44rem)] w-full max-w-full rounded-none border-t border-border/60"
426443 >
427444 <table className="w-full min-w-[1040px] table-fixed text-left text-xs">
428445 <colgroup>
@@ -434,7 +451,7 @@ function ProcessDiagnosticsTable({ · 5 unmodified lines
434451 <col className="w-[11%]" />
435452 <col className="w-[6%]" />
436453 </colgroup>
437− <thead className="border-b border-border/60 bg-muted/15 text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70">
454+ <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70">
438455 <tr>
439456 <th className="px-4 py-2 font-semibold sm:pl-5">Name</th>
440457 <th className="px-3 py-2 text-right font-semibold">CPU</th>
@@ -442,7 +459,7 @@ function ProcessDiagnosticsTable({ · 1 unmodified line
442459 <th className="px-3 py-2 font-semibold">Command</th>
443460 <th className="px-3 py-2 text-right font-semibold">PID</th>
444461 <th className="px-3 py-2 font-semibold">Type</th>
445− <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th>
462+ <th className="p-2 text-right font-semibold sm:pr-4">Kill</th>
446463 </tr>
447464 </thead>
448465 <tbody className="divide-y divide-border/50">
@@ -487,7 +504,7 @@ function ProcessDiagnosticsTable({ · 38 unmodified lines
487504 <td className="truncate px-3 py-2 align-middle text-muted-foreground">
488505 {formatProcessType(process)}
489506 </td>
490− <td className="px-2 py-2 align-middle sm:pr-4">
507+ <td className="p-2 align-middle sm:pr-4">
491508 <ProcessSignalActions
492509 process={process}
493510 isSignaling={signalingPid === process.pid}
@@ -502,6 +519,238 @@ function ProcessDiagnosticsTable({ · 8 unmodified lines
502519 );
503520 }
504521
522+const RESOURCE_HISTORY_WINDOWS = [
523+ { label: "5m", windowMs: 5 * 60_000, bucketMs: 30_000 },
524+ { label: "15m", windowMs: 15 * 60_000, bucketMs: 60_000 },
525+ { label: "30m", windowMs: 30 * 60_000, bucketMs: 2 * 60_000 },
526+ { label: "1h", windowMs: 60 * 60_000, bucketMs: 5 * 60_000 },
527+] as const;
528+
529+function formatCpuTime(seconds: number): string {
530+ if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`;
531+ const minutes = seconds / 60;
532+ if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`;
533+ return `${(minutes / 60).toFixed(2)}h`;
534+}
535+
536+function formatShortProcessName(command: string): string {
537+ const name = formatProcessName(command);
538+ return name.length > 42 ? `${name.slice(0, 39)}...` : name;
539+}
540+
541+function ResourceHistoryProcessNameCell({
542+ process,
543+ visualDepth,
544+}: {
545+ process: ServerProcessResourceHistorySummary;
546+ visualDepth: number;
547+}) {
548+ const name = formatShortProcessName(process.command);
549+
550+ return (
551+ <div
552+ className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
553+ style={{ paddingLeft: `${Math.min(visualDepth, 6) * 10}px` }}
554+ aria-label={`${process.isServerRoot ? "Root" : "Child"} process ${name}`}
555+ >
556+ <span className="size-5 shrink-0" aria-hidden="true" />
557+ <span
558+ className={cn(
559+ "size-1.5 shrink-0 rounded-full",
560+ process.isServerRoot ? "bg-amber-500/90" : "bg-emerald-500/80",
561+ )}
562+ />
563+ <Tooltip>
564+ <TooltipTrigger
565+ render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
566+ />
567+ <TooltipPopup
568+ side="top"
569+ className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
570+ >
571+ {process.command}
572+ </TooltipPopup>
573+ </Tooltip>
574+ </div>
575+ );
576+}
577+
578+function ProcessResourceHistoryChart({
579+ buckets,
580+}: {
581+ buckets: ReadonlyArray<{
582+ readonly startedAt: DateTime.Utc;
583+ readonly avgCpuPercent: number;
584+ readonly maxCpuPercent: number;
585+ }>;
586+}) {
587+ const maxCpuPercent = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent));
588+
589+ return (
590+ <div className="border-t border-border/60 px-4 py-3 sm:px-5">
591+ <div className="flex h-28 items-end gap-1 overflow-hidden rounded-sm bg-muted/10 p-2">
592+ {buckets.map((bucket) => {
593+ const peakHeight = Math.max(2, (bucket.maxCpuPercent / maxCpuPercent) * 100);
594+ const averageHeight = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100);
595+ return (
596+ <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
597+ <TooltipTrigger
598+ render={
599+ <div className="flex h-full min-w-1 flex-1 items-end">
600+ <div
601+ className="relative h-full w-full"
602+ aria-label={`Average CPU ${bucket.avgCpuPercent.toFixed(1)}%, peak CPU ${bucket.maxCpuPercent.toFixed(1)}%`}
603+ >
604+ <div
605+ className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/15 transition-colors"
606+ style={{ height: `${peakHeight}%` }}
607+ />
608+ <div
609+ className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/60 transition-colors"
610+ style={{ height: `${averageHeight}%` }}
611+ />
612+ </div>
613+ </div>
614+ }
615+ />
616+ <TooltipPopup side="top">
617+ Avg {bucket.avgCpuPercent.toFixed(1)}%, peak {bucket.maxCpuPercent.toFixed(1)}%
618+ </TooltipPopup>
619+ </Tooltip>
620+ );
621+ })}
622+ </div>
623+ </div>
624+ );
625+}
626+
627+function ResourceHistoryWindowSelector({
628+ selectedWindowMs,
629+ onSelect,
630+}: {
631+ selectedWindowMs: number;
632+ onSelect: (windowMs: number) => void;
633+}) {
634+ return (
635+ <div className="flex items-center rounded-md border border-border/60 p-0.5">
636+ {RESOURCE_HISTORY_WINDOWS.map((option) => (
637+ <button
638+ key={option.windowMs}
639+ type="button"
640+ className={cn(
641+ "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground",
642+ selectedWindowMs === option.windowMs && "bg-muted text-foreground",
643+ )}
644+ onClick={() => onSelect(option.windowMs)}
645+ >
646+ {option.label}
647+ </button>
648+ ))}
649+ </div>
650+ );
651+}
652+
653+function ProcessResourceHistoryTable({
654+ processes,
655+ emptyLabel,
656+}: {
657+ processes: ReadonlyArray<ServerProcessResourceHistorySummary>;
658+ emptyLabel: string;
659+}) {
660+ const shallowestChildDepth = processes.reduce<number | null>((minDepth, process) => {
661+ if (process.isServerRoot) return minDepth;
662+ return minDepth === null ? process.depth : Math.min(minDepth, process.depth);
663+ }, null);
664+
665+ return (
666+ <ScrollArea
667+ chainVerticalScroll
668+ scrollFade
669+ hideScrollbars
670+ className="max-h-[min(64vh,44rem)] w-full max-w-full border-t border-border/60"
671+ >
672+ <table className="w-full min-w-[980px] table-fixed text-left text-xs">
673+ <colgroup>
674+ <col className="w-[24%]" />
675+ <col className="w-[10%]" />
676+ <col className="w-[10%]" />
677+ <col className="w-[10%]" />
678+ <col className="w-[10%]" />
679+ <col className="w-[10%]" />
680+ <col className="w-[16%]" />
681+ <col className="w-[10%]" />
682+ </colgroup>
683+ <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[11px] uppercase tracking-[0.08em] text-muted-foreground/70">
684+ <tr>
685+ <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
686+ <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
687+ <th className="px-3 py-2 text-right font-semibold">Current</th>
688+ <th className="px-3 py-2 text-right font-semibold">Average</th>
689+ <th className="px-3 py-2 text-right font-semibold">Peak</th>
690+ <th className="px-3 py-2 text-right font-semibold">Max Mem</th>
691+ <th className="px-3 py-2 font-semibold">Command</th>
692+ <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th>
693+ </tr>
694+ </thead>
695+ <tbody className="divide-y divide-border/50">
696+ {processes.length === 0 ? (
697+ <tr>
698+ <td colSpan={8} className="px-4 py-4 text-xs text-muted-foreground sm:px-5">
699+ {emptyLabel}
700+ </td>
701+ </tr>
702+ ) : null}
703+ {processes.map((process) => (
704+ <tr key={process.processKey} className="hover:bg-muted/20">
705+ <td className="px-4 py-2 align-middle sm:pl-5">
706+ <ResourceHistoryProcessNameCell
707+ process={process}
708+ visualDepth={
709+ process.isServerRoot || shallowestChildDepth === null
710+ ? 0
711+ : Math.max(1, process.depth - shallowestChildDepth + 1)
712+ }
713+ />
714+ </td>
715+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
716+ {formatCpuTime(process.cpuSecondsApprox)}
717+ </td>
718+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
719+ {process.currentCpuPercent.toFixed(1)}%
720+ </td>
721+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
722+ {process.avgCpuPercent.toFixed(1)}%
723+ </td>
724+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
725+ {process.maxCpuPercent.toFixed(1)}%
726+ </td>
727+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
728+ {formatBytes(process.maxRssBytes)}
729+ </td>
730+ <td className="px-3 py-2 align-middle text-muted-foreground">
731+ <Tooltip>
732+ <TooltipTrigger
733+ render={<span className="block truncate">{process.command}</span>}
734+ />
735+ <TooltipPopup
736+ side="top"
737+ className="max-w-[min(440px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px] leading-relaxed text-wrap"
738+ >
739+ {process.command}
740+ </TooltipPopup>
741+ </Tooltip>
742+ </td>
743+ <td className="px-3 py-2 text-right align-middle font-mono tabular-nums text-muted-foreground sm:pr-5">
744+ {process.pid}
745+ </td>
746+ </tr>
747+ ))}
748+ </tbody>
749+ </table>
750+ </ScrollArea>
751+ );
752+}
753+
505754 function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {
506755 useRelativeTimeTick();
507756 const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null;
@@ -556,6 +805,10 @@ function DiagnosticsRefreshButton({ · 48 unmodified lines
556805 export function DiagnosticsSettingsPanel() {
557806 const observability = useServerObservability();
558807 const availableEditors = useServerAvailableEditors();
808+ const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);
809+ const selectedResourceWindow =
810+ RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
811+ RESOURCE_HISTORY_WINDOWS[1];
559812 const { data, error, isPending, refresh } = useTraceDiagnostics();
560813 const {
561814 data: processData,
@@ -563,6 +816,15 @@ export function DiagnosticsSettingsPanel() { · 1 unmodified line
563816 isPending: isProcessPending,
564817 refresh: refreshProcesses,
565818 } = useProcessDiagnostics();
819+ const {
820+ data: resourceData,
821+ error: resourceError,
822+ isPending: isResourcePending,
823+ refresh: refreshResources,
824+ } = useProcessResourceHistory({
825+ windowMs: selectedResourceWindow.windowMs,
826+ bucketMs: selectedResourceWindow.bucketMs,
827+ });
566828 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
567829 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
568830 const [signalingPid, setSignalingPid] = useState<number | null>(null);
@@ -643,6 +905,7 @@ export function DiagnosticsSettingsPanel() { · 74 unmodified lines
643905 );
644906
645907 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
908+ const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null;
646909 const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null;
647910 const traceDiagnosticsPartialFailure = data
648911 ? Option.getOrElse(data.partialFailure, () => false)
@@ -711,6 +974,70 @@ export function DiagnosticsSettingsPanel() { · 62 unmodified lines
711974 />
712975 </SettingsSection>
713976
977+ <SettingsSection
978+ title="Resource History"
979+ headerAction={
980+ <div className="flex items-center gap-1.5">
981+ <ResourceHistoryWindowSelector
982+ selectedWindowMs={resourceWindowMs}
983+ onSelect={setResourceWindowMs}
984+ />
985+ <DiagnosticsLastChecked checkedAt={resourceData?.readAt ?? null} />
986+ <DiagnosticsRefreshButton
987+ isPending={isResourcePending}
988+ label="Refresh resource history"
989+ onClick={refreshResources}
990+ />
991+ </div>
992+ }
993+ >
994+ <StatsGrid>
995+ <StatBlock
996+ label="CPU Time"
997+ value={resourceData ? formatCpuTime(resourceData.totalCpuSecondsApprox) : "..."}
998+ tooltip="Approximate active CPU time for the T3 server root process and its descendants during the selected window. It grows only while sampled processes use CPU and older samples leave as the window moves."
999+ />
1000+ <StatBlock
1001+ label="Samples"
1002+ value={resourceData ? formatCount(resourceData.retainedSampleCount) : "..."}
1003+ tooltip="In-memory process samples retained by the server. This resets when the server restarts."
1004+ />
1005+ <StatBlock
1006+ label="Interval"
1007+ value={resourceData ? formatDuration(resourceData.sampleIntervalMs) : "..."}
1008+ />
1009+ <StatBlock
1010+ label="Processes"
1011+ value={resourceData ? formatCount(resourceData.topProcesses.length) : "..."}
1012+ />
1013+ </StatsGrid>
1014+ {processResourceError || resourceError ? (
1015+ <div className="space-y-2 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-5">
1016+ {processResourceError ? (
1017+ <div className="flex items-start gap-2 text-destructive">
1018+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1019+ <span>{processResourceError.message}</span>
1020+ </div>
1021+ ) : null}
1022+ {resourceError ? (
1023+ <div className="flex items-start gap-2 text-destructive">
1024+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1025+ <span>{resourceError}</span>
1026+ </div>
1027+ ) : null}
1028+ </div>
1029+ ) : null}
1030+ <ProcessResourceHistoryChart buckets={resourceData?.buckets ?? []} />
1031+ <ProcessResourceHistoryTable
1032+ processes={resourceData?.topProcesses ?? []}
1033+ emptyLabel={
1034+ isResourcePending && resourceData === null
1035+ ? "Collecting process resource samples..."
1036+ : "No process resource samples found for this window."
1037+ }
1038+ />
1039+ </SettingsSection>
1040+
7141041 <SettingsSection
7151042 title="Trace Diagnostics"
7161043 headerAction={
apps/web/src/components/settings/SettingsPanels.browser.tsx+18−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 ProviderDriverKind,
1414 ProviderInstanceId,
1515 type ServerConfig,
16+ type ServerProcessResourceHistoryResult,
1617 type ServerProvider,
1718 type SourceControlDiscoveryResult,
1819 } from "@t3tools/contracts";
@@ -268,6 +269,20 @@ function makeUtc(value: string) { · 249 unmodified lines
268269 return DateTime.makeUnsafe(value);
269270 }
270271
272+function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistoryResult {
273+ return {
274+ readAt: makeUtc("2036-04-07T00:00:00.000Z"),
275+ windowMs: 15 * 60_000,
276+ bucketMs: 60_000,
277+ sampleIntervalMs: 5_000,
278+ retainedSampleCount: 0,
279+ totalCpuSecondsApprox: 0,
280+ buckets: [],
281+ topProcesses: [],
282+ error: Option.none(),
283+ };
284+}
285+
271286 function makePairingLink(input: {
272287 readonly id: string;
273288 readonly credential: string;
@@ -1065,6 +1080,9 @@ describe("GeneralSettingsPanel observability", () => { · 791 unmodified lines
10651080 processes: [],
10661081 error: Option.none(),
10671082 }),
1083+ getProcessResourceHistory: vi
1084+ .fn()
1085+ .mockResolvedValue(createEmptyProcessResourceHistoryResult()),
10681086 getTraceDiagnostics: vi.fn().mockResolvedValue({
10691087 traceFilePath: "/repo/project/.t3/traces.jsonl",
10701088 scannedFilePaths: ["/repo/project/.t3/traces.jsonl"],
apps/web/src/lib/processDiagnosticsState.ts+76−1
@@ -1,5 +1,8 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import type { ServerProcessDiagnosticsResult } from "@t3tools/contracts";
2+import type {
3+ ServerProcessDiagnosticsResult,
4+ ServerProcessResourceHistoryResult,
5+} from "@t3tools/contracts";
36 import * as Cause from "effect/Cause";
47 import * as Effect from "effect/Effect";
58 import * as Option from "effect/Option";
@@ -11,6 +14,8 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; · 5 unmodified lines
1114
1215 const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000;
1316 const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000;
17+const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000;
18+const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":";
1419
1520 const processDiagnosticsAtom = Atom.make(
1621 Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()),
@@ -23,6 +28,38 @@ const processDiagnosticsAtom = Atom.make( · 6 unmodified lines
2328 Atom.withLabel("process-diagnostics"),
2429 );
2530
31+function formatProcessResourceHistoryKey(input: {
32+ readonly windowMs: number;
33+ readonly bucketMs: number;
34+}): string {
35+ return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`;
36+}
37+
38+function parseProcessResourceHistoryKey(key: string): {
39+ readonly windowMs: number;
40+ readonly bucketMs: number;
41+} {
42+ const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR);
43+ return {
44+ windowMs: Number(windowMs),
45+ bucketMs: Number(bucketMs),
46+ };
47+}
48+
49+const processResourceHistoryAtom = Atom.family((key: string) => {
50+ const input = parseProcessResourceHistoryKey(key);
51+ return Atom.make(
52+ Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)),
53+ ).pipe(
54+ Atom.swr({
55+ staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS,
56+ revalidateOnMount: true,
57+ }),
58+ Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS),
59+ Atom.withLabel(`process-resource-history:${key}`),
60+ );
61+});
62+
2663 export interface ProcessDiagnosticsState {
2764 readonly data: ServerProcessDiagnosticsResult | null;
2865 readonly error: string | null;
@@ -30,6 +67,13 @@ export interface ProcessDiagnosticsState { · 1 unmodified line
3067 readonly refresh: () => void;
3168 }
3269
70+export interface ProcessResourceHistoryState {
71+ readonly data: ServerProcessResourceHistoryResult | null;
72+ readonly error: string | null;
73+ readonly isPending: boolean;
74+ readonly refresh: () => void;
75+}
76+
3377 function formatProcessDiagnosticsError(error: unknown): string {
3478 return error instanceof Error ? error.message : "Failed to load process diagnostics.";
3579 }
@@ -45,6 +89,17 @@ function readProcessDiagnosticsError( · 9 unmodified lines
4589 return formatProcessDiagnosticsError(squashed);
4690 }
4791
92+function readProcessResourceHistoryError(
93+ result: AsyncResult.AsyncResult<ServerProcessResourceHistoryResult, unknown>,
94+): string | null {
95+ if (result._tag !== "Failure") {
96+ return null;
97+ }
98+
99+ const squashed = Cause.squash(result.cause);
100+ return formatProcessDiagnosticsError(squashed);
101+}
102+
48103 export function refreshProcessDiagnostics(): void {
49104 appAtomRegistry.refresh(processDiagnosticsAtom);
50105 }
@@ -63,3 +118,23 @@ export function useProcessDiagnostics(): ProcessDiagnosticsState { · 12 unmodified lines
63118 refresh,
64119 };
65120 }
121+
122+export function useProcessResourceHistory(input: {
123+ readonly windowMs: number;
124+ readonly bucketMs: number;
125+}): ProcessResourceHistoryState {
126+ const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input));
127+ const result = useAtomValue(atom);
128+ const data = Option.getOrNull(AsyncResult.value(result));
129+
130+ const refresh = useCallback(() => {
131+ appAtomRegistry.refresh(atom);
132+ }, [atom]);
133+
134+ return {
135+ data,
136+ error: readProcessResourceHistoryError(result),
137+ isPending: result.waiting,
138+ refresh,
139+ };
140+}
apps/web/src/localApi.ts+4−0
@@ -155,6 +155,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { · 154 unmodified lines
155155 rpcClient
156156 ? rpcClient.server.getProcessDiagnostics()
157157 : Promise.reject(unavailableLocalBackendError()),
158+ getProcessResourceHistory: (input) =>
159+ rpcClient
160+ ? rpcClient.server.getProcessResourceHistory(input)
161+ : Promise.reject(unavailableLocalBackendError()),
158162 signalProcess: (input) =>
159163 rpcClient
160164 ? rpcClient.server.signalProcess(input)
apps/web/src/rpc/wsRpcClient.ts+9−0
@@ -135,6 +135,9 @@ export interface WsRpcClient { · 134 unmodified lines
135135 readonly getProcessDiagnostics: RpcUnaryNoArgMethod<
136136 typeof WS_METHODS.serverGetProcessDiagnostics
137137 >;
138+ readonly getProcessResourceHistory: RpcUnaryMethod<
139+ typeof WS_METHODS.serverGetProcessResourceHistory
140+ >;
138141 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
139142 readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>;
140143 readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>;
@@ -266,6 +269,12 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { · 125 unmodified lines
266269 transport.request((client) =>
267270 client[WS_METHODS.serverGetProcessDiagnostics]({}).pipe(Effect.withTracerEnabled(false)),
268271 ),
272+ getProcessResourceHistory: (input) =>
273+ transport.request((client) =>
274+ client[WS_METHODS.serverGetProcessResourceHistory](input).pipe(
275+ Effect.withTracerEnabled(false),
276+ ),
277+ ),
269278 signalProcess: (input) =>
270279 transport.request((client) =>
271280 client[WS_METHODS.serverSignalProcess](input).pipe(Effect.withTracerEnabled(false)),
packages/contracts/src/ipc.ts+5−0
@@ -29,6 +29,8 @@ import type { ProviderInstanceId } from "./providerInstance.ts"; · 28 unmodified lines
2929 import type {
3030 ServerConfig,
3131 ServerProcessDiagnosticsResult,
32+ ServerProcessResourceHistoryInput,
33+ ServerProcessResourceHistoryResult,
3234 ServerProviderUpdateInput,
3335 ServerProviderUpdatedPayload,
3436 ServerRemoveKeybindingResult,
@@ -475,6 +477,9 @@ export interface LocalApi { · 440 unmodified lines
475477 discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
476478 getTraceDiagnostics: () => Promise<ServerTraceDiagnosticsResult>;
477479 getProcessDiagnostics: () => Promise<ServerProcessDiagnosticsResult>;
480+ getProcessResourceHistory: (
481+ input: ServerProcessResourceHistoryInput,
482+ ) => Promise<ServerProcessResourceHistoryResult>;
478483 signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>;
479484 };
480485 }
packages/contracts/src/rpc.ts+12−0
@@ -79,6 +79,8 @@ import { · 78 unmodified lines
7979 ServerProviderUpdatedPayload,
8080 ServerTraceDiagnosticsResult,
8181 ServerProcessDiagnosticsResult,
82+ ServerProcessResourceHistoryInput,
83+ ServerProcessResourceHistoryResult,
8284 ServerSignalProcessInput,
8385 ServerSignalProcessResult,
8486 ServerUpsertKeybindingInput,
@@ -145,6 +147,7 @@ export const WS_METHODS = { · 60 unmodified lines
145147 serverDiscoverSourceControl: "server.discoverSourceControl",
146148 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
147149 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
150+ serverGetProcessResourceHistory: "server.getProcessResourceHistory",
148151 serverSignalProcess: "server.signalProcess",
149152
150153 // Source control methods
@@ -224,6 +227,14 @@ export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetPro · 73 unmodified lines
224227 success: ServerProcessDiagnosticsResult,
225228 });
226229
230+export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
231+ WS_METHODS.serverGetProcessResourceHistory,
232+ {
233+ payload: ServerProcessResourceHistoryInput,
234+ success: ServerProcessResourceHistoryResult,
235+ },
236+);
237+
227238 export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
228239 payload: ServerSignalProcessInput,
229240 success: ServerSignalProcessResult,
@@ -472,6 +483,7 @@ export const WsRpcGroup = RpcGroup.make( · 242 unmodified lines
472483 WsServerDiscoverSourceControlRpc,
473484 WsServerGetTraceDiagnosticsRpc,
474485 WsServerGetProcessDiagnosticsRpc,
486+ WsServerGetProcessResourceHistoryRpc,
475487 WsServerSignalProcessRpc,
476488 WsSourceControlLookupRepositoryRpc,
477489 WsSourceControlCloneRepositoryRpc,
packages/contracts/src/server.ts+52−0
@@ -328,6 +328,58 @@ export const ServerProcessDiagnosticsResult = Schema.Struct({ · 327 unmodified lines
328328 });
329329 export type ServerProcessDiagnosticsResult = typeof ServerProcessDiagnosticsResult.Type;
330330
331+export const ServerProcessResourceHistoryInput = Schema.Struct({
332+ windowMs: NonNegativeInt,
333+ bucketMs: NonNegativeInt,
334+});
335+export type ServerProcessResourceHistoryInput = typeof ServerProcessResourceHistoryInput.Type;
336+
337+export const ServerProcessResourceHistoryBucket = Schema.Struct({
338+ startedAt: Schema.DateTimeUtc,
339+ endedAt: Schema.DateTimeUtc,
340+ avgCpuPercent: Schema.Number,
341+ maxCpuPercent: Schema.Number,
342+ maxRssBytes: NonNegativeInt,
343+ maxProcessCount: NonNegativeInt,
344+});
345+export type ServerProcessResourceHistoryBucket = typeof ServerProcessResourceHistoryBucket.Type;
346+
347+export const ServerProcessResourceHistorySummary = Schema.Struct({
348+ processKey: TrimmedNonEmptyString,
349+ pid: PositiveInt,
350+ ppid: NonNegativeInt,
351+ command: TrimmedNonEmptyString,
352+ depth: NonNegativeInt,
353+ isServerRoot: Schema.Boolean,
354+ firstSeenAt: Schema.DateTimeUtc,
355+ lastSeenAt: Schema.DateTimeUtc,
356+ currentCpuPercent: Schema.Number,
357+ avgCpuPercent: Schema.Number,
358+ maxCpuPercent: Schema.Number,
359+ cpuSecondsApprox: Schema.Number,
360+ currentRssBytes: NonNegativeInt,
361+ maxRssBytes: NonNegativeInt,
362+ sampleCount: NonNegativeInt,
363+});
364+export type ServerProcessResourceHistorySummary = typeof ServerProcessResourceHistorySummary.Type;
365+
366+export const ServerProcessResourceHistoryResult = Schema.Struct({
367+ readAt: Schema.DateTimeUtc,
368+ windowMs: NonNegativeInt,
369+ bucketMs: NonNegativeInt,
370+ sampleIntervalMs: NonNegativeInt,
371+ retainedSampleCount: NonNegativeInt,
372+ totalCpuSecondsApprox: Schema.Number,
373+ buckets: Schema.Array(ServerProcessResourceHistoryBucket),
374+ topProcesses: Schema.Array(ServerProcessResourceHistorySummary),
375+ error: Schema.Option(
376+ Schema.Struct({
377+ message: TrimmedNonEmptyString,
378+ }),
379+ ),
380+});
381+export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHistoryResult.Type;
382+
331383 export const ServerSignalProcessInput = Schema.Struct({
332384 pid: PositiveInt,
333385 signal: ServerProcessSignal,