From 31a67ee1ccc718d927dd5a58f29b3412d3ddb56b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 13 May 2026 20:56:42 -0700 Subject: [PATCH 1/3] add diagnostics resource history --- .../src/diagnostics/ProcessDiagnostics.ts | 8 +- .../ProcessResourceMonitor.test.ts | 129 +++++++ .../src/diagnostics/ProcessResourceMonitor.ts | 330 ++++++++++++++++++ .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/server.test.ts | 17 + apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 10 + .../settings/DiagnosticsSettings.tsx | 278 ++++++++++++++- apps/web/src/lib/processDiagnosticsState.ts | 72 +++- apps/web/src/localApi.ts | 4 + apps/web/src/rpc/wsRpcClient.ts | 9 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 12 + packages/contracts/src/server.ts | 52 +++ 14 files changed, 921 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/diagnostics/ProcessResourceMonitor.test.ts create mode 100644 apps/server/src/diagnostics/ProcessResourceMonitor.ts diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 7730b8c7d6b..f56bf216513 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -15,7 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; -interface ProcessRow { +export interface ProcessRow { readonly pid: number; readonly ppid: number; readonly pgid: number | null; @@ -186,7 +186,7 @@ function parseWindowsProcessRows(output: string): ReadonlyArray { } } -function buildDescendantEntries( +export function buildDescendantEntries( rows: ReadonlyArray, serverPid: number, ): ReadonlyArray { @@ -230,7 +230,7 @@ function buildDescendantEntries( return entries; } -function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { +export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { if (row.ppid !== serverPid) return false; const command = row.command.trim(); @@ -370,7 +370,7 @@ function readWindowsProcessRows(): Effect.Effect< ); } -const readProcessRows = (platform = process.platform) => +export const readProcessRows = (platform = process.platform) => platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); export function aggregateProcessDiagnostics(input: { diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts new file mode 100644 index 00000000000..22b061da8b2 --- /dev/null +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { + aggregateProcessResourceHistory, + collectMonitoredSamples, +} from "./ProcessResourceMonitor.ts"; + +describe("ProcessResourceMonitor", () => { + it.effect("samples the server root process and descendants", () => + Effect.sync(() => { + const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const samples = collectMonitoredSamples({ + serverPid: 100, + sampledAt, + sampledAtMs: DateTime.toEpochMillis(sampledAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 2, + rssBytes: 1_000, + elapsed: "01:00", + command: "t3 server", + }, + { + pid: 101, + ppid: 100, + pgid: 100, + status: "S", + cpuPercent: 10, + rssBytes: 2_000, + elapsed: "00:20", + command: "codex app-server", + }, + { + pid: 102, + ppid: 101, + pgid: 100, + status: "R", + cpuPercent: 50, + rssBytes: 3_000, + elapsed: "00:05", + command: "rg needle", + }, + { + pid: 200, + ppid: 1, + pgid: 200, + status: "R", + cpuPercent: 99, + rssBytes: 9_000, + elapsed: "00:05", + command: "unrelated", + }, + ], + }); + + expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]); + expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]); + expect(samples[0]?.isServerRoot).toBe(true); + expect(samples[1]?.isServerRoot).toBe(false); + }), + ); + + it.effect("rolls samples up by process and CPU time", () => + Effect.sync(() => { + const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); + const samples = [ + ...collectMonitoredSamples({ + serverPid: 100, + sampledAt: firstAt, + sampledAtMs: DateTime.toEpochMillis(firstAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 10, + rssBytes: 1_000, + elapsed: "01:00", + command: "t3 server", + }, + ], + }), + ...collectMonitoredSamples({ + serverPid: 100, + sampledAt: secondAt, + sampledAtMs: DateTime.toEpochMillis(secondAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 30, + rssBytes: 2_000, + elapsed: "01:05", + command: "t3 server", + }, + ], + }), + ]; + + const result = aggregateProcessResourceHistory({ + samples, + readAt: secondAt, + readAtMs: DateTime.toEpochMillis(secondAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: null, + }); + + expect(Option.isNone(result.error)).toBe(true); + expect(result.topProcesses).toHaveLength(1); + expect(result.topProcesses[0]?.avgCpuPercent).toBe(20); + expect(result.topProcesses[0]?.maxCpuPercent).toBe(30); + expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2); + expect(result.totalCpuSecondsApprox).toBe(2); + expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true); + }), + ); +}); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts new file mode 100644 index 00000000000..2f8f050b626 --- /dev/null +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -0,0 +1,330 @@ +import type { + ServerProcessResourceHistoryBucket, + ServerProcessResourceHistoryInput, + ServerProcessResourceHistoryResult, + ServerProcessResourceHistorySummary, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildDescendantEntries, + isDiagnosticsQueryProcess, + type ProcessRow, + readProcessRows, +} from "./ProcessDiagnostics.ts"; + +const SAMPLE_INTERVAL_MS = 5_000; +const RETENTION_MS = 60 * 60_000; +const MAX_RETAINED_SAMPLES = 20_000; +const DEFAULT_TOP_PROCESS_LIMIT = 30; + +export interface ProcessResourceSample { + readonly sampledAt: DateTime.Utc; + readonly sampledAtMs: number; + readonly processKey: string; + readonly pid: number; + readonly ppid: number; + readonly command: string; + readonly cpuPercent: number; + readonly rssBytes: number; + readonly depth: number; + readonly isServerRoot: boolean; +} + +interface MonitorState { + readonly samples: ReadonlyArray; + readonly lastError: string | null; +} + +export interface ProcessResourceMonitorShape { + readonly readHistory: ( + input: ServerProcessResourceHistoryInput, + ) => Effect.Effect; +} + +export class ProcessResourceMonitor extends Context.Service< + ProcessResourceMonitor, + ProcessResourceMonitorShape +>()("t3/diagnostics/ProcessResourceMonitor") {} + +function dateTimeFromMillis(ms: number): DateTime.Utc { + return DateTime.makeUnsafe(ms); +} + +function parseElapsedMs(elapsed: string): number | null { + const [daysPrefix, timeText] = elapsed.includes("-") + ? (elapsed.split("-", 2) as [string, string]) + : (["0", elapsed] as const); + const days = Number.parseInt(daysPrefix, 10); + if (!Number.isFinite(days) || days < 0) return null; + + const parts = timeText.split(":"); + const secondsText = parts.pop(); + if (!secondsText) return null; + const seconds = Number.parseFloat(secondsText); + if (!Number.isFinite(seconds) || seconds < 0) return null; + + const minutesText = parts.pop(); + const minutes = minutesText === undefined ? 0 : Number.parseInt(minutesText, 10); + if (!Number.isFinite(minutes) || minutes < 0) return null; + + const hoursText = parts.pop(); + const hours = hoursText === undefined ? 0 : Number.parseInt(hoursText, 10); + if (!Number.isFinite(hours) || hours < 0 || parts.length > 0) return null; + + return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1_000; +} + +function sampleKey( + row: Pick, + sampledAtMs: number, +): string { + const elapsedMs = parseElapsedMs(row.elapsed); + const startedAtMs = elapsedMs === null ? null : Math.round((sampledAtMs - elapsedMs) / 1_000); + return `${row.pid}:${startedAtMs ?? "unknown"}:${row.command}`; +} + +function findServerRootRow(rows: ReadonlyArray, serverPid: number): ProcessRow | null { + return rows.find((row) => row.pid === serverPid) ?? null; +} + +export function collectMonitoredSamples(input: { + readonly rows: ReadonlyArray; + readonly serverPid: number; + readonly sampledAt: DateTime.Utc; + readonly sampledAtMs: number; +}): ReadonlyArray { + const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); + const root = findServerRootRow(rows, input.serverPid); + const descendants = buildDescendantEntries(rows, input.serverPid); + const samples: ProcessResourceSample[] = []; + + if (root) { + samples.push({ + sampledAt: input.sampledAt, + sampledAtMs: input.sampledAtMs, + processKey: sampleKey(root, input.sampledAtMs), + pid: root.pid, + ppid: root.ppid, + command: root.command, + cpuPercent: root.cpuPercent, + rssBytes: root.rssBytes, + depth: 0, + isServerRoot: true, + }); + } + + for (const process of descendants) { + samples.push({ + sampledAt: input.sampledAt, + sampledAtMs: input.sampledAtMs, + processKey: sampleKey(process, input.sampledAtMs), + pid: process.pid, + ppid: process.ppid, + command: process.command, + cpuPercent: process.cpuPercent, + rssBytes: process.rssBytes, + depth: process.depth + 1, + isServerRoot: false, + }); + } + + return samples; +} + +function trimSamples( + samples: ReadonlyArray, + nowMs: number, +): ReadonlyArray { + const minSampledAtMs = nowMs - RETENTION_MS; + const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); + return retained.length <= MAX_RETAINED_SAMPLES + ? retained + : retained.slice(retained.length - MAX_RETAINED_SAMPLES); +} + +function summarizeProcesses( + samples: ReadonlyArray, +): ReadonlyArray { + const groups = new Map(); + for (const sample of samples) { + const processSamples = groups.get(sample.processKey) ?? []; + processSamples.push(sample); + groups.set(sample.processKey, processSamples); + } + + return [...groups.entries()] + .map(([processKey, processSamples]) => { + const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs); + const first = sorted[0]!; + const latest = sorted[sorted.length - 1]!; + const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0); + const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent)); + const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes)); + const cpuSecondsApprox = sorted.reduce( + (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), + 0, + ); + + return { + processKey, + pid: latest.pid, + ppid: latest.ppid, + command: latest.command, + depth: latest.depth, + isServerRoot: latest.isServerRoot, + firstSeenAt: first.sampledAt, + lastSeenAt: latest.sampledAt, + currentCpuPercent: latest.cpuPercent, + avgCpuPercent: cpuPercentTotal / sorted.length, + maxCpuPercent, + cpuSecondsApprox, + currentRssBytes: latest.rssBytes, + maxRssBytes, + sampleCount: sorted.length, + } satisfies ServerProcessResourceHistorySummary; + }) + .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox) + .slice(0, DEFAULT_TOP_PROCESS_LIMIT); +} + +function buildBuckets(input: { + readonly samples: ReadonlyArray; + readonly nowMs: number; + readonly windowMs: number; + readonly bucketMs: number; +}): ReadonlyArray { + const bucketMs = Math.max(1_000, input.bucketMs); + const windowStartMs = input.nowMs - input.windowMs; + const buckets: ServerProcessResourceHistoryBucket[] = []; + + for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) { + const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs); + const bucketSamples = input.samples.filter( + (sample) => + sample.sampledAtMs >= startedAtMs && + (endedAtMs === input.nowMs + ? sample.sampledAtMs <= endedAtMs + : sample.sampledAtMs < endedAtMs), + ); + const samplesByRead = new Map(); + for (const sample of bucketSamples) { + const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? []; + samplesAtTime.push(sample); + samplesByRead.set(sample.sampledAtMs, samplesAtTime); + } + + const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({ + cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0), + rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0), + processCount: samplesAtTime.length, + })); + const avgCpuPercent = + readTotals.length === 0 + ? 0 + : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length; + + buckets.push({ + startedAt: dateTimeFromMillis(startedAtMs), + endedAt: dateTimeFromMillis(endedAtMs), + avgCpuPercent, + maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0, + maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0, + maxProcessCount: readTotals.length + ? Math.max(...readTotals.map((read) => read.processCount)) + : 0, + }); + } + + return buckets; +} + +export function aggregateProcessResourceHistory(input: { + readonly samples: ReadonlyArray; + readonly readAt: DateTime.Utc; + readonly readAtMs: number; + readonly windowMs: number; + readonly bucketMs: number; + readonly lastError: string | null; +}): ServerProcessResourceHistoryResult { + const windowMs = Math.max(1_000, input.windowMs); + const bucketMs = Math.max(1_000, input.bucketMs); + const minSampledAtMs = input.readAtMs - windowMs; + const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs); + const topProcesses = summarizeProcesses(samples); + const totalCpuSecondsApprox = samples.reduce( + (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000), + 0, + ); + + return { + readAt: input.readAt, + windowMs, + bucketMs, + sampleIntervalMs: SAMPLE_INTERVAL_MS, + retainedSampleCount: input.samples.length, + totalCpuSecondsApprox, + buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), + topProcesses, + error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(), + }; +} + +export const make = Effect.fn("makeProcessResourceMonitor")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const state = yield* Ref.make({ samples: [], lastError: null }); + + const sampleOnce = Effect.gen(function* () { + const sampledAt = yield* DateTime.now; + const sampledAtMs = DateTime.toEpochMillis(sampledAt); + const rows = yield* readProcessRows().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const samples = collectMonitoredSamples({ + rows, + serverPid: process.pid, + sampledAt, + sampledAtMs, + }); + yield* Ref.update(state, (current) => ({ + samples: trimSamples([...current.samples, ...samples], sampledAtMs), + lastError: null, + })); + }).pipe( + Effect.catch((error: unknown) => + Ref.update(state, (current) => ({ + ...current, + lastError: error instanceof Error ? error.message : "Failed to sample process resources.", + })), + ), + ); + + yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( + Effect.forkScoped, + ); + + const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) => + Effect.gen(function* () { + const readAt = yield* DateTime.now; + const readAtMs = DateTime.toEpochMillis(readAt); + const current = yield* Ref.get(state); + return aggregateProcessResourceHistory({ + samples: current.samples, + readAt, + readAtMs, + windowMs: input.windowMs, + bucketMs: input.bucketMs, + lastError: current.lastError, + }); + }); + + return ProcessResourceMonitor.of({ readHistory }); +}); + +export const layer = Layer.effect(ProcessResourceMonitor, make()); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index e0ea5859af0..edbd705b3ee 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -18,6 +18,7 @@ const DEFAULT_RPC_SPAN_ATTRIBUTES = { const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ WS_METHODS.serverGetTraceDiagnostics, WS_METHODS.serverGetProcessDiagnostics, + WS_METHODS.serverGetProcessResourceHistory, WS_METHODS.serverSignalProcess, ]); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8394897c48c..fcbdf9504ce 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -115,6 +115,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as Data from "effect/Data"; @@ -564,6 +565,22 @@ const buildAppUnderTest = (options?: { }), }), ), + Layer.provide( + Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ + readHistory: (input) => + Effect.succeed({ + readAt: TEST_EPOCH, + windowMs: input.windowMs, + bucketMs: input.bucketMs, + sampleIntervalMs: 5_000, + retainedSampleCount: 0, + totalCpuSecondsApprox: 0, + buckets: [], + topProcesses: [], + error: Option.none(), + }), + }), + ), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 813cdae7317..dee6e88cae7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -76,6 +76,7 @@ import { import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { @@ -280,6 +281,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(ProcessDiagnostics.layer), + Layer.provideMerge(ProcessResourceMonitor.layer), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(OpenLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 001c9baff9e..7b54ce60094 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -66,6 +66,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; @@ -193,6 +194,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -909,6 +911,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetProcessResourceHistory]: (input) => + observeRpcEffect( + WS_METHODS.serverGetProcessResourceHistory, + processResourceMonitor.readHistory(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 394c957166c..086be696983 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -8,7 +8,11 @@ import { RefreshCwIcon, } from "lucide-react"; import { useCallback, useMemo, useState, type ReactNode } from "react"; -import type { ServerProcessDiagnosticsEntry, ServerProcessSignal } from "@t3tools/contracts"; +import type { + ServerProcessDiagnosticsEntry, + ServerProcessResourceHistorySummary, + ServerProcessSignal, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -17,7 +21,10 @@ import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTime } from "../../timestampFormat"; import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; -import { useProcessDiagnostics } from "../../lib/processDiagnosticsState"; +import { + useProcessDiagnostics, + useProcessResourceHistory, +} from "../../lib/processDiagnosticsState"; import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; @@ -502,6 +509,195 @@ function ProcessDiagnosticsTable({ ); } +const RESOURCE_HISTORY_WINDOWS = [ + { label: "5m", windowMs: 5 * 60_000, bucketMs: 30_000 }, + { label: "15m", windowMs: 15 * 60_000, bucketMs: 60_000 }, + { label: "30m", windowMs: 30 * 60_000, bucketMs: 2 * 60_000 }, + { label: "1h", windowMs: 60 * 60_000, bucketMs: 5 * 60_000 }, +] as const; + +function formatCpuTime(seconds: number): string { + if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; + const minutes = seconds / 60; + if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; + return `${(minutes / 60).toFixed(2)}h`; +} + +function formatShortProcessName(command: string): string { + const name = formatProcessName(command); + return name.length > 42 ? `${name.slice(0, 39)}...` : name; +} + +function ProcessResourceHistoryChart({ + buckets, +}: { + buckets: ReadonlyArray<{ + readonly startedAt: DateTime.Utc; + readonly avgCpuPercent: number; + readonly maxCpuPercent: number; + }>; +}) { + const maxCpuPercent = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent)); + + return ( +
+
+ {buckets.map((bucket) => { + const height = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100); + return ( + + +
+
+ } + /> + + Avg {bucket.avgCpuPercent.toFixed(1)}%, peak {bucket.maxCpuPercent.toFixed(1)}% + +
+ ); + })} +
+
+ ); +} + +function ResourceHistoryWindowSelector({ + selectedWindowMs, + onSelect, +}: { + selectedWindowMs: number; + onSelect: (windowMs: number) => void; +}) { + return ( +
+ {RESOURCE_HISTORY_WINDOWS.map((option) => ( + + ))} +
+ ); +} + +function ProcessResourceHistoryTable({ + processes, + emptyLabel, +}: { + processes: ReadonlyArray; + emptyLabel: string; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {processes.length === 0 ? ( + + + + ) : null} + {processes.map((process) => ( + + + + + + + + + + + ))} + +
ProcessCPU TimeCurrentAveragePeakMax MemCommandPID
+ {emptyLabel} +
+
+ + {process.isServerRoot ? "Root" : "Child"} + + + {formatShortProcessName(process.command)} + +
+
+ {formatCpuTime(process.cpuSecondsApprox)} + + {process.currentCpuPercent.toFixed(1)}% + + {process.avgCpuPercent.toFixed(1)}% + + {process.maxCpuPercent.toFixed(1)}% + + {formatBytes(process.maxRssBytes)} + + + {process.command}} + /> + + {process.command} + + + + {process.pid} +
+
+ ); +} + function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { useRelativeTimeTick(); const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; @@ -556,6 +752,10 @@ function DiagnosticsRefreshButton({ export function DiagnosticsSettingsPanel() { const observability = useServerObservability(); const availableEditors = useServerAvailableEditors(); + const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); + const selectedResourceWindow = + RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? + RESOURCE_HISTORY_WINDOWS[1]; const { data, error, isPending, refresh } = useTraceDiagnostics(); const { data: processData, @@ -563,6 +763,15 @@ export function DiagnosticsSettingsPanel() { isPending: isProcessPending, refresh: refreshProcesses, } = useProcessDiagnostics(); + const { + data: resourceData, + error: resourceError, + isPending: isResourcePending, + refresh: refreshResources, + } = useProcessResourceHistory({ + windowMs: selectedResourceWindow.windowMs, + bucketMs: selectedResourceWindow.bucketMs, + }); const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); const [signalingPid, setSignalingPid] = useState(null); @@ -643,6 +852,7 @@ export function DiagnosticsSettingsPanel() { ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; + const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null; const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null; const traceDiagnosticsPartialFailure = data ? Option.getOrElse(data.partialFailure, () => false) @@ -711,6 +921,70 @@ export function DiagnosticsSettingsPanel() { /> + + + + + + } + > + + + + + + + {processResourceError || resourceError ? ( +
+ {processResourceError ? ( +
+ + {processResourceError.message} +
+ ) : null} + {resourceError ? ( +
+ + {resourceError} +
+ ) : null} +
+ ) : null} + + +
+ ensureLocalApi().server.getProcessDiagnostics()), @@ -30,6 +34,13 @@ export interface ProcessDiagnosticsState { readonly refresh: () => void; } +export interface ProcessResourceHistoryState { + readonly data: ServerProcessResourceHistoryResult | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + function formatProcessDiagnosticsError(error: unknown): string { return error instanceof Error ? error.message : "Failed to load process diagnostics."; } @@ -63,3 +74,60 @@ export function useProcessDiagnostics(): ProcessDiagnosticsState { refresh, }; } + +export function useProcessResourceHistory(input: { + readonly windowMs: number; + readonly bucketMs: number; +}): ProcessResourceHistoryState { + const { bucketMs, windowMs } = input; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(true); + + const refresh = useCallback(() => { + setIsPending(true); + void ensureLocalApi() + .server.getProcessResourceHistory({ bucketMs, windowMs }) + .then((result) => { + setData(result); + setError(null); + }) + .catch((cause: unknown) => { + setError(formatProcessDiagnosticsError(cause)); + }) + .finally(() => { + setIsPending(false); + }); + }, [bucketMs, windowMs]); + + useEffect(() => { + let isMounted = true; + const read = () => { + setIsPending(true); + void ensureLocalApi() + .server.getProcessResourceHistory({ bucketMs, windowMs }) + .then((result) => { + if (!isMounted) return; + setData(result); + setError(null); + }) + .catch((cause: unknown) => { + if (!isMounted) return; + setError(formatProcessDiagnosticsError(cause)); + }) + .finally(() => { + if (!isMounted) return; + setIsPending(false); + }); + }; + + read(); + const interval = window.setInterval(read, PROCESS_RESOURCE_HISTORY_REFRESH_MS); + return () => { + isMounted = false; + window.clearInterval(interval); + }; + }, [bucketMs, windowMs]); + + return { data, error, isPending, refresh }; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cbb3427b004..ba097d2ff6b 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -155,6 +155,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { rpcClient ? rpcClient.server.getProcessDiagnostics() : Promise.reject(unavailableLocalBackendError()), + getProcessResourceHistory: (input) => + rpcClient + ? rpcClient.server.getProcessResourceHistory(input) + : Promise.reject(unavailableLocalBackendError()), signalProcess: (input) => rpcClient ? rpcClient.server.signalProcess(input) diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 2f1ca624d98..900c8d1e4b7 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -134,6 +134,9 @@ export interface WsRpcClient { readonly getProcessDiagnostics: RpcUnaryNoArgMethod< typeof WS_METHODS.serverGetProcessDiagnostics >; + readonly getProcessResourceHistory: RpcUnaryMethod< + typeof WS_METHODS.serverGetProcessResourceHistory + >; readonly signalProcess: RpcUnaryMethod; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; @@ -264,6 +267,12 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({}).pipe(Effect.withTracerEnabled(false)), ), + getProcessResourceHistory: (input) => + transport.request((client) => + client[WS_METHODS.serverGetProcessResourceHistory](input).pipe( + Effect.withTracerEnabled(false), + ), + ), signalProcess: (input) => transport.request((client) => client[WS_METHODS.serverSignalProcess](input).pipe(Effect.withTracerEnabled(false)), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 58894adac1a..c0515aa9b26 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -29,6 +29,8 @@ import type { ProviderInstanceId } from "./providerInstance.ts"; import type { ServerConfig, ServerProcessDiagnosticsResult, + ServerProcessResourceHistoryInput, + ServerProcessResourceHistoryResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -475,6 +477,9 @@ export interface LocalApi { discoverSourceControl: () => Promise; getTraceDiagnostics: () => Promise; getProcessDiagnostics: () => Promise; + getProcessResourceHistory: ( + input: ServerProcessResourceHistoryInput, + ) => Promise; signalProcess: (input: ServerSignalProcessInput) => Promise; }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 705621b5dac..d36ba9fbcf4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -79,6 +79,8 @@ import { ServerProviderUpdatedPayload, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, + ServerProcessResourceHistoryInput, + ServerProcessResourceHistoryResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerUpsertKeybindingInput, @@ -145,6 +147,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverSignalProcess: "server.signalProcess", // Source control methods @@ -224,6 +227,14 @@ export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetPro success: ServerProcessDiagnosticsResult, }); +export const WsServerGetProcessResourceHistoryRpc = Rpc.make( + WS_METHODS.serverGetProcessResourceHistory, + { + payload: ServerProcessResourceHistoryInput, + success: ServerProcessResourceHistoryResult, + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -472,6 +483,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetProcessResourceHistoryRpc, WsServerSignalProcessRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0081c00ac7e..85ff4a4b2cb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -328,6 +328,58 @@ export const ServerProcessDiagnosticsResult = Schema.Struct({ }); export type ServerProcessDiagnosticsResult = typeof ServerProcessDiagnosticsResult.Type; +export const ServerProcessResourceHistoryInput = Schema.Struct({ + windowMs: NonNegativeInt, + bucketMs: NonNegativeInt, +}); +export type ServerProcessResourceHistoryInput = typeof ServerProcessResourceHistoryInput.Type; + +export const ServerProcessResourceHistoryBucket = Schema.Struct({ + startedAt: Schema.DateTimeUtc, + endedAt: Schema.DateTimeUtc, + avgCpuPercent: Schema.Number, + maxCpuPercent: Schema.Number, + maxRssBytes: NonNegativeInt, + maxProcessCount: NonNegativeInt, +}); +export type ServerProcessResourceHistoryBucket = typeof ServerProcessResourceHistoryBucket.Type; + +export const ServerProcessResourceHistorySummary = Schema.Struct({ + processKey: TrimmedNonEmptyString, + pid: PositiveInt, + ppid: NonNegativeInt, + command: TrimmedNonEmptyString, + depth: NonNegativeInt, + isServerRoot: Schema.Boolean, + firstSeenAt: Schema.DateTimeUtc, + lastSeenAt: Schema.DateTimeUtc, + currentCpuPercent: Schema.Number, + avgCpuPercent: Schema.Number, + maxCpuPercent: Schema.Number, + cpuSecondsApprox: Schema.Number, + currentRssBytes: NonNegativeInt, + maxRssBytes: NonNegativeInt, + sampleCount: NonNegativeInt, +}); +export type ServerProcessResourceHistorySummary = typeof ServerProcessResourceHistorySummary.Type; + +export const ServerProcessResourceHistoryResult = Schema.Struct({ + readAt: Schema.DateTimeUtc, + windowMs: NonNegativeInt, + bucketMs: NonNegativeInt, + sampleIntervalMs: NonNegativeInt, + retainedSampleCount: NonNegativeInt, + totalCpuSecondsApprox: Schema.Number, + buckets: Schema.Array(ServerProcessResourceHistoryBucket), + topProcesses: Schema.Array(ServerProcessResourceHistorySummary), + error: Schema.Option( + Schema.Struct({ + message: TrimmedNonEmptyString, + }), + ), +}); +export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHistoryResult.Type; + export const ServerSignalProcessInput = Schema.Struct({ pid: PositiveInt, signal: ServerProcessSignal, From 20a2a1c3355d97ee634699e7535015bb2561b068 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 14 May 2026 08:08:44 +0200 Subject: [PATCH 2/3] Improve diagnostics process grouping and display - Group sampled processes by PID and command to keep histories stable across elapsed-time drift - Show all process summaries in the window and refine the diagnostics table and chart UI - Add coverage for grouping drift and expanded summaries --- .../ProcessResourceMonitor.test.ts | 102 ++++++++++++++++ .../src/diagnostics/ProcessResourceMonitor.ts | 41 +------ .../settings/DiagnosticsSettings.tsx | 115 +++++++++++++----- 3 files changed, 191 insertions(+), 67 deletions(-) diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index 22b061da8b2..11d12c012db 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -126,4 +126,106 @@ describe("ProcessResourceMonitor", () => { expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true); }), ); + + it.effect("keeps a process grouped when elapsed time drifts between samples", () => + Effect.sync(() => { + const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); + const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); + const samples = [ + ...collectMonitoredSamples({ + serverPid: 100, + sampledAt: firstAt, + sampledAtMs: DateTime.toEpochMillis(firstAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 1, + rssBytes: 1_000, + elapsed: "01:00", + command: "t3 server", + }, + ], + }), + ...collectMonitoredSamples({ + serverPid: 100, + sampledAt: secondAt, + sampledAtMs: DateTime.toEpochMillis(secondAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 2, + rssBytes: 2_000, + elapsed: "01:06", + command: "t3 server", + }, + ], + }), + ]; + + const result = aggregateProcessResourceHistory({ + samples, + readAt: secondAt, + readAtMs: DateTime.toEpochMillis(secondAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: null, + }); + + expect(result.topProcesses).toHaveLength(1); + expect(result.topProcesses[0]?.isServerRoot).toBe(true); + expect(result.topProcesses[0]?.sampleCount).toBe(2); + expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000); + }), + ); + + it.effect("returns all process summaries in the selected window", () => + Effect.sync(() => { + const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const samples = collectMonitoredSamples({ + serverPid: 100, + sampledAt, + sampledAtMs: DateTime.toEpochMillis(sampledAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 1, + rssBytes: 1_000, + elapsed: "01:00", + command: "t3 server", + }, + ...Array.from({ length: 35 }, (_, index) => ({ + pid: 200 + index, + ppid: index === 0 ? 100 : 199 + index, + pgid: 100, + status: "S", + cpuPercent: 35 - index, + rssBytes: 2_000 + index, + elapsed: "00:10", + command: `worker ${index}`, + })), + ], + }); + + const result = aggregateProcessResourceHistory({ + samples, + readAt: sampledAt, + readAtMs: DateTime.toEpochMillis(sampledAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: null, + }); + + expect(result.topProcesses).toHaveLength(36); + expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); + }), + ); }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 2f8f050b626..2b6dfe8d362 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -22,7 +22,6 @@ import { const SAMPLE_INTERVAL_MS = 5_000; const RETENTION_MS = 60 * 60_000; const MAX_RETAINED_SAMPLES = 20_000; -const DEFAULT_TOP_PROCESS_LIMIT = 30; export interface ProcessResourceSample { readonly sampledAt: DateTime.Utc; @@ -57,37 +56,8 @@ function dateTimeFromMillis(ms: number): DateTime.Utc { return DateTime.makeUnsafe(ms); } -function parseElapsedMs(elapsed: string): number | null { - const [daysPrefix, timeText] = elapsed.includes("-") - ? (elapsed.split("-", 2) as [string, string]) - : (["0", elapsed] as const); - const days = Number.parseInt(daysPrefix, 10); - if (!Number.isFinite(days) || days < 0) return null; - - const parts = timeText.split(":"); - const secondsText = parts.pop(); - if (!secondsText) return null; - const seconds = Number.parseFloat(secondsText); - if (!Number.isFinite(seconds) || seconds < 0) return null; - - const minutesText = parts.pop(); - const minutes = minutesText === undefined ? 0 : Number.parseInt(minutesText, 10); - if (!Number.isFinite(minutes) || minutes < 0) return null; - - const hoursText = parts.pop(); - const hours = hoursText === undefined ? 0 : Number.parseInt(hoursText, 10); - if (!Number.isFinite(hours) || hours < 0 || parts.length > 0) return null; - - return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1_000; -} - -function sampleKey( - row: Pick, - sampledAtMs: number, -): string { - const elapsedMs = parseElapsedMs(row.elapsed); - const startedAtMs = elapsedMs === null ? null : Math.round((sampledAtMs - elapsedMs) / 1_000); - return `${row.pid}:${startedAtMs ?? "unknown"}:${row.command}`; +function sampleKey(row: Pick): string { + return `${row.pid}:${row.command}`; } function findServerRootRow(rows: ReadonlyArray, serverPid: number): ProcessRow | null { @@ -109,7 +79,7 @@ export function collectMonitoredSamples(input: { samples.push({ sampledAt: input.sampledAt, sampledAtMs: input.sampledAtMs, - processKey: sampleKey(root, input.sampledAtMs), + processKey: sampleKey(root), pid: root.pid, ppid: root.ppid, command: root.command, @@ -124,7 +94,7 @@ export function collectMonitoredSamples(input: { samples.push({ sampledAt: input.sampledAt, sampledAtMs: input.sampledAtMs, - processKey: sampleKey(process, input.sampledAtMs), + processKey: sampleKey(process), pid: process.pid, ppid: process.ppid, command: process.command, @@ -190,8 +160,7 @@ function summarizeProcesses( sampleCount: sorted.length, } satisfies ServerProcessResourceHistorySummary; }) - .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox) - .slice(0, DEFAULT_TOP_PROCESS_LIMIT); + .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox); } function buildBuckets(input: { diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 086be696983..3a36e2a51e5 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -316,7 +316,7 @@ function ProcessNameCell({ return (
{hasChildren ? (
); } @@ -429,7 +439,7 @@ function ProcessDiagnosticsTable({ chainVerticalScroll scrollFade hideScrollbars - className="w-full max-w-full rounded-none border-t border-border/60" + className="max-h-[min(64vh,44rem)] w-full max-w-full rounded-none border-t border-border/60" > @@ -441,7 +451,7 @@ function ProcessDiagnosticsTable({ - + @@ -449,7 +459,7 @@ function ProcessDiagnosticsTable({ - + @@ -494,7 +504,7 @@ function ProcessDiagnosticsTable({ -
Name CPUCommand PID TypeKillKill
{formatProcessType(process)} + 42 ? `${name.slice(0, 39)}...` : name; } +function ResourceHistoryProcessNameCell({ + process, + visualDepth, +}: { + process: ServerProcessResourceHistorySummary; + visualDepth: number; +}) { + const name = formatShortProcessName(process.command); + + return ( +
+
+ ); +} + function ProcessResourceHistoryChart({ buckets, }: { @@ -541,18 +588,28 @@ function ProcessResourceHistoryChart({ return (
-
+
{buckets.map((bucket) => { - const height = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100); + const peakHeight = Math.max(2, (bucket.maxCpuPercent / maxCpuPercent) * 100); + const averageHeight = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100); return (
+ className="relative h-full w-full" + aria-label={`Average CPU ${bucket.avgCpuPercent.toFixed(1)}%, peak CPU ${bucket.maxCpuPercent.toFixed(1)}%`} + > +
+
+
} /> @@ -600,12 +657,17 @@ function ProcessResourceHistoryTable({ processes: ReadonlyArray; emptyLabel: string; }) { + const shallowestChildDepth = processes.reduce((minDepth, process) => { + if (process.isServerRoot) return minDepth; + return minDepth === null ? process.depth : Math.min(minDepth, process.depth); + }, null); + return ( @@ -618,7 +680,7 @@ function ProcessResourceHistoryTable({ - + @@ -641,23 +703,14 @@ function ProcessResourceHistoryTable({ {processes.map((process) => (
Process CPU Time
-
- - {process.isServerRoot ? "Root" : "Child"} - - - {formatShortProcessName(process.command)} - -
+
{formatCpuTime(process.cpuSecondsApprox)} @@ -942,7 +995,7 @@ export function DiagnosticsSettingsPanel() { Date: Thu, 14 May 2026 09:06:12 +0200 Subject: [PATCH 3/3] Refactor process diagnostics history polling - Move resource history loading to atom-based SWR state - Add test coverage for the empty diagnostics history fixture --- .../settings/SettingsPanels.browser.tsx | 18 +++ apps/web/src/lib/processDiagnosticsState.ts | 109 ++++++++++-------- 2 files changed, 76 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4abefc5425b..3de78f34814 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -13,6 +13,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerConfig, + type ServerProcessResourceHistoryResult, type ServerProvider, type SourceControlDiscoveryResult, } from "@t3tools/contracts"; @@ -265,6 +266,20 @@ function makeUtc(value: string) { return DateTime.makeUnsafe(value); } +function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistoryResult { + return { + readAt: makeUtc("2036-04-07T00:00:00.000Z"), + windowMs: 15 * 60_000, + bucketMs: 60_000, + sampleIntervalMs: 5_000, + retainedSampleCount: 0, + totalCpuSecondsApprox: 0, + buckets: [], + topProcesses: [], + error: Option.none(), + }; +} + function makePairingLink(input: { readonly id: string; readonly credential: string; @@ -1062,6 +1077,9 @@ describe("GeneralSettingsPanel observability", () => { processes: [], error: Option.none(), }), + getProcessResourceHistory: vi + .fn() + .mockResolvedValue(createEmptyProcessResourceHistoryResult()), getTraceDiagnostics: vi.fn().mockResolvedValue({ traceFilePath: "/repo/project/.t3/traces.jsonl", scannedFilePaths: ["/repo/project/.t3/traces.jsonl"], diff --git a/apps/web/src/lib/processDiagnosticsState.ts b/apps/web/src/lib/processDiagnosticsState.ts index 474f0410e40..7e1b3d698a6 100644 --- a/apps/web/src/lib/processDiagnosticsState.ts +++ b/apps/web/src/lib/processDiagnosticsState.ts @@ -7,14 +7,15 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { ensureLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000; const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; -const PROCESS_RESOURCE_HISTORY_REFRESH_MS = 5_000; +const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000; +const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":"; const processDiagnosticsAtom = Atom.make( Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()), @@ -27,6 +28,38 @@ const processDiagnosticsAtom = Atom.make( Atom.withLabel("process-diagnostics"), ); +function formatProcessResourceHistoryKey(input: { + readonly windowMs: number; + readonly bucketMs: number; +}): string { + return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; +} + +function parseProcessResourceHistoryKey(key: string): { + readonly windowMs: number; + readonly bucketMs: number; +} { + const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR); + return { + windowMs: Number(windowMs), + bucketMs: Number(bucketMs), + }; +} + +const processResourceHistoryAtom = Atom.family((key: string) => { + const input = parseProcessResourceHistoryKey(key); + return Atom.make( + Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)), + ).pipe( + Atom.swr({ + staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), + Atom.withLabel(`process-resource-history:${key}`), + ); +}); + export interface ProcessDiagnosticsState { readonly data: ServerProcessDiagnosticsResult | null; readonly error: string | null; @@ -56,6 +89,17 @@ function readProcessDiagnosticsError( return formatProcessDiagnosticsError(squashed); } +function readProcessResourceHistoryError( + result: AsyncResult.AsyncResult, +): string | null { + if (result._tag !== "Failure") { + return null; + } + + const squashed = Cause.squash(result.cause); + return formatProcessDiagnosticsError(squashed); +} + export function refreshProcessDiagnostics(): void { appAtomRegistry.refresh(processDiagnosticsAtom); } @@ -79,55 +123,18 @@ export function useProcessResourceHistory(input: { readonly windowMs: number; readonly bucketMs: number; }): ProcessResourceHistoryState { - const { bucketMs, windowMs } = input; - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isPending, setIsPending] = useState(true); + const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input)); + const result = useAtomValue(atom); + const data = Option.getOrNull(AsyncResult.value(result)); const refresh = useCallback(() => { - setIsPending(true); - void ensureLocalApi() - .server.getProcessResourceHistory({ bucketMs, windowMs }) - .then((result) => { - setData(result); - setError(null); - }) - .catch((cause: unknown) => { - setError(formatProcessDiagnosticsError(cause)); - }) - .finally(() => { - setIsPending(false); - }); - }, [bucketMs, windowMs]); - - useEffect(() => { - let isMounted = true; - const read = () => { - setIsPending(true); - void ensureLocalApi() - .server.getProcessResourceHistory({ bucketMs, windowMs }) - .then((result) => { - if (!isMounted) return; - setData(result); - setError(null); - }) - .catch((cause: unknown) => { - if (!isMounted) return; - setError(formatProcessDiagnosticsError(cause)); - }) - .finally(() => { - if (!isMounted) return; - setIsPending(false); - }); - }; - - read(); - const interval = window.setInterval(read, PROCESS_RESOURCE_HISTORY_REFRESH_MS); - return () => { - isMounted = false; - window.clearInterval(interval); - }; - }, [bucketMs, windowMs]); - - return { data, error, isPending, refresh }; + appAtomRegistry.refresh(atom); + }, [atom]); + + return { + data, + error: readProcessResourceHistoryError(result), + isPending: result.waiting, + refresh, + }; }