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