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