Skip to content
Source
Sign inSign up
Open History to see the last commit

ResourceTelemetryDiagnostics.tsx

1,290 lines47.6 KB

1import { ProcessSignalActions } from "./ProcessSignalActions";
2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
3import { RefreshIcon } from "~/components/ui/refresh-icon";
4import {
5 ActivityIcon,
6 AlertTriangleIcon,
7 BatteryIcon,
8 CpuIcon,
9 DatabaseIcon,
10 GaugeIcon,
11 HardDriveIcon,
12 MemoryStickIcon,
13 ChevronDownIcon,
14 ChevronRightIcon,
15} from "lucide-react";
16import type {
17 BackgroundBooleanState,
18 EnvironmentId,
19 ResourceAttributionEntry,
20 ResourceTelemetryAggregate,
21 ResourceTelemetryHistoryBucket,
22 ResourceTelemetryIoSemantics,
23 ResourceTelemetryProcess,
24 ResourceTelemetryProcessCategory,
25 ResourceTelemetryProcessSummary,
26 ResourceTelemetrySourceHealth,
27 ResourceTelemetrySourceStatus,
28 ServerProcessSignal,
29} from "@t3tools/contracts";
30import * as DateTime from "effect/DateTime";
31import * as Option from "effect/Option";
32import { RegistryContext } from "@effect/atom-react";
33import { useContext, useRef, useState, type ReactNode } from "react";
34import {
35 isAtomCommandInterrupted,
36 squashAtomCommandFailure,
37} from "@t3tools/client-runtime/state/runtime";
38​
39import {
40 useResourceTelemetry,
41 useResourceTelemetryHistory,
42} from "../../lib/resourceTelemetryState";
43import { cn } from "../../lib/utils";
44import { formatBytes } from "../../lib/formatBytes";
45import { ensureLocalApi } from "../../localApi";
46import { serverEnvironment } from "../../state/server";
47import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
48import { useAtomCommand } from "../../state/use-atom-command";
49import { useRelativeTimeState } from "../../hooks/useRelativeTimeState";
50import { Button } from "../ui/button";
51import { ScrollArea } from "../ui/scroll-area";
52import { Toggle, ToggleGroup } from "../ui/toggle-group";
53import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
54import { toastManager } from "../ui/toast";
55import {
56 formatCpuTime,
57 formatSamplingStatus,
58 processIdentityKey,
59 resourceHistoryBarHeight,
60 resourceHistoryCpuScaleMax,
61 shouldShowResourceMonitorRetry,
62 visibleResourceTelemetryProcesses,
63} from "./ResourceTelemetryDiagnostics.logic";
64import { SettingsSection } from "./settingsLayout";
65​
66const HISTORY_WINDOWS = [
67 { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 },
68 { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 },
69 { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 },
70 { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 },
71] as const;
72​
73function formatRate(value: number): string {
74 return `${formatBytes(value)}/s`;
76​
77function formatDurationMicros(value: number): string {
78 if (value < 1_000) return `${Math.round(value)} µs`;
79 if (value < 1_000_000) return `${(value / 1_000).toFixed(2).replace(/\.0+$/, "")} ms`;
80 return `${(value / 1_000_000).toFixed(2).replace(/\.0+$/, "")} s`;
82​
83function formatProcessName(process: Pick<ResourceTelemetryProcess, "command" | "name">): string {
84 if (process.name.trim()) return process.name;
85 const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command;
86 const normalized = firstToken.replace(/^['"]|['"]$/g, "");
87 return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized;
89​
90function categoryLabel(category: ResourceTelemetryProcessCategory): string {
91 switch (category) {
92 case "server":
93 return "Server";
94 case "server-child":
95 return "Backend child";
96 case "provider-root":
97 return "Provider";
98 case "terminal-root":
99 return "Terminal";
100 case "electron-main":
101 return "Electron main";
102 case "electron-renderer":
103 return "Renderer";
104 case "electron-gpu":
105 return "GPU";
106 case "electron-utility":
107 return "Electron utility";
108 case "resource-monitor":
109 return "Monitor";
110 case "unknown-t3":
111 return "T3 process";
112 }
114​
115function categoryDotClass(category: ResourceTelemetryProcessCategory): string {
116 if (category === "resource-monitor") return "bg-warning";
117 if (category.startsWith("electron-")) return "bg-info";
118 if (category === "server") return "bg-violet-500";
119 return "bg-success";
121​
122function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string {
123 switch (semantics) {
124 case "storage":
125 return "Storage bytes";
126 case "logical":
127 return "Logical bytes";
128 case "all-io":
129 return "All I/O bytes";
130 case "unavailable":
131 return "Unavailable";
132 }
134​
135function booleanStateLabel(
136 value: BackgroundBooleanState,
137 labels: { readonly true: string; readonly false: string },
138): string {
139 if (value === "true") return labels.true;
140 if (value === "false") return labels.false;
141 return "Unknown";
143​
144function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" {
145 if (status === "healthy") return "default";
146 if (status === "starting" || status === "degraded") return "warning";
147 return "danger";
149​
150function SourceStatusBadge({
151 label,
152 status,
153 presentation,
154}: {
155 label: string;
156 status: ResourceTelemetrySourceStatus;
157 presentation?:
158 | {
159 readonly label: string;
160 readonly tone: "neutral";
161 }
162 | undefined;
163}) {
164 const tone = presentation?.tone ?? sourceStatusTone(status);
165 return (
166 <span
167 className={cn(
168 "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-3xs font-semibold uppercase tracking-widest",
169 tone === "neutral" && "border-border/70 bg-muted/45 text-muted-foreground",
170 tone === "default" && "border-success/25 bg-success/10 text-success-foreground",
171 tone === "warning" && "border-warning/30 bg-warning/10 text-warning-foreground",
172 tone === "danger" && "border-destructive/30 bg-destructive/10 text-destructive",
173 )}
174 >
175 <span
176 className={cn(
177 "size-1.5 rounded-full",
178 tone === "neutral" && "bg-muted-foreground/55",
179 tone === "default" && "bg-success",
180 tone === "warning" && "bg-warning",
181 tone === "danger" && "bg-destructive",
182 )}
183 />
184 {label} {presentation?.label ?? status}
185 </span>
186 );
188​
189function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) {
190 const relative = useRelativeTimeState(sampledAt ? DateTime.formatIso(sampledAt) : null);
191 if (relative.status !== "relative") {
192 return <span className="text-2xs text-muted-foreground/55">Waiting for sample</span>;
193 }
194 return (
195 <span className="text-2xs text-muted-foreground/60">
196 Updated <span className="font-mono tabular-nums">{relative.value}</span>
197 {relative.suffix ? ` ${relative.suffix}` : ""}
198 </span>
199 );
201​
202function IconStat({
203 icon,
204 label,
205 value,
206 detail,
207 tone = "default",
208}: {
209 icon: ReactNode;
210 label: string;
211 value: string;
212 detail?: string | undefined;
213 tone?: "default" | "warning" | "danger";
214}) {
215 return (
216 <div className="group min-w-0 px-4 py-4 sm:px-5">
217 <div className="flex items-center gap-2 text-3xs font-semibold uppercase tracking-widest text-muted-foreground/70">
218 <span className="text-muted-foreground/55 transition-colors group-hover:text-foreground/65">
219 {icon}
220 </span>
221 <span className="truncate">{label}</span>
222 </div>
223 <div
224 className={cn(
225 "mt-2.5 truncate font-mono text-2xl font-semibold tracking-tighter tabular-nums text-foreground",
226 tone === "warning" && "text-warning-foreground",
227 tone === "danger" && "text-destructive",
228 )}
229 >
230 {value}
231 </div>
232 {detail ? (
233 <div className="mt-1.5 truncate text-3xs text-muted-foreground/60">{detail}</div>
234 ) : null}
235 </div>
236 );
238​
239function AggregateCard({
240 label,
241 accentClass,
242 aggregate,
243}: {
244 label: string;
245 accentClass: string;
246 aggregate: ResourceTelemetryAggregate;
247}) {
248 return (
249 <div className="relative overflow-hidden border-t border-border/60 px-4 py-4 first:border-t-0 md:border-t-0 md:border-l md:first:border-l-0 sm:px-5">
250 <span className={cn("absolute inset-x-5 top-0 h-0.5 rounded-full opacity-75", accentClass)} />
251 <div className="flex items-center justify-between gap-3">
252 <div className="text-3xs font-semibold uppercase tracking-widest text-muted-foreground/75">
253 {label}
254 </div>
255 <div className="rounded-md bg-muted/55 px-1.5 py-0.5 font-mono text-3xs tabular-nums text-muted-foreground/70">
256 {aggregate.processCount} {aggregate.processCount === 1 ? "process" : "processes"}
257 </div>
258 </div>
259 <div className="mt-3.5 grid grid-cols-2 gap-x-4 gap-y-2.5">
260 <MetricPair label="CPU" value={`${aggregate.currentCpuPercent.toFixed(1)}%`} />
261 <MetricPair label="Memory" value={formatBytes(aggregate.currentRssBytes)} />
262 <MetricPair label="Read" value={formatRate(aggregate.ioReadBytesPerSecond)} />
263 <MetricPair label="Write" value={formatRate(aggregate.ioWriteBytesPerSecond)} />
264 </div>
265 </div>
266 );
268​
269function MetricPair({ label, value }: { label: string; value: string }) {
270 return (
271 <div className="min-w-0">
272 <div className="text-3xs font-semibold uppercase tracking-widest text-muted-foreground/45">
273 {label}
274 </div>
275 <div className="truncate font-mono text-xs font-medium tabular-nums text-foreground/90">
276 {value}
277 </div>
278 </div>
279 );
281​
282function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) {
283 const expectedInBrowser =
284 health.status === "unavailable" &&
285 Option.exists(health.lastError, (error) => error.includes("'web' mode"));
286 return (
287 <div className="flex items-start justify-between gap-4 border-t border-border/50 py-3 first:border-t-0">
288 <div className="min-w-0">
289 <div className="text-sm font-medium text-foreground">{label}</div>
290 <div className="mt-1 text-2xs leading-relaxed text-muted-foreground/65">
291 {expectedInBrowser
292 ? "Available when this page runs inside the desktop app."
293 : Option.match(health.lastError, {
294 onNone: () => "No reported errors",
295 onSome: (error) => error,
296 })}
297 </div>
298 </div>
299 <SourceStatusBadge
300 label=""
301 status={health.status}
302 presentation={
303 expectedInBrowser
304 ? {
305 label: "Desktop only",
306 tone: "neutral",
307 }
308 : undefined
309 }
310 />
311 </div>
312 );
314​
315function DetailRow({
316 label,
317 value,
318 valueClassName,
319}: {
320 label: string;
321 value: ReactNode;
322 valueClassName?: string | undefined;
323}) {
324 return (
325 <div className="flex items-center justify-between gap-4 border-t border-border/50 py-2.5 first:border-t-0">
326 <span className="text-2xs text-muted-foreground/75">{label}</span>
327 <span
328 className={cn(
329 "min-w-0 truncate text-right font-mono text-2xs tabular-nums text-foreground/85",
330 valueClassName,
331 )}
332 >
333 {value}
334 </span>
335 </div>
336 );
338​
339function HistoryWindowSelector({
340 selectedWindowMs,
341 onSelect,
342}: {
343 selectedWindowMs: number;
344 onSelect: (windowMs: number) => void;
345}) {
346 return (
347 <ToggleGroup
348 aria-label="Resource history period"
349 variant="segmented"
350 value={[String(selectedWindowMs)]}
351 onValueChange={(next) => {
352 const selected = HISTORY_WINDOWS.find((option) => String(option.windowMs) === next[0]);
353 if (selected) onSelect(selected.windowMs);
354 }}
355 >
356 {HISTORY_WINDOWS.map((option) => (
357 <Toggle key={option.windowMs} value={String(option.windowMs)}>
358 {option.label}
359 </Toggle>
360 ))}
361 </ToggleGroup>
362 );
364​
365function ResourceHistoryChart({
366 buckets,
367}: {
368 buckets: ReadonlyArray<ResourceTelemetryHistoryBucket>;
369}) {
370 const maxCpu = resourceHistoryCpuScaleMax(buckets);
371 const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes));
372​
373 return (
374 <div className="border-t border-border/60 px-4 py-4 sm:px-5">
375 <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-3xs text-muted-foreground/65">
376 <span className="inline-flex items-center gap-1.5">
377 <span className="h-1.5 w-3 rounded-full bg-foreground/70" /> CPU average
378 </span>
379 <span className="inline-flex items-center gap-1.5">
380 <span className="h-1.5 w-3 rounded-full bg-info/70" /> I/O reads
381 </span>
382 <span className="inline-flex items-center gap-1.5">
383 <span className="h-1.5 w-3 rounded-full bg-warning/80" /> I/O writes
384 </span>
385 </div>
386 <div className="flex h-32 items-end gap-1 overflow-hidden rounded-lg border border-border/40 bg-muted/8 px-2 pt-3 pb-2">
387 {buckets.map((bucket) => {
388 const cpuHeight = resourceHistoryBarHeight({
389 value: bucket.avgCpuPercent,
390 max: maxCpu,
391 minimumVisiblePercent: 2,
392 });
393 const readHeight = resourceHistoryBarHeight({
394 value: bucket.ioReadBytes,
395 max: maxIo,
396 minimumVisiblePercent: 1,
397 });
398 const writeHeight = resourceHistoryBarHeight({
399 value: bucket.ioWriteBytes,
400 max: maxIo,
401 minimumVisiblePercent: 1,
402 });
403 return (
404 <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
405 <TooltipTrigger
406 render={
407 <div className="grid h-full min-w-1 flex-1 grid-cols-3 items-end gap-px">
408 <span
409 className="block rounded-t-sm bg-foreground/65"
410 style={{ height: `${cpuHeight}%` }}
411 />
412 <span
413 className="block rounded-t-sm bg-info/70"
414 style={{ height: `${readHeight}%` }}
415 />
416 <span
417 className="block rounded-t-sm bg-warning/80"
418 style={{ height: `${writeHeight}%` }}
419 />
420 </div>
421 }
422 />
423 <TooltipPopup side="top" className="text-left">
424 <div className="space-y-0.5">
425 <div>CPU avg {bucket.avgCpuPercent.toFixed(1)}%</div>
426 <div>CPU peak {bucket.maxCpuPercent.toFixed(1)}%</div>
427 <div>Read {formatBytes(bucket.ioReadBytes)}</div>
428 <div>Write {formatBytes(bucket.ioWriteBytes)}</div>
429 </div>
430 </TooltipPopup>
431 </Tooltip>
432 );
433 })}
434 </div>
435 </div>
436 );
438​
439function ProcessTreeName({
440 process,
441 collapsed,
442 onToggle,
443}: {
444 process: ResourceTelemetryProcess;
445 collapsed: boolean;
446 onToggle: (process: ResourceTelemetryProcess) => void;
447}) {
448 const name = formatProcessName(process);
449 const hasChildren = process.childPids.length > 0;
450 return (
451 <div
452 className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
453 style={{ paddingLeft: `${Math.min(process.depth, 7) * 10}px` }}
454 >
455 {hasChildren ? (
456 <Button
457 size="icon-micro"
458 variant="ghost-muted"
459 onClick={() => onToggle(process)}
460 aria-label={collapsed ? `Expand ${name}` : `Collapse ${name}`}
461 >
462 {collapsed ? (
463 <ChevronRightIcon className="size-3.5" />
464 ) : (
465 <ChevronDownIcon className="size-3.5" />
466 )}
467 </Button>
468 ) : (
469 <span className="size-5" aria-hidden />
470 )}
471 <span className={cn("size-1.5 rounded-full", categoryDotClass(process.category))} />
472 <Tooltip>
473 <TooltipTrigger
474 render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
475 />
476 <TooltipPopup side="top" variant="code">
477 {process.command || process.name}
478 </TooltipPopup>
479 </Tooltip>
480 </div>
481 );
483​
484function canSignalProcess(process: ResourceTelemetryProcess): boolean {
485 return (
486 process.category === "server-child" ||
487 process.category === "provider-root" ||
488 process.category === "terminal-root"
489 );
491​
492function ProcessActions({
493 process,
494 canMaintainEnvironment,
495 signalingKeys,
496 onSignal,
497}: {
498 process: ResourceTelemetryProcess;
499 canMaintainEnvironment: boolean;
500 signalingKeys: ReadonlySet<string>;
501 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
502}) {
503 if (!canSignalProcess(process)) {
504 return <span className="text-3xs text-muted-foreground/35">-</span>;
505 }
506 const isSignaling = signalingKeys.has(processIdentityKey(process));
507 return (
508 <ProcessSignalActions
509 disabled={!canMaintainEnvironment || isSignaling}
510 onSignal={(signal) => onSignal(process, signal)}
511 />
512 );
514​
515function ProcessTable({
516 processes,
517 canMaintainEnvironment,
518 signalingKeys,
519 onSignal,
520}: {
521 processes: ReadonlyArray<ResourceTelemetryProcess>;
522 canMaintainEnvironment: boolean;
523 signalingKeys: ReadonlySet<string>;
524 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
525}) {
526 const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
527 const visible = visibleResourceTelemetryProcesses(processes, collapsed);
528 const toggle = (process: ResourceTelemetryProcess) => {
529 const identityKey = processIdentityKey(process);
530 setCollapsed((current) => {
531 const next = new Set(current);
532 if (next.has(identityKey)) {
533 next.delete(identityKey);
534 } else {
535 next.add(identityKey);
536 }
537 return next;
538 });
539 };
540​
541 return (
542 <div className="border-t border-border/60">
543 <ScrollArea
544 chainVerticalScroll
545 scrollFade
546 hideScrollbars
547 className="max-h-[min(68vh,48rem)] w-full max-w-full"
548 >
549 <table className="w-full min-w-[1320px] table-fixed text-left text-xs">
550 <colgroup>
551 <col className="w-[20%]" />
552 <col className="w-[10%]" />
553 <col className="w-[7%]" />
554 <col className="w-[8%]" />
555 <col className="w-[9%]" />
556 <col className="w-[9%]" />
557 <col className="w-[9%]" />
558 <col className="w-[10%]" />
559 <col className="w-[8%]" />
560 <col className="w-[6%]" />
561 <col className="w-[4%]" />
562 </colgroup>
563 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-3xs uppercase tracking-widest text-muted-foreground/65">
564 <tr>
565 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
566 <th className="px-3 py-2 font-semibold">Category</th>
567 <th className="px-3 py-2 text-right font-semibold">CPU</th>
568 <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
569 <th className="px-3 py-2 text-right font-semibold">Memory</th>
570 <th className="px-3 py-2 text-right font-semibold">Read/s</th>
571 <th className="px-3 py-2 text-right font-semibold">Write/s</th>
572 <th className="px-3 py-2 text-right font-semibold">Read Total</th>
573 <th className="px-3 py-2 text-right font-semibold">Write Total</th>
574 <th className="px-3 py-2 text-right font-semibold">PID</th>
575 <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th>
576 </tr>
577 </thead>
578 <tbody className="divide-y divide-border/50">
579 {visible.length === 0 ? (
580 <tr>
581 <td colSpan={11} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
582 Waiting for the native process monitor.
583 </td>
584 </tr>
585 ) : null}
586 {visible.map((process) => (
587 <tr key={processIdentityKey(process)} className="hover:bg-muted/20">
588 <td className="px-4 py-2 sm:pl-5">
589 <ProcessTreeName
590 process={process}
591 collapsed={collapsed.has(processIdentityKey(process))}
592 onToggle={toggle}
593 />
594 </td>
595 <td className="truncate px-3 py-2 text-2xs text-muted-foreground">
596 {categoryLabel(process.category)}
597 </td>
598 <td className="px-3 py-2 text-right font-mono tabular-nums">
599 {process.cpuPercent.toFixed(1)}%
600 </td>
601 <td className="px-3 py-2 text-right font-mono tabular-nums">
602 {formatCpuTime(process.cpuTimeMs / 1_000)}
603 </td>
604 <td className="px-3 py-2 text-right font-mono tabular-nums">
605 {formatBytes(process.residentBytes)}
606 </td>
607 <td className="px-3 py-2 text-right font-mono tabular-nums text-info-foreground">
608 {formatRate(process.ioReadBytesPerSecond)}
609 </td>
610 <td className="px-3 py-2 text-right font-mono tabular-nums text-warning-foreground">
611 {formatRate(process.ioWriteBytesPerSecond)}
612 </td>
613 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
614 {formatBytes(process.ioReadBytes)}
615 </td>
616 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
617 <Tooltip>
618 <TooltipTrigger render={<span>{formatBytes(process.ioWriteBytes)}</span>} />
619 <TooltipPopup side="top">{ioSemanticsLabel(process.ioSemantics)}</TooltipPopup>
620 </Tooltip>
621 </td>
622 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
623 {process.identity.pid}
624 </td>
625 <td className="px-2 py-2 text-right sm:pr-4">
626 <ProcessActions
627 process={process}
628 canMaintainEnvironment={canMaintainEnvironment}
629 signalingKeys={signalingKeys}
630 onSignal={onSignal}
631 />
632 </td>
633 </tr>
634 ))}
635 </tbody>
636 </table>
637 </ScrollArea>
638 </div>
639 );
641​
642function HistoryProcessTable({
643 processes,
644}: {
645 processes: ReadonlyArray<ResourceTelemetryProcessSummary>;
646}) {
647 return (
648 <div className="border-t border-border/60">
649 <ScrollArea
650 chainVerticalScroll
651 scrollFade
652 hideScrollbars
653 className="max-h-[28rem] w-full max-w-full"
654 >
655 <table className="w-full min-w-[1020px] table-fixed text-left text-xs">
656 <colgroup>
657 <col className="w-[24%]" />
658 <col className="w-[11%]" />
659 <col className="w-[10%]" />
660 <col className="w-[10%]" />
661 <col className="w-[11%]" />
662 <col className="w-[11%]" />
663 <col className="w-[11%]" />
664 <col className="w-[7%]" />
665 <col className="w-[5%]" />
666 </colgroup>
667 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-3xs uppercase tracking-widest text-muted-foreground/65">
668 <tr>
669 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
670 <th className="px-3 py-2 font-semibold">Category</th>
671 <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
672 <th className="px-3 py-2 text-right font-semibold">Peak CPU</th>
673 <th className="px-3 py-2 text-right font-semibold">Peak Mem</th>
674 <th className="px-3 py-2 text-right font-semibold">Read</th>
675 <th className="px-3 py-2 text-right font-semibold">Write</th>
676 <th className="px-3 py-2 text-right font-semibold">Samples</th>
677 <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th>
678 </tr>
679 </thead>
680 <tbody className="divide-y divide-border/50">
681 {processes.length === 0 ? (
682 <tr>
683 <td colSpan={9} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
684 No retained process samples in this window.
685 </td>
686 </tr>
687 ) : null}
688 {processes.map((process) => (
689 <tr key={processIdentityKey(process)} className="hover:bg-muted/20">
690 <td className="px-4 py-2 sm:pl-5">
691 <Tooltip>
692 <TooltipTrigger
693 render={
694 <span className="block truncate font-medium text-foreground">
695 {process.name || process.command}
696 </span>
697 }
698 />
699 <TooltipPopup side="top" variant="code">
700 {process.command || process.name}
701 </TooltipPopup>
702 </Tooltip>
703 </td>
704 <td className="truncate px-3 py-2 text-2xs text-muted-foreground">
705 {categoryLabel(process.category)}
706 </td>
707 <td className="px-3 py-2 text-right font-mono tabular-nums">
708 {formatCpuTime(process.cpuTimeMs / 1_000)}
709 </td>
710 <td className="px-3 py-2 text-right font-mono tabular-nums">
711 {process.maxCpuPercent.toFixed(1)}%
712 </td>
713 <td className="px-3 py-2 text-right font-mono tabular-nums">
714 {formatBytes(process.peakRssBytes)}
715 </td>
716 <td className="px-3 py-2 text-right font-mono tabular-nums text-info-foreground">
717 {formatBytes(process.ioReadBytes)}
718 </td>
719 <td className="px-3 py-2 text-right font-mono tabular-nums text-warning-foreground">
720 {formatBytes(process.ioWriteBytes)}
721 </td>
722 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
723 {process.sampleCount}
724 </td>
725 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
726 {process.identity.pid}
727 </td>
728 </tr>
729 ))}
730 </tbody>
731 </table>
732 </ScrollArea>
733 </div>
734 );
736​
737function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttributionEntry> }) {
738 return (
739 <div className="overflow-x-auto border-t border-border/60">
740 <table className="w-full min-w-[720px] table-fixed text-left text-xs">
741 <colgroup>
742 <col className="w-[22%]" />
743 <col className="w-[28%]" />
744 <col className="w-[14%]" />
745 <col className="w-[14%]" />
746 <col className="w-[10%]" />
747 <col className="w-[12%]" />
748 </colgroup>
749 <thead className="border-b border-border/60 text-3xs uppercase tracking-widest text-muted-foreground/65">
750 <tr>
751 <th className="px-4 py-2 font-semibold sm:pl-5">Component</th>
752 <th className="px-3 py-2 font-semibold">Operation</th>
753 <th className="px-3 py-2 text-right font-semibold">Logical Read</th>
754 <th className="px-3 py-2 text-right font-semibold">Logical Write</th>
755 <th className="px-3 py-2 text-right font-semibold">Count</th>
756 <th className="px-3 py-2 text-right font-semibold sm:pr-5">Time</th>
757 </tr>
758 </thead>
759 <tbody className="divide-y divide-border/50">
760 {entries.length === 0 ? (
761 <tr>
762 <td colSpan={6} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
763 No instrumented application I/O has been recorded yet.
764 </td>
765 </tr>
766 ) : null}
767 {entries.map((entry) => (
768 <tr key={`${entry.component}:${entry.operation}`} className="hover:bg-muted/20">
769 <td className="truncate px-4 py-2 font-medium text-foreground sm:pl-5">
770 {entry.component}
771 </td>
772 <td className="truncate px-3 py-2 text-muted-foreground">{entry.operation}</td>
773 <td className="px-3 py-2 text-right font-mono tabular-nums text-info-foreground">
774 {formatBytes(entry.logicalReadBytes)}
775 </td>
776 <td className="px-3 py-2 text-right font-mono tabular-nums text-warning-foreground">
777 {formatBytes(entry.logicalWriteBytes)}
778 </td>
779 <td className="px-3 py-2 text-right font-mono tabular-nums">{entry.count}</td>
780 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
781 {(entry.durationMs / 1_000).toFixed(2).replace(/\.0+$/, "")}s
782 </td>
783 </tr>
784 ))}
785 </tbody>
786 </table>
787 </div>
788 );
790​
791export function ResourceTelemetryDiagnostics({
792 environmentId,
793}: {
794 environmentId: EnvironmentId | null;
795}) {
796 // Pending signals and retries belong to one environment, so a switch starts fresh.
797 return (
798 <ResourceTelemetryDiagnosticsBody key={environmentId ?? ""} environmentId={environmentId} />
799 );
801​
802function ResourceTelemetryDiagnosticsBody({
803 environmentId,
804}: {
805 environmentId: EnvironmentId | null;
806}) {
807 const registry = useContext(RegistryContext);
808 const [windowMs, setWindowMs] = useState(15 * 60_000);
809 const selectedWindow =
810 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
811 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
812 const telemetry = useResourceTelemetry(environmentId);
813 const retryTelemetry = telemetry.retry;
814 const history = useResourceTelemetryHistory(
815 {
816 windowMs: selectedWindow.windowMs,
817 bucketMs: selectedWindow.bucketMs,
818 },
819 environmentId,
820 );
821 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
822 reportFailure: false,
823 });
824 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());
825 const signalingKeysRef = useRef<ReadonlySet<string>>(new Set());
826 const [isRetrying, setIsRetrying] = useState(false);
827 const snapshot = telemetry.data;
828 const allT3 = snapshot?.groups.allT3;
829​
830 const signalProcess = async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
831 const targetEnvironmentId = environmentId;
832 if (
833 targetEnvironmentId === null ||
834 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
835 )
836 return;
837 const identityKey = processIdentityKey(process);
838 if (signalingKeysRef.current.has(identityKey)) return;
839 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
840 signalingKeysRef.current = nextSignalingKeys;
841 setSignalingKeys(nextSignalingKeys);
842 const clearSignaling = () => {
843 const next = new Set(signalingKeysRef.current);
844 next.delete(identityKey);
845 signalingKeysRef.current = next;
846 setSignalingKeys(next);
847 };
848​
849 if (signal === "SIGKILL") {
850 let confirmed = false;
851 try {
852 confirmed = await ensureLocalApi().dialogs.confirm(
853 `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`,
854 { variant: "destructive" },
855 );
856 } catch (error) {
857 clearSignaling();
858 toastManager.add({
859 type: "error",
860 title: "Could not confirm signal",
861 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
862 });
863 return;
864 }
865 if (!confirmed) {
866 clearSignaling();
867 return;
868 }
869 }
870 if (!readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)) {
871 clearSignaling();
872 return;
873 }
874 void signalServerProcess({
875 environmentId: targetEnvironmentId,
876 input: {
877 pid: process.identity.pid,
878 startTimeMs: process.identity.startTimeMs,
879 signal,
880 },
881 })
882 .then((result) => {
883 if (result._tag === "Failure") {
884 if (isAtomCommandInterrupted(result)) return;
885 throw squashAtomCommandFailure(result);
886 }
887 if (result.value.signaled) return;
888 toastManager.add({
889 type: "error",
890 title: `Could not send ${signal}`,
891 description: Option.getOrElse(
892 result.value.message,
893 () => `Failed to send ${signal} to process ${process.identity.pid}.`,
894 ),
895 });
896 })
897 .catch((error: unknown) => {
898 toastManager.add({
899 type: "error",
900 title: `Could not send ${signal}`,
901 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
902 });
903 })
904 .finally(() => {
905 clearSignaling();
906 });
907 };
908​
909 const retryCollector = () => {
910 if (
911 environmentId === null ||
912 !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)
913 )
914 return;
915 setIsRetrying(true);
916 void retryTelemetry()
917 .then(() => {
918 // The user can pick another window while the retry runs; refreshing every window
919 // re-reads the one on screen and only invalidates the rest.
920 for (const option of HISTORY_WINDOWS) {
921 registry.refresh(
922 serverEnvironment.resourceTelemetryHistory({
923 environmentId,
924 input: { windowMs: option.windowMs, bucketMs: option.bucketMs },
925 }),
926 );
927 }
928 })
929 .catch((error: unknown) => {
930 toastManager.add({
931 type: "error",
932 title: "Could not restart resource monitor",
933 description:
934 error instanceof Error ? error.message : "The resource monitor retry failed.",
935 });
936 })
937 .finally(() => {
938 setIsRetrying(false);
939 });
940 };
941​
942 const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null;
943 const collectorNeedsRetry = shouldShowResourceMonitorRetry({
944 nativeStatus: snapshot?.health.native.status ?? null,
945 error: telemetry.error,
946 });
947 const hasHostPowerSignal =
948 snapshot !== null &&
949 (snapshot.power.onBattery !== "unknown" ||
950 snapshot.power.lowPowerMode !== "unknown" ||
951 snapshot.power.idle !== "unknown" ||
952 snapshot.power.locked !== "unknown" ||
953 snapshot.power.thermalState !== "unknown");
954​
955 return (
956 <>
957 <SettingsSection
958 title="Resource monitor"
959 icon={<ActivityIcon className="size-4 text-muted-foreground" />}
960 headerAction={
961 <div className="flex items-center gap-2">
962 {snapshot ? (
963 <SourceStatusBadge label="Native" status={snapshot.health.native.status} />
964 ) : null}
965 <LastSampleLabel sampledAt={snapshot?.readAt ?? null} />
966 <Tooltip>
967 <TooltipTrigger
968 render={
969 <Button
970 size="icon-micro"
971 variant="ghost"
972 disabled={telemetry.isPending}
973 onClick={telemetry.refresh}
974 aria-label="Refresh resource telemetry"
975 >
976 <RefreshIcon size="xs" refreshing={telemetry.isPending} />
977 </Button>
978 }
979 />
980 <TooltipPopup side="top">Refresh telemetry snapshot</TooltipPopup>
981 </Tooltip>
982 </div>
983 }
984 >
985 <div className="overflow-hidden rounded-[inherit]">
986 <div className="flex flex-col gap-3 border-b border-border/60 bg-linear-to-r from-muted/45 via-muted/20 to-transparent px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
987 <div>
988 <div className="text-3xs font-semibold uppercase tracking-widest text-muted-foreground/70">
989 T3 system footprint
990 </div>
991 <p className="mt-1 max-w-xl text-xs leading-relaxed text-muted-foreground">
992 Live native counters for the server, providers, terminals, desktop processes, and
993 the monitor itself.
994 </p>
995 </div>
996 <div className="flex items-center gap-2 text-3xs text-muted-foreground/65">
997 <span
998 className={cn(
999 "size-1.5 rounded-full",
1000 snapshot && snapshot.sampleIntervalMs > 0
1001 ? "bg-success"
1002 : "bg-muted-foreground/55",
1003 )}
1004 />
1005 {snapshot ? formatSamplingStatus(snapshot.sampleIntervalMs) : "Sampling every ..."}
1006 </div>
1007 </div>
1008 <div className="grid grid-cols-2 divide-x divide-y divide-border/55 md:grid-cols-3">
1009 <IconStat
1010 icon={<CpuIcon className="size-3.5" />}
1011 label="Current CPU"
1012 value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."}
1013 detail={
1014 allT3 ? `${formatCpuTime(allT3.cpuTimeMs / 1_000)} observed CPU time` : undefined
1016 />
1017 <IconStat
1018 icon={<MemoryStickIcon className="size-3.5" />}
1019 label="Resident memory"
1020 value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."}
1021 detail={
1022 allT3 ? `${formatBytes(allT3.peakRssBytes)} combined process peaks` : undefined
1024 />
1025 <IconStat
1026 icon={<ActivityIcon className="size-3.5" />}
1027 label="Process count"
1028 value={allT3 ? String(allT3.processCount) : "..."}
1029 detail={
1030 allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined
1032 />
1033 <IconStat
1034 icon={<HardDriveIcon className="size-3.5" />}
1035 label="Read throughput"
1036 value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."}
1037 detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined}
1038 />
1039 <IconStat
1040 icon={<DatabaseIcon className="size-3.5" />}
1041 label="Write throughput"
1042 value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."}
1043 detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined}
1044 tone={
1045 allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024
1046 ? "danger"
1047 : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024
1048 ? "warning"
1049 : "default"
1051 />
1052 <IconStat
1053 icon={<GaugeIcon className="size-3.5" />}
1054 label="CPU speed limit"
1055 value={
1056 snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..."
1058 detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined}
1059 tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"}
1060 />
1061 </div>
1062 {telemetry.error ? (
1063 <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5">
1064 <AlertTriangleIcon className="size-3.5 h-lh shrink-0" />
1065 <span>{telemetry.error}</span>
1066 </div>
1067 ) : null}
1068 {snapshot ? (
1069 <div className="grid border-t border-border/60 bg-muted/10 md:grid-cols-3">
1070 <AggregateCard
1071 label="Backend + agents"
1072 accentClass="bg-success/80"
1073 aggregate={snapshot.groups.backend}
1074 />
1075 <AggregateCard
1076 label="Desktop"
1077 accentClass="bg-info/80"
1078 aggregate={snapshot.groups.electron}
1079 />
1080 <AggregateCard
1081 label="Monitor overhead"
1082 accentClass="bg-warning/80"
1083 aggregate={snapshot.groups.monitor}
1084 />
1085 </div>
1086 ) : null}
1087 </div>
1088 </SettingsSection>
1089​
1090 <SettingsSection
1091 title="Host & collection"
1092 icon={<GaugeIcon className="size-4 text-muted-foreground" />}
1093 headerAction={
1094 collectorNeedsRetry ? (
1095 <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}>
1096 <RefreshIcon size="xs" refreshing={isRetrying} />
1097 Retry monitor
1098 </Button>
1099 ) : null
1102 <div className="grid overflow-hidden rounded-[inherit] md:grid-cols-2 md:divide-x md:divide-border/60">
1103 <div className="px-4 py-4 sm:px-5">
1104 <div className="mb-3 flex items-center gap-2 text-3xs font-semibold uppercase tracking-widest text-muted-foreground/70">
1105 <span className="flex size-6 items-center justify-center rounded-md bg-muted/60">
1106 <BatteryIcon className="size-3.5" />
1107 </span>
1108 Host state
1109 </div>
1110 {hasHostPowerSignal && snapshot ? (
1111 <>
1112 <DetailRow
1113 label="Power source"
1114 value={booleanStateLabel(snapshot.power.onBattery, {
1115 true: "Battery",
1116 false: "External power",
1117 })}
1118 />
1119 <DetailRow
1120 label="Low power mode"
1121 value={booleanStateLabel(snapshot.power.lowPowerMode, {
1122 true: "Enabled",
1123 false: "Disabled",
1124 })}
1125 />
1126 <DetailRow
1127 label="Idle"
1128 value={`${booleanStateLabel(snapshot.power.idle, {
1129 true: "Idle",
1130 false: "Active",
1131 })}${
1132 snapshot.power.idleSeconds === null
1133 ? ""
1134 : ` · ${Math.round(snapshot.power.idleSeconds)}s`
1135 }`}
1136 />
1137 <DetailRow
1138 label="Session"
1139 value={
1140 snapshot.power.suspended
1141 ? "Suspended"
1142 : booleanStateLabel(snapshot.power.locked, {
1143 true: "Locked",
1144 false: "Unlocked",
1145 })
1147 />
1148 <DetailRow
1149 label="Thermal"
1150 value={snapshot.power.thermalState}
1151 valueClassName={
1152 snapshot.power.thermalState === "serious" ||
1153 snapshot.power.thermalState === "critical"
1154 ? "text-destructive"
1155 : undefined
1157 />
1158 </>
1159 ) : (
1160 <div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-4 py-5">
1161 <div className="text-sm font-medium text-foreground">
1162 Desktop host signals not connected
1163 </div>
1164 <p className="mt-1.5 max-w-sm text-2xs leading-relaxed text-muted-foreground/70">
1165 Power, idle, lock, and thermal state are supplied by the desktop host. Process
1166 telemetry remains fully active in this browser session.
1167 </p>
1168 </div>
1169 )}
1170 </div>
1171 <div className="border-t border-border/60 px-4 py-4 md:border-t-0 sm:px-5">
1172 <div className="mb-3 flex items-center gap-2 text-3xs font-semibold uppercase tracking-widest text-muted-foreground/70">
1173 <span className="flex size-6 items-center justify-center rounded-md bg-muted/60">
1174 <GaugeIcon className="size-3.5" />
1175 </span>
1176 Collection health
1177 </div>
1178 {snapshot ? (
1179 <>
1180 <HealthSource label="Native process monitor" health={snapshot.health.native} />
1181 <HealthSource label="Electron main process" health={snapshot.health.desktop} />
1182 <DetailRow
1183 label="Collection time"
1184 value={formatDurationMicros(snapshot.health.collectionDurationMicros)}
1185 />
1186 <DetailRow
1187 label="Process scan"
1188 value={`${snapshot.health.retainedProcessCount}/${snapshot.health.scannedProcessCount} retained`}
1189 />
1190 <DetailRow
1191 label="Inaccessible"
1192 value={String(snapshot.health.inaccessibleProcessCount)}
1193 valueClassName={
1194 snapshot.health.inaccessibleProcessCount > 0
1195 ? "text-warning-foreground"
1196 : undefined
1198 />
1199 <DetailRow
1200 label="Sidecar"
1201 value={Option.match(snapshot.health.sidecarVersion, {
1202 onNone: () => "Unavailable",
1203 onSome: (version) =>
1204 `${version}${Option.match(snapshot.health.sidecarPid, {
1205 onNone: () => "",
1206 onSome: (pid) => ` · PID ${pid}`,
1207 })}`,
1208 })}
1209 />
1210 <DetailRow label="Restarts" value={String(snapshot.health.restartCount)} />
1211 </>
1212 ) : (
1213 <div className="py-4 text-xs text-muted-foreground">
1214 Waiting for collector health.
1215 </div>
1216 )}
1217 </div>
1218 </div>
1219 </SettingsSection>
1220​
1221 <SettingsSection
1222 title="Resource timeline"
1223 icon={<HardDriveIcon className="size-4 text-muted-foreground" />}
1224 headerAction={
1225 <div className="flex items-center gap-2">
1226 <HistoryWindowSelector selectedWindowMs={windowMs} onSelect={setWindowMs} />
1227 <Button
1228 size="icon-micro"
1229 variant="ghost"
1230 disabled={history.isPending}
1231 onClick={history.refresh}
1232 aria-label="Refresh resource history"
1234 <RefreshIcon size="xs" refreshing={history.isPending} />
1235 </Button>
1236 </div>
1239 <div className="overflow-hidden rounded-[inherit]">
1240 {history.error ? (
1241 <div className="flex items-start gap-2 border-b border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5">
1242 <AlertTriangleIcon className="size-3.5 h-lh shrink-0" />
1243 <span>{history.error}</span>
1244 </div>
1245 ) : null}
1246 <ResourceHistoryChart buckets={history.data?.buckets ?? []} />
1247 <HistoryProcessTable processes={history.data?.topProcesses ?? []} />
1248 </div>
1249 </SettingsSection>
1250​
1251 <SettingsSection
1252 title="Live process tree"
1253 icon={<CpuIcon className="size-4 text-muted-foreground" />}
1254 headerAction={
1255 snapshot ? (
1256 <span className="text-3xs text-muted-foreground/55">
1257 Identity: <span className="font-mono">PID + start time</span>
1258 </span>
1259 ) : null
1262 <div className="overflow-hidden rounded-[inherit]">
1263 <ProcessTable
1264 processes={snapshot?.processes ?? []}
1265 canMaintainEnvironment={canMaintainEnvironment}
1266 signalingKeys={signalingKeys}
1267 onSignal={signalProcess}
1268 />
1269 </div>
1270 </SettingsSection>
1271​
1272 <SettingsSection
1273 title="Instrumented application I/O"
1274 icon={<DatabaseIcon className="size-4 text-muted-foreground" />}
1275 headerAction={
1276 <span className="text-3xs text-muted-foreground/55">Logical bytes by operation</span>
1279 <div className="overflow-hidden rounded-[inherit]">
1280 <div className="bg-muted/15 px-4 py-3 text-2xs leading-relaxed text-muted-foreground sm:px-5">
1281 Native counters identify which process is reading or writing. These application-level
1282 counters identify known T3 operations so process spikes can be correlated with specific
1283 persistence and logging paths.
1284 </div>
1285 <AttributionTable entries={snapshot?.attribution.entries ?? []} />
1286 </div>
1287 </SettingsSection>
1288 </>
1289 );