Open History to see the last commit
1import { ProcessSignalActions } from "./ProcessSignalActions";
2import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
3import { environmentSession } from "../../state/session";
4import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
5import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
6import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
7import { RefreshIcon } from "~/components/ui/refresh-icon";
8import {
9 AlertTriangleIcon,
10 CircleSlashIcon,
11 CopyIcon,
12 FolderOpenIcon,
13 InfoIcon,
14 ChevronDownIcon,
15 ChevronRightIcon,
16} from "lucide-react";
17
18import {
19 isAtomCommandInterrupted,
20 squashAtomCommandFailure,
21} from "@t3tools/client-runtime/state/runtime";
22import { Fragment, useRef, useState, type ReactNode, type ComponentProps } from "react";
23import type {
24 ServerProcessDiagnosticsEntry,
25 ServerProcessResourceHistorySummary,
26 ServerProcessSignal,
27} from "@t3tools/contracts";
28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";
30
31import { formatBytes } from "../../lib/formatBytes";
32import { cn } from "../../lib/utils";
33import { ensureLocalApi } from "../../localApi";
34import { useOpenInPreferredEditor } from "../../editorPreferences";
35import { useRelativeTimeState } from "../../hooks/useRelativeTimeState";
36import { useEnvironmentQuery } from "../../state/query";
37import { serverEnvironment } from "../../state/server";
38import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
39import { Button } from "../ui/button";
40import { ScrollArea } from "../ui/scroll-area";
41import { Toggle, ToggleGroup } from "../ui/toggle-group";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
43import { toastManager } from "../ui/toast";
44import { ExpandableText } from "./ExpandableText";
45import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
46import {
47 SettingsPageContainer,
48 SettingsNote,
49 SettingsRow,
50 SettingsSection,
51} from "./settingsLayout";
52import { Badge } from "../ui/badge";
53import { useAtomCommand } from "../../state/use-atom-command";
54import { useSettingsScope } from "./SettingsScopeContext";
55import { TelemetryExportSettings } from "./TelemetryExportSettings";
56
57const NUMBER_FORMAT = new Intl.NumberFormat();
58/** Shown in place of a total the process monitor could not measure. */
59const UNAVAILABLE_VALUE = "-";
60
61function formatCount(value: number): string {
62 return NUMBER_FORMAT.format(value);
63}
64
65function formatDuration(value: number): string {
66 if (value < 1_000) return `${Math.round(value)} ms`;
67 return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2).replace(/\.0+$/, "")} s`;
68}
69
70function RelativeTimeNoWrap(props: { value: DateTime.Utc }) {
71 const relative = useRelativeTimeState(DateTime.formatIso(props.value));
72 if (relative.status !== "relative") return null;
73 return relative.suffix ? `${relative.value}\u00a0${relative.suffix}` : relative.value;
74}
75
76function shortenTraceId(traceId: string): string {
77 if (traceId.length <= 32) return traceId;
78 return `${traceId.slice(0, 18)}...${traceId.slice(-10)}`;
79}
80
81function isStaleProcessSignalMessage(message: string | undefined): boolean {
82 return message?.includes("not a live descendant") ?? false;
83}
84
85function StatTooltip({ label, children }: { label: string; children: ReactNode }) {
86 return (
87 <Tooltip>
88 <TooltipTrigger
89 render={
90 <button
91 type="button"
92 className="inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-dim-foreground outline-none hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring"
93 aria-label={`${label} details`}
94 >
95 <InfoIcon className="size-3" />
96 </button>
97 }
98 />
99 <TooltipPopup side="top">{children}</TooltipPopup>
100 </Tooltip>
101 );
102}
103
104function StatBlock({
105 label,
106 value,
107 tooltip,
108 tone = "default",
109}: {
110 label: string;
111 value: string;
112 tooltip?: ReactNode;
113 tone?: "default" | "warning" | "danger";
114}) {
115 return (
116 <div className="min-w-0 border-border/60 px-4 py-3 sm:px-5">
117 <div className="flex min-w-0 items-center gap-1.5 text-caption text-dim-foreground">
118 <span className="min-w-0 truncate">{label}</span>
119 {tooltip ? <StatTooltip label={label}>{tooltip}</StatTooltip> : null}
120 </div>
121 <div
122 className={cn(
123 "mt-1 truncate font-mono text-lg font-semibold tabular-nums text-foreground",
124 tone === "warning" && "text-warning-foreground",
125 tone === "danger" && "text-destructive",
126 )}
127 >
128 {value}
129 </div>
130 </div>
131 );
132}
133
134function StatsGrid({ children }: { children: ReactNode }) {
135 return (
136 <div className="relative grid grid-cols-2 sm:grid-cols-4">
137 <span
138 className="pointer-events-none absolute inset-y-0 left-1/2 w-px bg-border/60"
139 aria-hidden
140 />
141 <span
142 className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-border/60 sm:hidden"
143 aria-hidden
144 />
145 <span
146 className="pointer-events-none absolute inset-y-0 left-1/4 hidden w-px bg-border/60 sm:block"
147 aria-hidden
148 />
149 <span
150 className="pointer-events-none absolute inset-y-0 left-3/4 hidden w-px bg-border/60 sm:block"
151 aria-hidden
152 />
153 {children}
154 </div>
155 );
156}
157
158/** One Overview tile: a card with a dim label over a large value. */
159function OverviewTile({
160 label,
161 value,
162 tooltip,
163}: {
164 label: string;
165 value: string;
166 tooltip?: ReactNode;
167}) {
168 return (
169 <div className="min-w-0 rounded-xl surface-card-settings px-3 py-2.5">
170 <div className="flex min-w-0 items-center gap-1.5 text-meta text-dim-foreground">
171 <span className="min-w-0 truncate">{label}</span>
172 {tooltip ? <StatTooltip label={label}>{tooltip}</StatTooltip> : null}
173 </div>
174 <div className="mt-1 truncate text-xl font-semibold tabular-nums text-strong-foreground">
175 {value}
176 </div>
177 </div>
178 );
179}
180
181function EmptyRows({ label }: { label: string }) {
182 return <div className="px-4 py-4 text-meta text-muted-foreground sm:px-5">{label}</div>;
183}
184
185/**
186 * A fixed-layout table that fits its settings card. Secondary values go on a
187 * `RowDetail` line under the first cell instead of into more columns.
188 */
189function DiagnosticsTable({
190 columns,
191 children,
192}: {
193 columns: ReadonlyArray<{ label: string; width?: string; numeric?: boolean }>;
194 children: ReactNode;
195}) {
196 return (
197 <table className="w-full table-fixed text-left text-meta">
198 <colgroup>
199 {columns.map((column) => (
200 <col key={column.label} className={column.width} />
201 ))}
202 </colgroup>
203 <thead className="border-b border-border/60 text-caption text-dim-foreground">
204 <tr>
205 {columns.map((column) => (
206 <th
207 key={column.label}
208 className={cn(
209 "whitespace-nowrap px-3 py-2.5 font-semibold first:pl-4 last:pr-4 first:sm:pl-5 last:sm:pr-5",
210 column.numeric && "text-right",
211 )}
212 >
213 {column.label}
214 </th>
215 ))}
216 </tr>
217 </thead>
218 <tbody className="divide-y divide-border/60">{children}</tbody>
219 </table>
220 );
221}
222
223/** A dotted span name that wraps between its segments, not inside a word. */
224function SpanName({ name }: { name: string }) {
225 const segments = name.split(".");
226 return segments.map((segment, index) => (
227 <Fragment key={segments.slice(0, index + 1).join(".")}>
228 {index > 0 ? (
229 <>
230 .<wbr />
231 </>
232 ) : null}
233 {segment}
234 </Fragment>
235 ));
236}
237
238/** The muted second line of a table row's first cell. */
239function RowDetail({ className, ...props }: ComponentProps<"div">) {
240 return (
241 <div
242 {...props}
243 className={cn(
244 "mt-0.5 min-w-0 font-mono text-meta tabular-nums text-muted-foreground",
245 className,
246 )}
247 />
248 );
249}
250
251function TraceIdCell({ traceId }: { traceId: string }) {
252 const { copyToClipboard, isCopied: copied } = useCopyToClipboard({
253 target: "trace ID",
254 timeout: 1_200,
255 });
256
257 return (
258 <div className="flex w-full min-w-0 max-w-full items-center gap-2">
259 <Tooltip>
260 <TooltipTrigger
261 render={
262 <span className="min-w-0 truncate font-mono text-caption">
263 {shortenTraceId(traceId)}
264 </span>
265 }
266 />
267 <TooltipPopup side="top" variant="code">
268 {traceId}
269 </TooltipPopup>
270 </Tooltip>
271 <Tooltip>
272 <TooltipTrigger
273 render={
274 <Button
275 size="icon-micro"
276 variant="ghost-muted"
277 aria-label={copied ? "Copied trace ID" : "Copy trace ID"}
278 onClick={() => copyToClipboard(traceId)}
279 >
280 <CopyIcon className="size-3" />
281 </Button>
282 }
283 />
284 <TooltipPopup side="top">{copied ? "Copied" : "Copy full trace ID"}</TooltipPopup>
285 </Tooltip>
286 </div>
287 );
288}
289
290function formatProcessName(command: string): string {
291 const firstToken = command.trim().split(/\s+/)[0];
292 if (!firstToken) return command;
293 const normalized = firstToken.replace(/^['"]|['"]$/g, "");
294 const segments = normalized.split(/[\\/]/).filter(Boolean);
295 return segments.at(-1) ?? normalized;
296}
297
298function formatProcessType(process: ServerProcessDiagnosticsEntry): string {
299 if (process.depth > 0) return "Subprocess";
300 if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent";
301 return "Process";
302}
303
304function ProcessNameCell({
305 process,
306 isExpanded,
307 onToggle,
308}: {
309 process: ServerProcessDiagnosticsEntry;
310 isExpanded: boolean;
311 onToggle: (pid: number) => void;
312}) {
313 const name = formatProcessName(process.command);
314 const hasChildren = process.childPids.length > 0;
315
316 return (
317 <div
318 className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
319 style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }}
320 >
321 {hasChildren ? (
322 <Button
323 size="icon-micro"
324 variant="ghost-muted"
325 aria-label={isExpanded ? `Collapse ${name}` : `Expand ${name}`}
326 onClick={() => onToggle(process.pid)}
327 >
328 {isExpanded ? (
329 <ChevronDownIcon className="size-3.5" />
330 ) : (
331 <ChevronRightIcon className="size-3.5" />
332 )}
333 </Button>
334 ) : (
335 <span className="size-5 shrink-0" aria-hidden="true" />
336 )}
337 <span className="size-1.5 shrink-0 rounded-full bg-success/80" />
338 <div className="min-w-0">
339 <div className="truncate text-label text-strong-foreground">{name}</div>
340 <Tooltip>
341 <TooltipTrigger render={<RowDetail>PID {process.pid}</RowDetail>} />
342 <TooltipPopup side="top" variant="code">
343 <span className="grid gap-1">
344 <span>{formatProcessType(process)}</span>
345 <span>{process.command}</span>
346 </span>
347 </TooltipPopup>
348 </Tooltip>
349 </div>
350 </div>
351 );
352}
353
354function visibleProcessTree(
355 processes: ReadonlyArray<ServerProcessDiagnosticsEntry>,
356 collapsedPids: ReadonlySet<number>,
357) {
358 const visible: ServerProcessDiagnosticsEntry[] = [];
359 let hiddenChildDepth: number | null = null;
360
361 for (const process of processes) {
362 if (hiddenChildDepth !== null) {
363 if (process.depth > hiddenChildDepth) continue;
364 hiddenChildDepth = null;
365 }
366
367 visible.push(process);
368 if (collapsedPids.has(process.pid)) {
369 hiddenChildDepth = process.depth;
370 }
371 }
372
373 return visible;
374}
375
376function ProcessDiagnosticsTable({
377 processes,
378 canMaintainEnvironment,
379 signalingPid,
380 onSignal,
381 emptyLabel,
382}: {
383 processes: ReadonlyArray<ServerProcessDiagnosticsEntry>;
384 canMaintainEnvironment: boolean;
385 signalingPid: number | null;
386 onSignal: (pid: number, signal: ServerProcessSignal) => void;
387 emptyLabel?: string;
388}) {
389 const [collapsedPids, setCollapsedPids] = useState<ReadonlySet<number>>(() => new Set());
390 const visibleProcesses = visibleProcessTree(processes, collapsedPids);
391
392 const toggleProcess = (pid: number) => {
393 setCollapsedPids((previous) => {
394 const next = new Set(previous);
395 if (next.has(pid)) {
396 next.delete(pid);
397 } else {
398 next.add(pid);
399 }
400 return next;
401 });
402 };
403
404 return (
405 <div className="border-t border-border/60">
406 <ScrollArea
407 radius="none"
408 chainVerticalScroll
409 scrollFade
410 hideScrollbars
411 className="max-h-[min(64vh,44rem)] w-full max-w-full"
412 >
413 <table className="w-full table-fixed text-left text-meta">
414 <colgroup>
415 <col />
416 <col className="w-18" />
417 <col className="w-22" />
418 <col className="w-24" />
419 </colgroup>
420 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-caption text-dim-foreground">
421 <tr>
422 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
423 <th className="px-3 py-2 text-right font-semibold">CPU</th>
424 <th className="px-3 py-2 text-right font-semibold">Memory</th>
425 <th className="p-2 font-semibold sm:pr-4">
426 <span className="sr-only">Signal</span>
427 </th>
428 </tr>
429 </thead>
430 <tbody className="divide-y divide-border/50">
431 {visibleProcesses.length === 0 ? (
432 <tr>
433 <td colSpan={4} className="px-4 py-4 text-meta text-muted-foreground sm:px-5">
434 {emptyLabel ?? "No live descendant processes found."}
435 </td>
436 </tr>
437 ) : null}
438 {visibleProcesses.map((process) => (
439 <tr key={process.pid} className="hover:surface-ghost-hover">
440 <td className="px-4 py-2 align-middle sm:pl-5">
441 <ProcessNameCell
442 process={process}
443 isExpanded={!collapsedPids.has(process.pid)}
444 onToggle={toggleProcess}
445 />
446 </td>
447 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
448 {process.cpuPercent.toFixed(1)}%
449 </td>
450 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
451 {formatBytes(process.rssBytes)}
452 </td>
453 <td className="p-2 align-middle sm:pr-4">
454 <ProcessSignalActions
455 disabled={!canMaintainEnvironment || signalingPid === process.pid}
456 onSignal={(signal) => onSignal(process.pid, signal)}
457 />
458 </td>
459 </tr>
460 ))}
461 </tbody>
462 </table>
463 </ScrollArea>
464 </div>
465 );
466}
467
468const RESOURCE_HISTORY_WINDOWS = [
469 { label: "5m", windowMs: 5 * 60_000, bucketMs: 30_000 },
470 { label: "15m", windowMs: 15 * 60_000, bucketMs: 60_000 },
471 { label: "30m", windowMs: 30 * 60_000, bucketMs: 2 * 60_000 },
472 { label: "1h", windowMs: 60 * 60_000, bucketMs: 5 * 60_000 },
473] as const;
474
475function formatCpuTime(seconds: number): string {
476 if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0+$/, "")}s`;
477 const minutes = seconds / 60;
478 if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2).replace(/\.0+$/, "")}m`;
479 return `${(minutes / 60).toFixed(2).replace(/\.0+$/, "")}h`;
480}
481
482function formatShortProcessName(command: string): string {
483 const name = formatProcessName(command);
484 return name.length > 42 ? `${name.slice(0, 39)}...` : name;
485}
486
487function ResourceHistoryProcessNameCell({
488 process,
489 visualDepth,
490}: {
491 process: ServerProcessResourceHistorySummary;
492 visualDepth: number;
493}) {
494 const name = formatShortProcessName(process.command);
495
496 return (
497 <div
498 className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
499 style={{ paddingLeft: `${Math.min(visualDepth, 6) * 10}px` }}
500 aria-label={`${process.isServerRoot ? "Root" : "Child"} process ${name}`}
501 >
502 <span className="size-5 shrink-0" aria-hidden="true" />
503 <span
504 className={cn(
505 "size-1.5 shrink-0 rounded-full",
506 process.isServerRoot ? "bg-warning/90" : "bg-success/80",
507 )}
508 />
509 <div className="min-w-0">
510 <div className="truncate text-label text-strong-foreground">{name}</div>
511 <Tooltip>
512 <TooltipTrigger render={<RowDetail>PID {process.pid}</RowDetail>} />
513 <TooltipPopup side="top" variant="code">
514 <span className="grid gap-1">
515 <span>Now {process.currentCpuPercent.toFixed(1)}%</span>
516 <span>{process.command}</span>
517 </span>
518 </TooltipPopup>
519 </Tooltip>
520 </div>
521 </div>
522 );
523}
524
525function ProcessResourceHistoryChart({
526 buckets,
527}: {
528 buckets: ReadonlyArray<{
529 readonly startedAt: DateTime.Utc;
530 readonly avgCpuPercent: number;
531 readonly maxCpuPercent: number;
532 }>;
533}) {
534 const maxCpuPercent = Math.max(1, ...buckets.map((bucket) => bucket.maxCpuPercent));
535
536 return (
537 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
538 <div className="flex h-28 items-end gap-1 overflow-hidden rounded-sm bg-muted/10 p-2">
539 {buckets.map((bucket) => {
540 const peakHeight = Math.max(2, (bucket.maxCpuPercent / maxCpuPercent) * 100);
541 const averageHeight = Math.max(2, (bucket.avgCpuPercent / maxCpuPercent) * 100);
542 return (
543 <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
544 <TooltipTrigger
545 render={
546 <div className="flex h-full min-w-1 flex-1 items-end">
547 <div
548 className="relative h-full w-full"
549 aria-label={`Average CPU ${bucket.avgCpuPercent.toFixed(1)}%, peak CPU ${bucket.maxCpuPercent.toFixed(1)}%`}
550 >
551 <div
552 className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/15 transition-colors"
553 style={{ height: `${peakHeight}%` }}
554 />
555 <div
556 className="absolute inset-x-0 bottom-0 rounded-t-sm bg-foreground/60 transition-colors"
557 style={{ height: `${averageHeight}%` }}
558 />
559 </div>
560 </div>
561 }
562 />
563 <TooltipPopup side="top">
564 Avg {bucket.avgCpuPercent.toFixed(1)}%, peak {bucket.maxCpuPercent.toFixed(1)}%
565 </TooltipPopup>
566 </Tooltip>
567 );
568 })}
569 </div>
570 </div>
571 );
572}
573
574function ResourceHistoryWindowSelector({
575 selectedWindowMs,
576 onSelect,
577}: {
578 selectedWindowMs: number;
579 onSelect: (windowMs: number) => void;
580}) {
581 return (
582 <ToggleGroup
583 aria-label="Process history period"
584 variant="segmented"
585 value={[String(selectedWindowMs)]}
586 onValueChange={(next) => {
587 const selected = RESOURCE_HISTORY_WINDOWS.find(
588 (option) => String(option.windowMs) === next[0],
589 );
590 if (selected) onSelect(selected.windowMs);
591 }}
592 >
593 {RESOURCE_HISTORY_WINDOWS.map((option) => (
594 <Toggle key={option.windowMs} value={String(option.windowMs)}>
595 {option.label}
596 </Toggle>
597 ))}
598 </ToggleGroup>
599 );
600}
601
602function ProcessResourceHistoryTable({
603 processes,
604 emptyLabel,
605}: {
606 processes: ReadonlyArray<ServerProcessResourceHistorySummary>;
607 emptyLabel: string;
608}) {
609 const shallowestChildDepth = processes.reduce<number | null>((minDepth, process) => {
610 if (process.isServerRoot) return minDepth;
611 return minDepth === null ? process.depth : Math.min(minDepth, process.depth);
612 }, null);
613
614 return (
615 <div className="border-t border-border/60">
616 <ScrollArea
617 chainVerticalScroll
618 scrollFade
619 hideScrollbars
620 className="max-h-[min(64vh,44rem)] w-full max-w-full"
621 >
622 <table className="w-full table-fixed text-left text-meta">
623 <colgroup>
624 <col />
625 <col className="w-20" />
626 <col className="w-18" />
627 <col className="w-18" />
628 <col className="w-22" />
629 </colgroup>
630 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-caption text-dim-foreground">
631 <tr>
632 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
633 <th className="px-3 py-2 text-right font-semibold">CPU time</th>
634 <th className="px-3 py-2 text-right font-semibold">Average</th>
635 <th className="px-3 py-2 text-right font-semibold">Peak</th>
636 <th className="px-3 py-2 text-right font-semibold sm:pr-5">Max mem</th>
637 </tr>
638 </thead>
639 <tbody className="divide-y divide-border/50">
640 {processes.length === 0 ? (
641 <tr>
642 <td colSpan={5} className="px-4 py-4 text-meta text-muted-foreground sm:px-5">
643 {emptyLabel}
644 </td>
645 </tr>
646 ) : null}
647 {processes.map((process) => (
648 <tr key={process.processKey} className="hover:surface-ghost-hover">
649 <td className="px-4 py-2 align-middle sm:pl-5">
650 <ResourceHistoryProcessNameCell
651 process={process}
652 visualDepth={
653 process.isServerRoot || shallowestChildDepth === null
654 ? 0
655 : Math.max(1, process.depth - shallowestChildDepth + 1)
656 }
657 />
658 </td>
659 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
660 {formatCpuTime(process.cpuSecondsApprox)}
661 </td>
662 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
663 {process.avgCpuPercent.toFixed(1)}%
664 </td>
665 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums">
666 {process.maxCpuPercent.toFixed(1)}%
667 </td>
668 <td className="px-3 py-2 text-right align-middle font-mono tabular-nums sm:pr-5">
669 {formatBytes(process.maxRssBytes)}
670 </td>
671 </tr>
672 ))}
673 </tbody>
674 </table>
675 </ScrollArea>
676 </div>
677 );
678}
679
680function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) {
681 const relative = useRelativeTimeState(checkedAt ? DateTime.formatIso(checkedAt) : null);
682
683 if (relative.status === "missing") {
684 return <span className="text-caption text-dim-foreground">Checking</span>;
685 }
686
687 if (relative.status === "invalid") {
688 return <span className="text-caption text-dim-foreground">Checked unavailable</span>;
689 }
690
691 return (
692 <span className="text-caption text-dim-foreground">
693 {relative.suffix ? (
694 <>
695 Checked <span className="font-mono tabular-nums">{relative.value}</span> {relative.suffix}
696 </>
697 ) : (
698 <>Checked {relative.value}</>
699 )}
700 </span>
701 );
702}
703
704function DiagnosticsRefreshButton({
705 isPending,
706 label,
707 onClick,
708}: {
709 isPending: boolean;
710 label: string;
711 onClick: () => void;
712}) {
713 return (
714 <Tooltip>
715 <TooltipTrigger
716 render={
717 <Button
718 size="icon-xs"
719 variant="ghost-muted"
720 disabled={isPending}
721 onClick={onClick}
722 aria-label={label}
723 >
724 <RefreshIcon refreshing={isPending} />
725 </Button>
726 }
727 />
728 <TooltipPopup side="top">{label}</TooltipPopup>
729 </Tooltip>
730 );
731}
732
733export function DiagnosticsSettingsPanel() {
734 const { environment } = useSettingsScope();
735 // Signals and pending UI state belong to one environment, so a scope switch starts fresh.
736 return <DiagnosticsSettingsBody key={environment?.environmentId ?? ""} />;
737}
738
739function DiagnosticsSettingsBody() {
740 const { environment } = useSettingsScope();
741 const environmentId = environment?.environmentId ?? null;
742 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
743 const observability = environment?.serverConfig?.observability;
744 const availableEditors = environment?.serverConfig?.availableEditors;
745 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
746 const session = useEnvironmentQuery(
747 environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
748 );
749 const diagnosticsAccess = resolveUsageAccess({
750 connectionPhase: environment?.connection.phase ?? "available",
751 session: session.data,
752 hasSessionError: session.error !== null,
753 });
754 const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics;
755 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
756 reportFailure: false,
757 });
758 const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors ?? []);
759 const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);
760 const selectedResourceWindow =
761 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
762 RESOURCE_HISTORY_WINDOWS[1];
763 const { data, error, isPending, refresh } = useEnvironmentQuery(
764 environmentId === null || !canReadDiagnostics
765 ? null
766 : serverEnvironment.traceDiagnostics({ environmentId, input: {} }),
767 );
768 const {
769 data: processData,
770 error: processError,
771 isPending: isProcessPending,
772 refresh: refreshProcesses,
773 } = useEnvironmentQuery(
774 environmentId === null || !canReadDiagnostics
775 ? null
776 : serverEnvironment.processDiagnostics({ environmentId, input: {} }),
777 );
778 const {
779 data: resourceData,
780 error: resourceError,
781 isPending: isResourcePending,
782 refresh: refreshResources,
783 } = useEnvironmentQuery(
784 environmentId === null || !canReadDiagnostics
785 ? null
786 : serverEnvironment.processResourceHistory({
787 environmentId,
788 input: {
789 windowMs: selectedResourceWindow.windowMs,
790 bucketMs: selectedResourceWindow.bucketMs,
791 },
792 }),
793 );
794 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
795 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
796 const [signalingPid, setSignalingPid] = useState<number | null>(null);
797 const signalingPidRef = useRef<number | null>(null);
798
799 const openLogsDirectory = () => {
800 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
801 if (!logsDirectoryPath) return;
802
803 if (
804 environmentId === null ||
805 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
806 ) {
807 return;
808 }
809
810 setIsOpeningLogsDirectory(true);
811 setOpenLogsDirectoryError(null);
812 void (async () => {
813 const result = await openInEditor(logsDirectoryPath);
814 setIsOpeningLogsDirectory(false);
815 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
816 const error = squashAtomCommandFailure(result);
817 setOpenLogsDirectoryError(
818 error instanceof Error ? error.message : "Unable to open logs folder.",
819 );
820 }
821 })();
822 };
823
824 const isInitialLoading = isPending && data === null;
825 const isProcessInitialLoading = isProcessPending && processData === null;
826 const isResourceInitialLoading = isResourcePending && resourceData === null;
827 // Wait for initial tables to stabilize search targets; later loads must preserve export drafts.
828 const [exportFormEnvironmentId, setExportFormEnvironmentId] =
829 useState<typeof environmentId>(null);
830 if (
831 exportFormEnvironmentId !== environmentId &&
832 canReadDiagnostics &&
833 !isInitialLoading &&
834 !isProcessInitialLoading &&
835 !isResourceInitialLoading
836 ) {
837 setExportFormEnvironmentId(environmentId);
838 }
839 const signalProcess = async (pid: number, signal: ServerProcessSignal) => {
840 const targetEnvironmentId = environmentId;
841 const process = processData?.processes.find((entry) => entry.pid === pid);
842 if (
843 targetEnvironmentId === null ||
844 process === undefined ||
845 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
846 )
847 return;
848 if (signalingPidRef.current !== null) return;
849 signalingPidRef.current = pid;
850 setSignalingPid(pid);
851 const clearSignaling = () => {
852 signalingPidRef.current = null;
853 setSignalingPid(null);
854 };
855 if (signal === "SIGKILL") {
856 let confirmed = false;
857 try {
858 confirmed = await ensureLocalApi().dialogs.confirm(
859 `Send SIGKILL to process ${pid}? This cannot be handled by the process.`,
860 { variant: "destructive" },
861 );
862 } catch (error) {
863 clearSignaling();
864 toastManager.add({
865 type: "error",
866 title: "Could not confirm signal",
867 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
868 });
869 return;
870 }
871 if (!confirmed) {
872 clearSignaling();
873 return;
874 }
875 }
876 // The server rejects a pid whose start time no longer matches, so a process that exited
877 // during confirmation is never signaled by mistake.
878 if (!readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)) {
879 clearSignaling();
880 return;
881 }
882
883 const result = await signalServerProcess({
884 environmentId: targetEnvironmentId,
885 input: { pid, startTimeMs: process.startTimeMs, signal },
886 });
887 clearSignaling();
888 if (result._tag === "Failure") {
889 if (!isAtomCommandInterrupted(result)) {
890 const error = squashAtomCommandFailure(result);
891 toastManager.add({
892 type: "error",
893 title: `Could not send ${signal}`,
894 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
895 });
896 }
897 return;
898 }
899 if (!result.value.signaled) {
900 const message = Option.getOrUndefined(result.value.message);
901 refreshProcesses();
902 if (isStaleProcessSignalMessage(message)) {
903 toastManager.add({
904 type: "info",
905 title: "Process already exited",
906 description: "The process is not a child of the T3 Server. It might already have exited.",
907 });
908 return;
909 }
910
911 toastManager.add({
912 type: "error",
913 title: `Could not send ${signal}`,
914 description: message ?? `Failed to send ${signal}.`,
915 });
916 return;
917 }
918 refreshProcesses();
919 };
920
921 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
922 const processNote = processDiagnosticsError?.message ?? processError;
923 // A monitor error with no processes means the totals are not zero, just unknown.
924 const processTotalsUnavailable = processData
925 ? processDiagnosticsError !== null && processData.processes.length === 0
926 : processError !== null;
927 const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null;
928 const resourceNote = processResourceError?.message ?? resourceError;
929 const resourceTotalsUnavailable = resourceData
930 ? processResourceError !== null && resourceData.topProcesses.length === 0
931 : resourceError !== null;
932 const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null;
933 const traceDiagnosticsPartialFailure = data
934 ? Option.getOrElse(data.partialFailure, () => false)
935 : false;
936
937 if (!canReadDiagnostics) {
938 return (
939 <SettingsPageContainer>
940 <p className="text-sm text-muted-foreground">
941 {environmentId === null
942 ? "Connect an environment to see diagnostics."
943 : diagnosticsAccess.isPending
944 ? "Checking diagnostics access…"
945 : diagnosticsAccess.error}
946 </p>
947 </SettingsPageContainer>
948 );
949 }
950
951 return (
952 <SettingsPageContainer width="expanded">
953 <section className="space-y-2">
954 <h2 className="text-meta text-dim-foreground">Overview</h2>
955 <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
956 <OverviewTile
957 label="Server PID"
958 value={processData ? String(processData.serverPid) : "..."}
959 />
960 <OverviewTile
961 label="CPU"
962 value={
963 processTotalsUnavailable
964 ? UNAVAILABLE_VALUE
965 : processData
966 ? `${processData.totalCpuPercent.toFixed(1)}%`
967 : "..."
968 }
969 tooltip="Total CPU across live child processes of the current server process. The desktop shell and other parent processes are not included."
970 />
971 <OverviewTile
972 label="Memory"
973 value={
974 processTotalsUnavailable
975 ? UNAVAILABLE_VALUE
976 : processData
977 ? formatBytes(processData.totalRssBytes)
978 : "..."
979 }
980 tooltip="Total resident memory across live child processes of the current server process. The desktop shell and other parent processes are not included."
981 />
982 <OverviewTile
983 label="Child processes"
984 value={
985 processTotalsUnavailable
986 ? UNAVAILABLE_VALUE
987 : processData
988 ? formatCount(processData.processCount)
989 : "..."
990 }
991 />
992 </div>
993 </section>
994
995 <SettingsSection
996 title="Live processes"
997 headerAction={
998 <div className="flex items-center gap-1.5">
999 <DiagnosticsLastChecked checkedAt={processData?.readAt ?? null} />
1000 <DiagnosticsRefreshButton
1001 isPending={isProcessPending}
1002 label="Refresh process diagnostics"
1003 onClick={refreshProcesses}
1004 />
1005 </div>
1006 }
1007 >
1008 {processNote ? (
1009 <SettingsNote icon={<CircleSlashIcon className="text-dim-foreground" />}>
1010 {processNote}
1011 </SettingsNote>
1012 ) : null}
1013 {processTotalsUnavailable ? null : (
1014 <ProcessDiagnosticsTable
1015 processes={processData?.processes ?? []}
1016 canMaintainEnvironment={canMaintainEnvironment}
1017 signalingPid={signalingPid}
1018 onSignal={signalProcess}
1019 emptyLabel={
1020 isProcessInitialLoading
1021 ? "Loading live processes..."
1022 : "No live descendant processes found."
1023 }
1024 />
1025 )}
1026 </SettingsSection>
1027
1028 <SettingsSection
1029 title="Trace diagnostics"
1030 className="max-sm:[&>[data-settings-scroll-target]]:flex-wrap max-sm:[&>[data-settings-scroll-target]]:gap-2"
1031 headerAction={
1032 <div className="flex flex-wrap items-center gap-1.5 sm:flex-nowrap">
1033 <span className="max-sm:basis-full sm:contents">
1034 <DiagnosticsLastChecked checkedAt={data?.readAt ?? null} />
1035 </span>
1036 <Button
1037 size="xs"
1038 variant="secondary"
1039 disabled={
1040 !canOpenHostEditor || !observability?.logsDirectoryPath || isOpeningLogsDirectory
1041 }
1042 onClick={openLogsDirectory}
1043 >
1044 <FolderOpenIcon />
1045 Open logs folder
1046 </Button>
1047 <DiagnosticsRefreshButton
1048 isPending={isPending}
1049 label="Refresh trace diagnostics"
1050 onClick={refresh}
1051 />
1052 </div>
1053 }
1054 >
1055 <SettingsRow
1056 title="Spans"
1057 description="Recorded in the current trace files."
1058 control={
1059 <span className="font-mono text-body tabular-nums text-foreground">
1060 {data ? formatCount(data.recordCount) : "..."}
1061 </span>
1062 }
1063 />
1064 <SettingsRow
1065 title="Failures"
1066 description={
1067 data && data.commonFailures[0]
1068 ? `${formatCount(data.commonFailures[0].count)} in ${data.commonFailures[0].name}`
1069 : "Spans that ended with an error."
1070 }
1071 control={
1072 data ? (
1073 <Badge variant={data.failureCount > 0 ? "error" : "secondary"}>
1074 {data.failureCount > 0 ? `${formatCount(data.failureCount)} failed` : "None"}
1075 </Badge>
1076 ) : (
1077 <span className="text-body text-muted-foreground">...</span>
1078 )
1079 }
1080 />
1081 <SettingsRow
1082 title="Slow spans"
1083 description={
1084 data
1085 ? `Spans that took ${formatDuration(data.slowSpanThresholdMs)} or longer.`
1086 : "Spans at or above the slow-span threshold."
1087 }
1088 control={
1089 <Badge variant={data && data.slowSpanCount > 0 ? "warning" : "secondary"}>
1090 {data ? formatCount(data.slowSpanCount) : "..."}
1091 </Badge>
1092 }
1093 />
1094 {data && data.parseErrorCount > 0 ? (
1095 <SettingsRow
1096 title="Parse errors"
1097 description="Trace lines that could not be read."
1098 control={<Badge variant="warning">{formatCount(data.parseErrorCount)}</Badge>}
1099 />
1100 ) : null}
1101 {openLogsDirectoryError || traceDiagnosticsError || error ? (
1102 <div className="space-y-2 px-4 py-3 text-meta text-muted-foreground sm:px-5">
1103 {openLogsDirectoryError ? (
1104 <div className="flex items-start gap-2 text-destructive">
1105 <AlertTriangleIcon className="size-3.5 h-lh shrink-0" />
1106 <span>{openLogsDirectoryError}</span>
1107 </div>
1108 ) : null}
1109 {traceDiagnosticsError ? (
1110 <div
1111 className={cn(
1112 "flex items-start gap-2",
1113 traceDiagnosticsPartialFailure ? "text-warning-foreground" : "text-destructive",
1114 )}
1115 >
1116 <AlertTriangleIcon className="size-3.5 h-lh shrink-0" />
1117 <span>
1118 {traceDiagnosticsPartialFailure
1119 ? `Some trace files could not be read, so diagnostics may be incomplete. ${traceDiagnosticsError.message}`
1120 : traceDiagnosticsError.message}
1121 </span>
1122 </div>
1123 ) : null}
1124 {error ? (
1125 <div className="flex items-start gap-2 text-destructive">
1126 <AlertTriangleIcon className="size-3.5 h-lh shrink-0" />
1127 <span>{error}</span>
1128 </div>
1129 ) : null}
1130 </div>
1131 ) : null}
1132 </SettingsSection>
1133
1134 <SettingsSection title="Latest failures">
1135 {data && data.latestFailures.length > 0 ? (
1136 <DiagnosticsTable columns={[{ label: "Span", width: "w-[38%]" }, { label: "Cause" }]}>
1137 {data.latestFailures.map((failure) => (
1138 <tr key={`${failure.traceId}:${failure.spanId}`}>
1139 <td className="py-3 pr-3 pl-4 align-top sm:pl-5">
1140 <div className="break-words text-strong-foreground">
1141 <SpanName name={failure.name} />
1142 </div>
1143 <RowDetail>
1144 <Tooltip>
1145 <TooltipTrigger render={<span tabIndex={0} />}>
1146 {formatDuration(failure.durationMs)}
1147 </TooltipTrigger>
1148 <TooltipPopup side="top">
1149 Ended <RelativeTimeNoWrap value={failure.endedAt} />
1150 </TooltipPopup>
1151 </Tooltip>
1152 </RowDetail>
1153 </td>
1154 <td className="py-3 pr-4 pl-3 align-top break-words text-muted-foreground sm:pr-5">
1155 <ExpandableText text={failure.cause} />
1156 </td>
1157 </tr>
1158 ))}
1159 </DiagnosticsTable>
1160 ) : (
1161 <EmptyRows label={isInitialLoading ? "Loading failures..." : "No failed spans found."} />
1162 )}
1163 </SettingsSection>
1164
1165 <SettingsSection title="Most common failures">
1166 {data && data.commonFailures.length > 0 ? (
1167 <DiagnosticsTable
1168 columns={[
1169 { label: "Span", width: "w-[38%]" },
1170 { label: "Cause" },
1171 { label: "Count", width: "w-20", numeric: true },
1172 ]}
1173 >
1174 {data.commonFailures.map((failure) => (
1175 <tr key={`${failure.name}:${failure.cause}`}>
1176 <td className="py-3 pr-3 pl-4 align-top sm:pl-5">
1177 <div className="break-words text-strong-foreground">
1178 <SpanName name={failure.name} />
1179 </div>
1180 <RowDetail>
1181 Last <RelativeTimeNoWrap value={failure.lastSeenAt} />
1182 </RowDetail>
1183 </td>
1184 <td className="px-3 py-3 align-top break-words text-muted-foreground">
1185 <ExpandableText text={failure.cause} />
1186 </td>
1187 <td className="py-3 pr-4 pl-3 text-right align-top font-mono tabular-nums sm:pr-5">
1188 {formatCount(failure.count)}
1189 </td>
1190 </tr>
1191 ))}
1192 </DiagnosticsTable>
1193 ) : (
1194 <EmptyRows
1195 label={isInitialLoading ? "Loading failure groups..." : "No repeated failures found."}
1196 />
1197 )}
1198 </SettingsSection>
1199
1200 <SettingsSection title="Slowest spans">
1201 {data && data.slowestSpans.length > 0 ? (
1202 <DiagnosticsTable
1203 columns={[
1204 { label: "Span" },
1205 { label: "Duration", width: "w-22", numeric: true },
1206 { label: "Ended", width: "w-24", numeric: true },
1207 ]}
1208 >
1209 {data.slowestSpans.map((span) => (
1210 <tr key={`${span.traceId}:${span.spanId}`}>
1211 <td className="py-3 pr-3 pl-4 align-top sm:pl-5">
1212 <div className="break-words text-strong-foreground">
1213 <SpanName name={span.name} />
1214 </div>
1215 <div className="mt-0.5 text-dim-foreground">
1216 <TraceIdCell traceId={span.traceId} />
1217 </div>
1218 </td>
1219 <td className="px-3 py-3 text-right align-top font-mono tabular-nums">
1220 {formatDuration(span.durationMs)}
1221 </td>
1222 <td className="py-3 pr-4 pl-3 text-right align-top font-mono tabular-nums whitespace-nowrap text-muted-foreground sm:pr-5">
1223 <RelativeTimeNoWrap value={span.endedAt} />
1224 </td>
1225 </tr>
1226 ))}
1227 </DiagnosticsTable>
1228 ) : (
1229 <EmptyRows label={isInitialLoading ? "Loading slow spans..." : "No spans found."} />
1230 )}
1231 </SettingsSection>
1232
1233 <SettingsSection title="Span logs">
1234 {data && data.latestWarningAndErrorLogs.length > 0 ? (
1235 <DiagnosticsTable
1236 columns={[
1237 { label: "Time", width: "w-24" },
1238 { label: "Span", width: "w-[34%]" },
1239 { label: "Message" },
1240 ]}
1241 >
1242 {data.latestWarningAndErrorLogs.map((event) => (
1243 <tr
1244 key={`${event.traceId}:${event.spanId}:${DateTime.formatIso(event.seenAt)}:${event.message}`}
1245 className="hover:surface-ghost-hover"
1246 >
1247 <td className="py-3 pr-3 pl-4 align-top font-mono tabular-nums whitespace-nowrap text-muted-foreground sm:pl-5">
1248 <div>
1249 <RelativeTimeNoWrap value={event.seenAt} />
1250 </div>
1251 <span className="mt-1 inline-flex rounded-badge bg-secondary px-1.5 py-0.5 text-caption uppercase text-foreground">
1252 {event.level}
1253 </span>
1254 </td>
1255 <td className="px-3 py-3 align-top">
1256 <div className="truncate text-meta text-strong-foreground">{event.spanName}</div>
1257 <div className="mt-0.5 text-dim-foreground">
1258 <TraceIdCell traceId={event.traceId} />
1259 </div>
1260 </td>
1261 <td className="py-3 pr-4 pl-3 align-top break-words text-muted-foreground sm:pr-5">
1262 <ExpandableText
1263 collapsedClassName="line-clamp-2"
1264 expandLabel="Show full message"
1265 text={event.message}
1266 />
1267 </td>
1268 </tr>
1269 ))}
1270 </DiagnosticsTable>
1271 ) : (
1272 <EmptyRows
1273 label={isInitialLoading ? "Loading recent logs..." : "No warnings or errors found."}
1274 />
1275 )}
1276 </SettingsSection>
1277
1278 <SettingsSection title="Top span names">
1279 {data && data.topSpansByCount.length > 0 ? (
1280 <DiagnosticsTable
1281 columns={[
1282 { label: "Span" },
1283 { label: "Count", width: "w-20", numeric: true },
1284 { label: "Failures", width: "w-20", numeric: true },
1285 { label: "Average", width: "w-20", numeric: true },
1286 { label: "Max", width: "w-22", numeric: true },
1287 ]}
1288 >
1289 {data.topSpansByCount.map((span) => (
1290 <tr key={span.name}>
1291 <td className="break-words py-3 pr-3 pl-4 align-top text-strong-foreground sm:pl-5">
1292 <SpanName name={span.name} />
1293 </td>
1294 <td className="whitespace-nowrap px-3 py-3 text-right align-top font-mono tabular-nums">
1295 {formatCount(span.count)}
1296 </td>
1297 <td className="whitespace-nowrap px-3 py-3 text-right align-top font-mono tabular-nums">
1298 {formatCount(span.failureCount)}
1299 </td>
1300 <td className="whitespace-nowrap px-3 py-3 text-right align-top font-mono tabular-nums">
1301 {formatDuration(span.averageDurationMs)}
1302 </td>
1303 <td className="whitespace-nowrap py-3 pr-4 pl-3 text-right align-top font-mono tabular-nums sm:pr-5">
1304 {formatDuration(span.maxDurationMs)}
1305 </td>
1306 </tr>
1307 ))}
1308 </DiagnosticsTable>
1309 ) : (
1310 <EmptyRows label={isInitialLoading ? "Loading span names..." : "No spans found."} />
1311 )}
1312 </SettingsSection>
1313 <ResourceTelemetryDiagnostics environmentId={environmentId} />
1314
1315 <SettingsSection
1316 title="Resource history"
1317 headerAction={
1318 <div className="flex items-center gap-1.5">
1319 <ResourceHistoryWindowSelector
1320 selectedWindowMs={resourceWindowMs}
1321 onSelect={setResourceWindowMs}
1322 />
1323 <DiagnosticsLastChecked checkedAt={resourceData?.readAt ?? null} />
1324 <DiagnosticsRefreshButton
1325 isPending={isResourcePending}
1326 label="Refresh resource history"
1327 onClick={refreshResources}
1328 />
1329 </div>
1330 }
1331 >
1332 <StatsGrid>
1333 <StatBlock
1334 label="CPU Time"
1335 value={
1336 resourceTotalsUnavailable
1337 ? UNAVAILABLE_VALUE
1338 : resourceData
1339 ? formatCpuTime(resourceData.totalCpuSecondsApprox)
1340 : "..."
1341 }
1342 tooltip="Approximate active CPU time for the T3 server root process and its descendants during the selected window. It grows only while sampled processes use CPU and older samples leave as the window moves."
1343 />
1344 <StatBlock
1345 label="Samples"
1346 value={resourceData ? formatCount(resourceData.retainedSampleCount) : "..."}
1347 tooltip="In-memory process samples retained by the server. The server samples only while diagnostics is open, and this resets when the server restarts."
1348 />
1349 <StatBlock
1350 label="Interval"
1351 value={
1352 resourceData
1353 ? resourceData.sampleIntervalMs === 0
1354 ? "On demand"
1355 : formatDuration(resourceData.sampleIntervalMs)
1356 : "..."
1357 }
1358 />
1359 <StatBlock
1360 label="Processes"
1361 value={
1362 resourceTotalsUnavailable
1363 ? UNAVAILABLE_VALUE
1364 : resourceData
1365 ? formatCount(resourceData.topProcesses.length)
1366 : "..."
1367 }
1368 />
1369 </StatsGrid>
1370 {resourceNote ? (
1371 <div className="border-t border-border/60">
1372 {/* Live processes already states this reason; do not repeat it. */}
1373 {resourceNote === processNote ? (
1374 <EmptyRows label="Unavailable" />
1375 ) : (
1376 <SettingsNote icon={<CircleSlashIcon className="text-dim-foreground" />}>
1377 {resourceNote}
1378 </SettingsNote>
1379 )}
1380 </div>
1381 ) : null}
1382 {resourceTotalsUnavailable ? null : (
1383 <>
1384 {resourceData && resourceData.topProcesses.length > 0 ? (
1385 <ProcessResourceHistoryChart buckets={resourceData.buckets} />
1386 ) : null}
1387 <ProcessResourceHistoryTable
1388 processes={resourceData?.topProcesses ?? []}
1389 emptyLabel={
1390 isResourceInitialLoading
1391 ? "Collecting process resource samples..."
1392 : "No process resource samples found for this window."
1393 }
1394 />
1395 </>
1396 )}
1397 </SettingsSection>
1398 {exportFormEnvironmentId === environmentId && <TelemetryExportSettings />}
1399 </SettingsPageContainer>
1400 );
1401}