@skyhook-io/radar-app 1.13.1 → 1.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +74 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1913 -395
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +92 -0
  28. package/src/components/diagnose/investigationExplanation.ts +29 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1337 -541
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. package/src/index.ts +1 -1
@@ -0,0 +1,2253 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useId,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { clsx } from "clsx";
12
+ import {
13
+ Activity,
14
+ AlertTriangle,
15
+ Boxes,
16
+ Bug,
17
+ CheckCircle2,
18
+ CircleAlert,
19
+ Clock3,
20
+ FileClock,
21
+ FileSearch,
22
+ Info,
23
+ ListTree,
24
+ Network,
25
+ ScrollText,
26
+ SearchCheck,
27
+ ShieldAlert,
28
+ SquareArrowOutUpRight,
29
+ } from "lucide-react";
30
+ import {
31
+ Badge,
32
+ Collapse,
33
+ CollapseChevron,
34
+ DiffViewer,
35
+ StatusDot,
36
+ TerminalBlock,
37
+ defaultConditionTone,
38
+ displayKind,
39
+ formatRelativeAgeTime,
40
+ mapHealthToTone,
41
+ ResourceLink,
42
+ stripAnsi,
43
+ } from "@skyhook-io/k8s-ui";
44
+ import { apiVersionToGroup } from "../../utils/navigation";
45
+ import { parseLogLine } from "../../utils/log-format";
46
+ import {
47
+ evidenceDisplaySnapshot,
48
+ groupEvidenceCoverage,
49
+ type EvidenceCoverageGroup,
50
+ } from "./investigationEvidencePresentation";
51
+
52
+ import {
53
+ investigationEvidenceSubjectRef,
54
+ investigationEvidenceSourceDomId,
55
+ type InvestigationEvidenceData,
56
+ type InvestigationEvidenceGroup,
57
+ type InvestigationEvidenceObservation,
58
+ type InvestigationEvidenceProjection,
59
+ type InvestigationRootCauseEvidenceResolution,
60
+ type InvestigationEvidenceSource,
61
+ type InvestigationEvidenceTier,
62
+ } from "./investigationEvidence";
63
+ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
64
+ import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
65
+ import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
66
+ import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
67
+ import { evidenceSourceExcerpt } from "./investigationSourceFocus";
68
+ import { Tooltip } from "../ui/Tooltip";
69
+
70
+ import {
71
+ investigationDisclosureSettleDelay,
72
+ prefersReducedMotion,
73
+ useDisclosureReveal,
74
+ } from "./useDisclosureReveal";
75
+ export {
76
+ INVESTIGATION_DISCLOSURE_SETTLE_MS,
77
+ investigationDisclosureSettleDelay,
78
+ investigationDisclosureScrollTop,
79
+ } from "./useDisclosureReveal";
80
+ export const VISIBLE_LOG_EVIDENCE_LINES = 12;
81
+
82
+ const EvidenceNavigationContext = createContext<{
83
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
84
+ revealSourceId?: string;
85
+ revealRequestId?: number;
86
+ expandedGroupIds?: ReadonlySet<string>;
87
+ onGroupOpenChange?: (id: string, open: boolean) => void;
88
+ citedOrderByGroup?: ReadonlyMap<string, number>;
89
+ }>({});
90
+
91
+ function evidenceTypePrefersFullRow(
92
+ type: InvestigationEvidenceData["type"],
93
+ ): boolean {
94
+ return type === "logs" || type === "events";
95
+ }
96
+
97
+ // Supporting evidence becomes a two-column grid when the pane is wide enough.
98
+ // Keep compact cards paired only with an adjacent compact card. Otherwise a
99
+ // full-row card between them strands a conspicuous empty half-row (and makes the
100
+ // visual order look accidental), as does an odd card at the end of a run.
101
+ export function investigationEvidenceFullRowFlags(
102
+ types: readonly InvestigationEvidenceData["type"][],
103
+ ): boolean[] {
104
+ const fullRow = types.map(evidenceTypePrefersFullRow);
105
+ let compactRunStart = 0;
106
+
107
+ for (let index = 0; index <= types.length; index += 1) {
108
+ if (index < types.length && !fullRow[index]) continue;
109
+ const compactRunLength = index - compactRunStart;
110
+ if (compactRunLength % 2 === 1) fullRow[index - 1] = true;
111
+ compactRunStart = index + 1;
112
+ }
113
+
114
+ return fullRow;
115
+ }
116
+
117
+ type EvidenceCollection = "main" | "workload" | "earlier";
118
+
119
+ export function partitionInvestigationEvidence(
120
+ groups: InvestigationEvidenceGroup[],
121
+ resolution?: InvestigationRootCauseEvidenceResolution,
122
+ ) {
123
+ const selected = new Set(
124
+ resolution?.status === "linked"
125
+ ? resolution.links.map((link) => link.originalGroupId)
126
+ : [],
127
+ );
128
+ const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
129
+ {
130
+ main: [],
131
+ workload: [],
132
+ earlier: [],
133
+ };
134
+ const collectionByGroup = new Map<string, EvidenceCollection>();
135
+ const adverse = (group: InvestigationEvidenceGroup) =>
136
+ ["error", "alert", "warning"].includes(group.latest.tone);
137
+ for (const group of groups) {
138
+ const broader = group.latest.relevance === "broader";
139
+ // Citations select tool results, not individual rows in a broad search.
140
+ // Only a focused, unambiguous fact can be promoted by a source citation.
141
+ if (broader) {
142
+ const link = resolution?.links.find(
143
+ (item) => item.originalGroupId === group.id,
144
+ );
145
+ const focused = ["resource", "logs", "crash"].includes(
146
+ group.latest.data.type,
147
+ );
148
+ const sourceGroups = link
149
+ ? groups.filter(
150
+ (candidate) =>
151
+ ["resource", "logs", "crash"].includes(
152
+ candidate.latest.data.type,
153
+ ) &&
154
+ candidate.observations.some(
155
+ (observation) => observation.source.id === link.source.id,
156
+ ),
157
+ )
158
+ : [];
159
+ if (!selected.has(group.id) || !focused || sourceGroups.length !== 1)
160
+ continue;
161
+ }
162
+ const main =
163
+ !group.historical &&
164
+ (selected.has(group.id) ||
165
+ (!broader &&
166
+ (group.latest.tier === "key" ||
167
+ (group.latest.tier === "supporting" && adverse(group)))));
168
+ const collection = main
169
+ ? "main"
170
+ : group.historical
171
+ ? "earlier"
172
+ : "workload";
173
+ collections[collection].push(group);
174
+ collectionByGroup.set(group.id, collection);
175
+ }
176
+ collections.main.sort(
177
+ (left, right) =>
178
+ Number(right.latest.tier === "key") -
179
+ Number(left.latest.tier === "key") ||
180
+ Number(adverse(right)) - Number(adverse(left)) ||
181
+ left.firstOrder - right.firstOrder,
182
+ );
183
+ return { ...collections, collectionByGroup };
184
+ }
185
+
186
+ export function investigationEvidenceRevealCollection(
187
+ projection: InvestigationEvidenceProjection,
188
+ sourceId: string,
189
+ partition = partitionInvestigationEvidence(projection.groups),
190
+ ): Exclude<EvidenceCollection, "main"> | "coverage" | undefined {
191
+ const source = projection.sources.find((item) => item.id === sourceId);
192
+ const collection = source?.primaryGroupId
193
+ ? partition.collectionByGroup.get(source.primaryGroupId)
194
+ : undefined;
195
+ if (collection) return collection === "main" ? undefined : collection;
196
+ if (
197
+ projection.limitations.some((limitation) =>
198
+ limitation.sources.some((source) => source.id === sourceId),
199
+ )
200
+ )
201
+ return "coverage";
202
+ return undefined;
203
+ }
204
+
205
+ export function InvestigationEvidencePane({
206
+ projection,
207
+ rootCauseEvidence,
208
+ collecting,
209
+ animateGroupIds,
210
+ onViewSource,
211
+ onViewActivity,
212
+ onOpenResource,
213
+ afterEvidence,
214
+ revealRequest,
215
+ onRevealReady,
216
+ }: {
217
+ projection: InvestigationEvidenceProjection;
218
+ /** Server-validated links for the current root cause; absent without one. */
219
+ rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
220
+ collecting: boolean;
221
+ animateGroupIds: ReadonlySet<string>;
222
+ onViewSource: (
223
+ sourceId: string,
224
+ excerpt?: InvestigationSourceExcerpt,
225
+ ) => void;
226
+ /** Opens the Activity record when no exact result can be identified. */
227
+ onViewActivity: () => void;
228
+ /** Opens an evidence subject in Radar when the producer identified it exactly. */
229
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
230
+ /** Actions follow the complete evidence section, including its disclosures. */
231
+ afterEvidence?: ReactNode;
232
+ /** Explicit Activity → Findings navigation, including repeat clicks. */
233
+ revealRequest?: { sourceId: string; requestId: number };
234
+ onRevealReady?: (sourceId: string) => void;
235
+ }) {
236
+ const [expandedGroupIds, setExpandedGroupIds] = useState<ReadonlySet<string>>(
237
+ new Set(),
238
+ );
239
+ const onGroupOpenChange = useCallback((id: string, open: boolean) => {
240
+ setExpandedGroupIds((previous) => {
241
+ if (previous.has(id) === open) return previous;
242
+ const next = new Set(previous);
243
+ if (open) next.add(id);
244
+ else next.delete(id);
245
+ return next;
246
+ });
247
+ }, []);
248
+ const [coverageOpen, setCoverageOpen] = useState(false);
249
+ const [workloadOpen, setWorkloadOpen] = useState(false);
250
+ const [earlierOpen, setEarlierOpen] = useState(false);
251
+ const handledRevealRequestRef = useRef<number | undefined>(undefined);
252
+ const openingForRevealRequestRef = useRef<number | undefined>(undefined);
253
+ const partition = partitionInvestigationEvidence(
254
+ projection.groups,
255
+ rootCauseEvidence,
256
+ );
257
+ const hasCurrentEvidence =
258
+ partition.main.length + partition.workload.length > 0;
259
+ const revealCollection = revealRequest
260
+ ? investigationEvidenceRevealCollection(
261
+ projection,
262
+ revealRequest.sourceId,
263
+ partition,
264
+ )
265
+ : undefined;
266
+
267
+ // A source link is a navigation request, not a disclosure preference. Open
268
+ // whichever collection owns the source first, then tell the workspace that
269
+ // its double-rAF focus/scroll can safely run outside an inert subtree.
270
+ useLayoutEffect(() => {
271
+ if (
272
+ !revealRequest ||
273
+ handledRevealRequestRef.current === revealRequest.requestId
274
+ ) {
275
+ return;
276
+ }
277
+ const { sourceId, requestId } = revealRequest;
278
+ if (
279
+ (revealCollection === "workload" || revealCollection === "earlier") &&
280
+ !workloadOpen
281
+ ) {
282
+ openingForRevealRequestRef.current = requestId;
283
+ setWorkloadOpen(true);
284
+ return;
285
+ }
286
+ if (revealCollection === "earlier" && !earlierOpen) {
287
+ openingForRevealRequestRef.current = requestId;
288
+ setEarlierOpen(true);
289
+ return;
290
+ }
291
+ if (revealCollection === "coverage" && !coverageOpen) {
292
+ openingForRevealRequestRef.current = requestId;
293
+ setCoverageOpen(true);
294
+ return;
295
+ }
296
+ const finishReveal = () => {
297
+ if (handledRevealRequestRef.current === requestId) return;
298
+ handledRevealRequestRef.current = requestId;
299
+ openingForRevealRequestRef.current = undefined;
300
+ onRevealReady?.(sourceId);
301
+ };
302
+ if (openingForRevealRequestRef.current !== requestId) {
303
+ finishReveal();
304
+ return;
305
+ }
306
+
307
+ // Two animation frames alone can target an item that is still moving. Wait
308
+ // through the shared Collapse transition only when motion is enabled.
309
+ const settleDelay = investigationDisclosureSettleDelay(
310
+ prefersReducedMotion(),
311
+ );
312
+ if (settleDelay === 0) {
313
+ finishReveal();
314
+ return;
315
+ }
316
+ const timer = window.setTimeout(finishReveal, settleDelay);
317
+ return () => window.clearTimeout(timer);
318
+ }, [
319
+ revealRequest,
320
+ revealCollection,
321
+ onRevealReady,
322
+ earlierOpen,
323
+ coverageOpen,
324
+ workloadOpen,
325
+ ]);
326
+
327
+ const coverageGroups = groupEvidenceCoverage(projection.limitations);
328
+ const limitationSummary = coverageGroups
329
+ .map((group) => `${group.label}: ${group.summary}`)
330
+ .join(" · ");
331
+ const content = (
332
+ <section
333
+ aria-labelledby="investigation-radar-evidence"
334
+ className="investigation-evidence @container/evidence space-y-4 rounded-xl border p-4"
335
+ >
336
+ <span className="sr-only" role="status" aria-live="polite">
337
+ {projection.limitations.length > 0
338
+ ? `Evidence coverage update: ${limitationSummary}`
339
+ : ""}
340
+ </span>
341
+ <div className="flex min-w-0 items-start justify-between gap-3">
342
+ <div className="min-w-0">
343
+ <div className="flex items-center gap-2">
344
+ <h2
345
+ id="investigation-radar-evidence"
346
+ className="text-lg font-semibold text-theme-text-primary"
347
+ >
348
+ Evidence
349
+ </h2>
350
+ {collecting ? (
351
+ <span className="inline-flex items-center gap-1.5 text-xs text-accent-text">
352
+ <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
353
+ collecting
354
+ </span>
355
+ ) : null}
356
+ </div>
357
+ </div>
358
+ </div>
359
+
360
+ {projection.limitations.length > 0 ? (
361
+ <CoverageStrip
362
+ groups={coverageGroups}
363
+ visibleGroupIds={new Set(partition.collectionByGroup.keys())}
364
+ summary={limitationSummary}
365
+ onViewSource={onViewSource}
366
+ open={coverageOpen}
367
+ onOpenChange={setCoverageOpen}
368
+ />
369
+ ) : null}
370
+
371
+ <div className="space-y-4">
372
+ {rootCauseEvidence && rootCauseEvidence.status !== "linked" ? (
373
+ <AssessmentEvidenceQualification
374
+ resolution={rootCauseEvidence}
375
+ onViewActivity={onViewActivity}
376
+ />
377
+ ) : null}
378
+
379
+ <div className="grid items-start gap-2.5">
380
+ {partition.main.map((group) => (
381
+ <EvidenceCard
382
+ key={group.id}
383
+ group={group}
384
+ spanFullRow
385
+ animateArrival={animateGroupIds.has(group.id)}
386
+ onViewSource={onViewSource}
387
+ />
388
+ ))}
389
+ </div>
390
+
391
+ {!hasCurrentEvidence ? (
392
+ <EmptyCollection
393
+ collecting={collecting}
394
+ hasEarlierEvidence={partition.earlier.length > 0}
395
+ onViewActivity={onViewActivity}
396
+ />
397
+ ) : null}
398
+
399
+ <CollapsedEvidenceCollection
400
+ id="investigation-workload-evidence"
401
+ title="More evidence about this workload"
402
+ description=""
403
+ groups={partition.workload}
404
+ totalCount={partition.workload.length + partition.earlier.length}
405
+ animateGroupIds={animateGroupIds}
406
+ onViewSource={onViewSource}
407
+ open={workloadOpen}
408
+ onOpenChange={setWorkloadOpen}
409
+ >
410
+ <CollapsedEvidenceCollection
411
+ id="investigation-earlier-evidence"
412
+ title="Previous observations"
413
+ description="Earlier does not mean resolved."
414
+ groups={partition.earlier}
415
+ animateGroupIds={animateGroupIds}
416
+ onViewSource={onViewSource}
417
+ open={earlierOpen}
418
+ onOpenChange={setEarlierOpen}
419
+ />
420
+ </CollapsedEvidenceCollection>
421
+ </div>
422
+ </section>
423
+ );
424
+
425
+ return (
426
+ <EvidenceNavigationContext.Provider
427
+ value={{
428
+ onOpenResource,
429
+ expandedGroupIds,
430
+ onGroupOpenChange,
431
+ revealSourceId: revealRequest?.sourceId,
432
+ revealRequestId: revealRequest?.requestId,
433
+ citedOrderByGroup: new Map(
434
+ rootCauseEvidence?.links.flatMap((link) =>
435
+ link.originalGroupId
436
+ ? [[link.originalGroupId, link.source.order] as const]
437
+ : [],
438
+ ) ?? [],
439
+ ),
440
+ }}
441
+ >
442
+ {content}
443
+ {afterEvidence}
444
+ </EvidenceNavigationContext.Provider>
445
+ );
446
+ }
447
+
448
+ function AssessmentEvidenceQualification({
449
+ resolution,
450
+ onViewActivity,
451
+ }: {
452
+ resolution: InvestigationRootCauseEvidenceResolution;
453
+ onViewActivity: () => void;
454
+ }) {
455
+ return (
456
+ <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5">
457
+ <AlertTriangle
458
+ className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
459
+ aria-hidden
460
+ />
461
+ <div className="min-w-0 flex-1">
462
+ <p className="text-xs font-medium text-theme-text-primary">
463
+ {resolution.status === "invalid"
464
+ ? "Assessment references could not be matched"
465
+ : "Assessment does not cite specific Radar evidence"}
466
+ </p>
467
+ <p className="mt-0.5 text-xs leading-relaxed text-theme-text-tertiary">
468
+ {resolution.status === "invalid"
469
+ ? "Radar could not match the assessment’s references to this investigation. Review Activity before acting."
470
+ : "Review Activity and the Radar evidence below before acting on the agent’s conclusion."}
471
+ </p>
472
+ </div>
473
+ <button
474
+ type="button"
475
+ onClick={onViewActivity}
476
+ className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
477
+ >
478
+ View Activity
479
+ </button>
480
+ </div>
481
+ );
482
+ }
483
+
484
+ function EmptyCollection({
485
+ collecting,
486
+ hasEarlierEvidence,
487
+ onViewActivity,
488
+ }: {
489
+ collecting: boolean;
490
+ hasEarlierEvidence: boolean;
491
+ onViewActivity: () => void;
492
+ }) {
493
+ return (
494
+ <div className="rounded-lg border border-dashed border-theme-border px-4 py-5 text-center">
495
+ {collecting ? (
496
+ <Activity className="mx-auto h-5 w-5 text-accent" aria-hidden />
497
+ ) : (
498
+ <SearchCheck
499
+ className="mx-auto h-5 w-5 text-theme-text-tertiary"
500
+ aria-hidden
501
+ />
502
+ )}
503
+ <p className="mt-2 text-sm font-medium text-theme-text-secondary">
504
+ {collecting
505
+ ? "Evidence will appear here"
506
+ : hasEarlierEvidence
507
+ ? "No current evidence was captured during verification"
508
+ : "No relevant evidence to show yet"}
509
+ </p>
510
+ <p className="mx-auto mt-1 max-w-md text-xs leading-relaxed text-theme-text-tertiary">
511
+ {collecting
512
+ ? "The Activity pane remains the live record while the agent investigates."
513
+ : hasEarlierEvidence
514
+ ? "Earlier observations remain below. This does not prove that those conditions resolved."
515
+ : "Investigation details are still available in Activity. This does not mean the resource is healthy."}
516
+ </p>
517
+ <button
518
+ type="button"
519
+ onClick={onViewActivity}
520
+ className="mt-2 rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
521
+ >
522
+ View Activity
523
+ </button>
524
+ </div>
525
+ );
526
+ }
527
+
528
+ function CollapsedEvidenceCollection({
529
+ id,
530
+ title,
531
+ description,
532
+ groups,
533
+ animateGroupIds,
534
+ onViewSource,
535
+ open,
536
+ onOpenChange,
537
+ totalCount = groups.length,
538
+ children,
539
+ }: {
540
+ id: string;
541
+ title: string;
542
+ description: string;
543
+ groups: InvestigationEvidenceGroup[];
544
+ animateGroupIds: ReadonlySet<string>;
545
+ onViewSource: (
546
+ sourceId: string,
547
+ excerpt?: InvestigationSourceExcerpt,
548
+ ) => void;
549
+ open: boolean;
550
+ onOpenChange: (open: boolean) => void;
551
+ totalCount?: number;
552
+ children?: ReactNode;
553
+ }) {
554
+ const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
555
+ if (totalCount === 0) return null;
556
+ const fullRowFlags = investigationEvidenceFullRowFlags(
557
+ groups.map((group) => group.latest.data.type),
558
+ );
559
+ return (
560
+ <section
561
+ ref={elementRef}
562
+ className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
563
+ >
564
+ <button
565
+ type="button"
566
+ aria-expanded={open}
567
+ aria-controls={id}
568
+ onClick={() => {
569
+ onOpenChange(!open);
570
+ revealAfterToggle(!open);
571
+ }}
572
+ className="flex w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left hover:bg-theme-hover/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
573
+ >
574
+ <span className="min-w-0 flex-1">
575
+ <span className="block text-xs font-semibold text-theme-text-secondary">
576
+ {title}
577
+ </span>
578
+ {description ? (
579
+ <span className="block text-xs text-theme-text-tertiary">
580
+ {description}
581
+ </span>
582
+ ) : null}
583
+ </span>
584
+ <span className="shrink-0 font-mono text-xs text-theme-text-tertiary">
585
+ {totalCount}
586
+ </span>
587
+ <CollapseChevron open={open} className="h-4 w-4" />
588
+ </button>
589
+ <div id={id}>
590
+ <Collapse open={open}>
591
+ <div
592
+ className={clsx(
593
+ "grid items-start gap-2.5 @min-[760px]/evidence:grid-cols-2",
594
+ "border-t border-theme-border/60 p-2.5",
595
+ )}
596
+ >
597
+ {groups.map((group, index) => (
598
+ <EvidenceCard
599
+ key={group.id}
600
+ group={group}
601
+
602
+ animateArrival={animateGroupIds.has(group.id)}
603
+ onViewSource={onViewSource}
604
+ spanFullRow={fullRowFlags[index]}
605
+ prominence="secondary"
606
+ />
607
+ ))}
608
+ {children ? <div className="col-span-full">{children}</div> : null}
609
+ </div>
610
+ </Collapse>
611
+ </div>
612
+ </section>
613
+ );
614
+ }
615
+
616
+ function CoverageStrip({
617
+ groups,
618
+ visibleGroupIds,
619
+ summary,
620
+ onViewSource,
621
+ open,
622
+ onOpenChange,
623
+ }: {
624
+ groups: EvidenceCoverageGroup[];
625
+ visibleGroupIds: ReadonlySet<string>;
626
+ summary: string;
627
+ onViewSource: (
628
+ sourceId: string,
629
+ excerpt?: InvestigationSourceExcerpt,
630
+ ) => void;
631
+ open: boolean;
632
+ onOpenChange: (open: boolean) => void;
633
+ }) {
634
+ const hasError = groups.some((group) => group.hasError);
635
+ const historyOnly = groups.every((group) => group.historyOnly);
636
+ const { elementRef, revealAfterToggle } =
637
+ useDisclosureReveal<HTMLDivElement>();
638
+ const regionId = "investigation-evidence-coverage";
639
+ const anchoredSources = new Set<string>();
640
+ return (
641
+ <div
642
+ ref={elementRef}
643
+ className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
644
+ >
645
+ <button
646
+ type="button"
647
+ aria-expanded={open}
648
+ aria-controls={regionId}
649
+ onClick={() => {
650
+ onOpenChange(!open);
651
+ revealAfterToggle(!open);
652
+ }}
653
+ className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
654
+ >
655
+ {historyOnly ? (
656
+ <Info
657
+ className="h-4 w-4 shrink-0 text-theme-text-tertiary"
658
+ aria-hidden
659
+ />
660
+ ) : hasError ? (
661
+ <CircleAlert className="h-4 w-4 shrink-0 text-red-400" aria-hidden />
662
+ ) : (
663
+ <AlertTriangle
664
+ className="h-4 w-4 shrink-0 text-amber-500"
665
+ aria-hidden
666
+ />
667
+ )}
668
+ <span className="min-w-0 flex-1">
669
+ <span
670
+ className={clsx(
671
+ "block text-xs",
672
+ historyOnly
673
+ ? "font-medium text-theme-text-secondary"
674
+ : "font-semibold text-theme-text-primary",
675
+ )}
676
+ >
677
+ {historyOnly
678
+ ? "Change history is limited"
679
+ : "Evidence coverage is incomplete"}
680
+ </span>
681
+ {!historyOnly && !open ? (
682
+ <span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
683
+ {summary}
684
+ </span>
685
+ ) : null}
686
+ </span>
687
+ <CollapseChevron open={open} className="h-4 w-4" />
688
+ </button>
689
+ <div id={regionId}>
690
+ <Collapse open={open}>
691
+ <div className="divide-y divide-theme-border/60 border-t border-theme-border/60">
692
+ {groups.map((group) => {
693
+ const sourceIds = group.limitations
694
+ .flatMap((item) => item.sources)
695
+ .filter((source) => {
696
+ if (
697
+ (source.primaryGroupId &&
698
+ visibleGroupIds.has(source.primaryGroupId)) ||
699
+ anchoredSources.has(source.id)
700
+ )
701
+ return false;
702
+ anchoredSources.add(source.id);
703
+ return true;
704
+ })
705
+ .map((source) => source.id);
706
+ return (
707
+ <CoverageGroupRow
708
+ key={group.label}
709
+ group={group}
710
+ sourceIds={sourceIds}
711
+ onViewSource={onViewSource}
712
+ />
713
+ );
714
+ })}
715
+ </div>
716
+ </Collapse>
717
+ </div>
718
+ </div>
719
+ );
720
+ }
721
+
722
+ function CoverageGroupRow({
723
+ group,
724
+ sourceIds,
725
+ onViewSource,
726
+ }: {
727
+ group: EvidenceCoverageGroup;
728
+ sourceIds: string[];
729
+ onViewSource: (
730
+ sourceId: string,
731
+ excerpt?: InvestigationSourceExcerpt,
732
+ ) => void;
733
+ }) {
734
+ const [open, setOpen] = useState(false);
735
+ const regionId = useId();
736
+ const { elementRef, revealAfterToggle } =
737
+ useDisclosureReveal<HTMLDivElement>();
738
+ return (
739
+ <div
740
+ ref={elementRef}
741
+ data-evidence-source-container
742
+ tabIndex={-1}
743
+ aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
744
+ className="outline-none focus:ring-2 focus:ring-accent/40"
745
+ >
746
+ {sourceIds.map((id) => (
747
+ <span
748
+ key={id}
749
+ id={investigationEvidenceSourceDomId(id)}
750
+ className="sr-only scroll-mt-14"
751
+ aria-hidden
752
+ />
753
+ ))}
754
+ <button
755
+ type="button"
756
+ aria-expanded={open}
757
+ aria-controls={regionId}
758
+ onClick={() => {
759
+ setOpen(!open);
760
+ revealAfterToggle(!open);
761
+ }}
762
+ className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs hover:bg-theme-hover/50"
763
+ >
764
+ {group.hasError ? (
765
+ <CircleAlert
766
+ className="h-3.5 w-3.5 shrink-0 text-red-400"
767
+ aria-hidden
768
+ />
769
+ ) : (
770
+ <Info
771
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
772
+ aria-hidden
773
+ />
774
+ )}
775
+ <span className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
776
+ <span className="font-medium text-theme-text-primary">
777
+ {group.label}:{" "}
778
+ </span>
779
+ {group.summary}
780
+ </span>
781
+ <CollapseChevron open={open} className="h-3.5 w-3.5 shrink-0" />
782
+ </button>
783
+ <div id={regionId}>
784
+ <Collapse open={open}>
785
+ <ul className="space-y-2 border-t border-theme-border/60 px-3 py-2.5">
786
+ {group.limitations.map((limitation, index) => (
787
+ <li
788
+ key={index}
789
+ className="flex min-w-0 items-start gap-2 text-xs"
790
+ >
791
+ {limitation.kind === "error" ? (
792
+ <CircleAlert
793
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400"
794
+ aria-hidden
795
+ />
796
+ ) : limitation.kind === "truncated" ? (
797
+ <AlertTriangle
798
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500"
799
+ aria-hidden
800
+ />
801
+ ) : (
802
+ <Info
803
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
804
+ aria-hidden
805
+ />
806
+ )}
807
+ <p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
808
+ <span className="font-medium text-theme-text-primary">
809
+ {limitation.source}:
810
+ </span>{" "}
811
+ {limitation.message}
812
+ </p>
813
+ {limitation.sources.at(-1) ? (
814
+ <SourceButton
815
+ ariaLabel={`View source for ${limitation.source}`}
816
+ buttonLabel={
817
+ limitation.sources.length > 1
818
+ ? "View latest in Activity"
819
+ : undefined
820
+ }
821
+ onClick={() => onViewSource(limitation.sources.at(-1)!.id)}
822
+ />
823
+ ) : null}
824
+ </li>
825
+ ))}
826
+ </ul>
827
+ </Collapse>
828
+ </div>
829
+ </div>
830
+ );
831
+ }
832
+
833
+ function previousDifferentObservations(
834
+ group: InvestigationEvidenceGroup,
835
+ citedOrder?: number,
836
+ ) {
837
+ const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
838
+ return [...group.observations].reverse().filter((observation) => {
839
+ if (
840
+ observation.source.order !== citedOrder &&
841
+ group.observations.some(
842
+ (other) =>
843
+ other.source.order === citedOrder &&
844
+ evidenceDisplaySnapshot(other) ===
845
+ evidenceDisplaySnapshot(observation),
846
+ )
847
+ )
848
+ return false;
849
+ const snapshot = evidenceDisplaySnapshot(observation);
850
+ if (seen.has(snapshot)) return false;
851
+ seen.add(snapshot);
852
+ return true;
853
+ });
854
+ }
855
+
856
+ export function investigationEvidenceShouldRevealHistory(
857
+ group: InvestigationEvidenceGroup,
858
+ sourceId?: string,
859
+ ): boolean {
860
+ return (
861
+ Boolean(sourceId) &&
862
+ group.latest.source.id !== sourceId &&
863
+ group.observations.some(
864
+ (observation) =>
865
+ observation.source.id === sourceId &&
866
+ evidenceDisplaySnapshot(observation) !==
867
+ evidenceDisplaySnapshot(group.latest),
868
+ )
869
+ );
870
+ }
871
+
872
+ function EvidenceCard({
873
+ group,
874
+ domId = group.id,
875
+ animateArrival,
876
+ onViewSource,
877
+ spanFullRow = false,
878
+ prominence = "primary",
879
+ }: {
880
+ group: InvestigationEvidenceGroup;
881
+ /** Stable layout/scroll identity when an existing card changes section. */
882
+ domId?: string;
883
+ animateArrival: boolean;
884
+ onViewSource: (
885
+ sourceId: string,
886
+ excerpt?: InvestigationSourceExcerpt,
887
+ ) => void;
888
+ /** Fill both columns when this card has no compact row partner. */
889
+ spanFullRow?: boolean;
890
+ prominence?: "primary" | "supporting" | "secondary";
891
+ }) {
892
+ const {
893
+ onOpenResource,
894
+ revealSourceId,
895
+ revealRequestId,
896
+ citedOrderByGroup,
897
+ expandedGroupIds,
898
+ onGroupOpenChange,
899
+ } = useContext(EvidenceNavigationContext);
900
+ const open = expandedGroupIds?.has(group.id) ?? false;
901
+ const setOpen = useCallback(
902
+ (value: boolean) => onGroupOpenChange?.(group.id, value),
903
+ [onGroupOpenChange, group.id],
904
+ );
905
+ const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
906
+ const observation = group.latest;
907
+ const displaySummary =
908
+ observation.data.type === "crash" && observation.summary
909
+ ? parseLogLine(observation.summary).content
910
+ : observation.summary;
911
+ const citedOrder = citedOrderByGroup?.get(group.id);
912
+ const previousObservations = previousDifferentObservations(group, citedOrder);
913
+ const meaningfulHistory = previousObservations.length > 0;
914
+ const citedObservation = group.observations.find(
915
+ (item) => item.source.order === citedOrder,
916
+ );
917
+ const differsFromAssessment =
918
+ citedObservation &&
919
+ evidenceDisplaySnapshot(citedObservation) !==
920
+ evidenceDisplaySnapshot(observation);
921
+ const bodyId = `${domId}-body`;
922
+ const hasEvidenceDetails = evidenceHasDetails(
923
+ observation.data,
924
+ observation.summary,
925
+ );
926
+ const canExpand = hasEvidenceDetails || meaningfulHistory;
927
+ const revealHistory = investigationEvidenceShouldRevealHistory(
928
+ group,
929
+ revealSourceId,
930
+ );
931
+ useLayoutEffect(() => {
932
+ const destination = revealSourceId
933
+ ? document.getElementById(
934
+ investigationEvidenceSourceDomId(revealSourceId),
935
+ )
936
+ : null;
937
+ if (
938
+ revealHistory ||
939
+ (destination && elementRef.current?.contains(destination))
940
+ )
941
+ setOpen(true);
942
+ }, [revealHistory, revealSourceId, revealRequestId, elementRef, setOpen]);
943
+ const wide = spanFullRow || evidenceTypePrefersFullRow(observation.data.type);
944
+ const resourceRef = investigationEvidenceSubjectRef(observation.data);
945
+ const resourceIdentity = resourceRef
946
+ ? `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`
947
+ : undefined;
948
+ const primarySources = uniquePrimarySources(group);
949
+ const inlineSecretKeys =
950
+ observation.data.type === "resource" &&
951
+ observation.data.resource.kind.toLowerCase() === "secret" &&
952
+ !canExpand;
953
+ const headerContent = (
954
+ <>
955
+ <EvidenceIcon observation={observation} prominence={prominence} />
956
+ <span className="min-w-0 flex-1">
957
+ <span className="flex flex-wrap items-center gap-1.5">
958
+ <span className="text-sm font-semibold leading-snug text-theme-text-primary">
959
+ {observation.title}
960
+ </span>
961
+ </span>
962
+ {observation.relevance === "broader" &&
963
+ resourceRef &&
964
+ resourceIdentity &&
965
+ !observation.title.includes(resourceIdentity) ? (
966
+ <span className="mt-0.5 block text-xs text-theme-text-secondary">
967
+ {resourceIdentity} · {resourceRef.kind}
968
+ </span>
969
+ ) : null}
970
+ {displaySummary ? (
971
+ <span
972
+ className={clsx(
973
+ "mt-0.5 block text-xs leading-relaxed text-theme-text-secondary",
974
+ !open && !inlineSecretKeys && "line-clamp-2",
975
+ "[overflow-wrap:anywhere]",
976
+ )}
977
+ >
978
+ {displaySummary}
979
+ </span>
980
+ ) : null}
981
+ {group.historical ? (
982
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">
983
+ Previous observation · not confirmed by the latest check
984
+ </span>
985
+ ) : differsFromAssessment &&
986
+ citedOrder != null &&
987
+ observation.source.order !== citedOrder ? (
988
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">
989
+ {observation.source.order > citedOrder
990
+ ? "Observed after the assessment’s source"
991
+ : "Earlier observation retained from a more direct source"}
992
+ </span>
993
+ ) : null}
994
+ </span>
995
+ {canExpand ? (
996
+ <CollapseChevron open={open} className="h-4 w-4 self-center" />
997
+ ) : null}
998
+ </>
999
+ );
1000
+ return (
1001
+ <article
1002
+ data-evidence-source={observation.source.id}
1003
+ ref={elementRef}
1004
+ id={domId}
1005
+ data-evidence-card
1006
+ tabIndex={-1}
1007
+ aria-label={`${observation.title} evidence`}
1008
+ className={clsx(
1009
+ "@container/card scroll-mt-14 overflow-hidden outline-none focus:ring-2 focus:ring-accent/50 data-[source-related]:ring-2 data-[source-related]:ring-accent/35",
1010
+ "rounded-lg border bg-theme-surface",
1011
+ toneBorder(observation.tone, observation.tier, prominence),
1012
+ wide && "@min-[760px]/evidence:col-span-2",
1013
+ animateArrival && "animate-transcript-enter",
1014
+ )}
1015
+ >
1016
+ {primarySources.map((source) => (
1017
+ <span
1018
+ key={source.id}
1019
+ id={investigationEvidenceSourceDomId(source.id)}
1020
+ className="block scroll-mt-14"
1021
+ aria-hidden
1022
+ />
1023
+ ))}
1024
+ <div className="flex min-w-0 items-stretch">
1025
+ {canExpand ? (
1026
+ <button
1027
+ type="button"
1028
+ aria-expanded={open}
1029
+ aria-controls={bodyId}
1030
+ onClick={() => {
1031
+ const opening = !open;
1032
+ setOpen(opening);
1033
+ revealAfterToggle(opening);
1034
+ }}
1035
+ className={clsx(
1036
+ "flex min-w-0 flex-1 items-start text-left hover:bg-theme-hover/60",
1037
+ prominence === "primary"
1038
+ ? "gap-2.5 px-3 py-2.5"
1039
+ : "gap-2 px-2.5 py-2",
1040
+ )}
1041
+ >
1042
+ {headerContent}
1043
+ </button>
1044
+ ) : (
1045
+ <div
1046
+ className={clsx(
1047
+ "flex min-w-0 flex-1 items-start text-left",
1048
+ prominence === "primary"
1049
+ ? "gap-2.5 px-3 py-2.5"
1050
+ : "gap-2 px-2.5 py-2",
1051
+ )}
1052
+ >
1053
+ {headerContent}
1054
+ </div>
1055
+ )}
1056
+ <div className="flex shrink-0 items-center gap-0.5 px-2">
1057
+ {observation.data.type === "changes" ? (
1058
+ <EvidenceCaveat data={observation.data} />
1059
+ ) : null}
1060
+ <SourceButton
1061
+ ariaLabel={`View source for ${observation.title}`}
1062
+ compact
1063
+ onClick={() =>
1064
+ onViewSource(
1065
+ observation.source.id,
1066
+ evidenceSourceExcerpt(observation.data),
1067
+ )
1068
+ }
1069
+ />
1070
+ <OpenResourceButton
1071
+ resourceRef={resourceRef}
1072
+ onOpenResource={onOpenResource}
1073
+ compact
1074
+ />
1075
+ </div>
1076
+ </div>
1077
+ {canExpand ? (
1078
+ <div id={bodyId}>
1079
+ <Collapse open={open}>
1080
+ <div
1081
+ className={clsx(
1082
+ "space-y-3 border-t border-theme-border/60",
1083
+ prominence === "primary" ? "px-3 py-3" : "px-2.5 py-2.5",
1084
+ )}
1085
+ >
1086
+ {hasEvidenceDetails ? (
1087
+ <EvidenceBody
1088
+ data={observation.data}
1089
+ cardSummary={observation.summary}
1090
+ />
1091
+ ) : null}
1092
+ {meaningfulHistory ? (
1093
+ <RevisionHistory
1094
+ observations={previousObservations}
1095
+ citedOrder={citedOrder}
1096
+ reveal={revealHistory}
1097
+ revealRequestId={revealRequestId}
1098
+ onViewSource={onViewSource}
1099
+ />
1100
+ ) : null}
1101
+ {observation.data.type !== "changes" ? (
1102
+ <EvidenceCaveat data={observation.data} />
1103
+ ) : null}
1104
+ </div>
1105
+ </Collapse>
1106
+ </div>
1107
+ ) : null}
1108
+ </article>
1109
+ );
1110
+ }
1111
+
1112
+ function OpenResourceButton({
1113
+ resourceRef,
1114
+ onOpenResource,
1115
+ compact = false,
1116
+ }: {
1117
+ resourceRef?: DiagnosisResourceRef;
1118
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
1119
+ compact?: boolean;
1120
+ }) {
1121
+ if (!resourceRef || !onOpenResource) return null;
1122
+ const identity = `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`;
1123
+ const label = `Open current ${displayKind(resourceRef.kind)} ${identity} in Radar`;
1124
+ return (
1125
+ <Tooltip
1126
+ content={label}
1127
+ delay={350}
1128
+ position="left"
1129
+ wrapperClassName="flex shrink-0"
1130
+ >
1131
+ <button
1132
+ type="button"
1133
+ aria-label={label}
1134
+ onClick={() => onOpenResource(resourceRef)}
1135
+ className={clsx(
1136
+ "flex h-7 shrink-0 items-center justify-center rounded text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
1137
+ "gap-1 px-2 text-xs font-medium",
1138
+ )}
1139
+ >
1140
+ {compact ? null : (
1141
+ <span>Open current {displayKind(resourceRef.kind)}</span>
1142
+ )}
1143
+ <SquareArrowOutUpRight className="h-3.5 w-3.5" aria-hidden />
1144
+ </button>
1145
+ </Tooltip>
1146
+ );
1147
+ }
1148
+
1149
+ function uniquePrimarySources(
1150
+ group: InvestigationEvidenceGroup,
1151
+ ): InvestigationEvidenceSource[] {
1152
+ // One tool call can contribute repeated revisions to the same semantic group.
1153
+ // It still owns one navigation destination, and DOM ids must remain unique.
1154
+ const sources = new Map<string, InvestigationEvidenceSource>();
1155
+ for (const observation of group.observations) {
1156
+ const { source } = observation;
1157
+ if (source.primaryGroupId === group.id && !sources.has(source.id)) {
1158
+ sources.set(source.id, source);
1159
+ }
1160
+ }
1161
+ return [...sources.values()];
1162
+ }
1163
+
1164
+ function EvidenceBody({
1165
+ data,
1166
+ cardSummary,
1167
+ }: {
1168
+ data: InvestigationEvidenceData;
1169
+ cardSummary?: string;
1170
+ }) {
1171
+ switch (data.type) {
1172
+ case "issue":
1173
+ return <IssueBody data={data} cardSummary={cardSummary} />;
1174
+ case "startup":
1175
+ return <StartupBody data={data} />;
1176
+ case "crash":
1177
+ return <CrashBody data={data} />;
1178
+ case "resource":
1179
+ return <ResourceBody data={data} />;
1180
+ case "logs":
1181
+ return <LogsBody data={data} />;
1182
+ case "events":
1183
+ return <EventsBody data={data} />;
1184
+ case "changes":
1185
+ return <ChangesBody data={data} />;
1186
+ case "dns":
1187
+ return <DNSBody data={data} />;
1188
+ case "network":
1189
+ return <NetworkBody data={data} />;
1190
+ case "relationships":
1191
+ return <RelationshipsBody data={data} />;
1192
+ case "topology":
1193
+ return <TopologyBody data={data} />;
1194
+ case "inventory":
1195
+ return <InventoryBody data={data} />;
1196
+ case "receipt":
1197
+ return (
1198
+ <p className="text-xs text-theme-text-secondary">{data.message}</p>
1199
+ );
1200
+ }
1201
+ }
1202
+
1203
+ function evidenceHasDetails(
1204
+ data: InvestigationEvidenceData,
1205
+ cardSummary?: string,
1206
+ ): boolean {
1207
+ switch (data.type) {
1208
+ case "issue": {
1209
+ const summary = cardSummary?.trim();
1210
+ const cause = data.issue.cause?.trim();
1211
+ const message = data.issue.message?.trim();
1212
+ return Boolean(
1213
+ (cause && cause !== summary) ||
1214
+ (message && message !== summary && message !== cause),
1215
+ );
1216
+ }
1217
+ case "startup":
1218
+ case "receipt":
1219
+ return false;
1220
+ case "resource": {
1221
+ const replicas = data.resourceContext?.workloadSummary?.replicas;
1222
+ return Boolean(
1223
+ investigationResourceEvidenceHasDetails(data.resource) ||
1224
+ replicas?.desired !== undefined ||
1225
+ data.resourceContext?.statusSummary?.conditions?.length ||
1226
+ data.gitOpsDiagnosis ||
1227
+ data.warnings.length,
1228
+ );
1229
+ }
1230
+ case "logs":
1231
+ return (data.logs?.lines?.length ?? 0) > 0 || Boolean(data.error);
1232
+ case "changes":
1233
+ return data.changes.length > 0 || Boolean(data.changeContext?.evidence);
1234
+ default:
1235
+ return true;
1236
+ }
1237
+ }
1238
+
1239
+ type EvidenceDataOf<T extends InvestigationEvidenceData["type"]> = Extract<
1240
+ InvestigationEvidenceData,
1241
+ { type: T }
1242
+ >;
1243
+
1244
+ function IssueBody({
1245
+ data,
1246
+ cardSummary,
1247
+ }: {
1248
+ data: EvidenceDataOf<"issue">;
1249
+ cardSummary?: string;
1250
+ }) {
1251
+ const issue = data.issue;
1252
+ const showCause =
1253
+ Boolean(issue.cause) && issue.cause?.trim() !== cardSummary?.trim();
1254
+ const showMessage =
1255
+ Boolean(issue.message) &&
1256
+ issue.message?.trim() !== cardSummary?.trim() &&
1257
+ issue.message?.trim() !== issue.cause?.trim();
1258
+ return (
1259
+ <div className="space-y-2">
1260
+ <div className="flex flex-wrap gap-1.5">
1261
+ <Badge
1262
+ severity={issue.severity === "critical" ? "error" : "warning"}
1263
+ size="sm"
1264
+ >
1265
+ {issue.severity}
1266
+ </Badge>
1267
+ <Badge tone="structural" size="sm">
1268
+ {issue.kind}
1269
+ </Badge>
1270
+ <Badge tone="structural" size="sm">
1271
+ {issue.namespace ? `${issue.namespace}/` : ""}
1272
+ {issue.name}
1273
+ </Badge>
1274
+ </div>
1275
+ {showCause ? (
1276
+ <p className="text-sm font-medium leading-relaxed text-theme-text-primary">
1277
+ {issue.cause}
1278
+ </p>
1279
+ ) : null}
1280
+ {showMessage ? (
1281
+ <p className="text-xs leading-relaxed text-theme-text-secondary">
1282
+ {issue.message}
1283
+ </p>
1284
+ ) : null}
1285
+ </div>
1286
+ );
1287
+ }
1288
+
1289
+ function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
1290
+ const blocker = data.blocker;
1291
+ return (
1292
+ <div className="space-y-2">
1293
+ <div className="flex flex-wrap gap-1.5">
1294
+ <Badge tone="structural" size="sm">
1295
+ {blocker.kind}
1296
+ </Badge>
1297
+ <Badge tone="structural" size="sm">
1298
+ {blocker.name}
1299
+ </Badge>
1300
+ <Badge severity={severityBadge(blocker.severity)} size="sm">
1301
+ {blocker.severity}
1302
+ </Badge>
1303
+ </div>
1304
+ <p className="text-sm leading-relaxed text-theme-text-primary">
1305
+ {blocker.message}
1306
+ </p>
1307
+ </div>
1308
+ );
1309
+ }
1310
+
1311
+ function CrashBody({ data }: { data: EvidenceDataOf<"crash"> }) {
1312
+ const crash = data.crash;
1313
+ return (
1314
+ <div className="space-y-2.5">
1315
+ <div className="flex flex-wrap gap-1.5">
1316
+ <Badge severity="error" size="sm">
1317
+ {crash.reason || crash.state}
1318
+ </Badge>
1319
+ <Badge tone="structural" size="sm">
1320
+ exit {crash.exitCode}
1321
+ </Badge>
1322
+ <Badge tone="structural" size="sm">
1323
+ {crash.container}
1324
+ </Badge>
1325
+ </div>
1326
+ <p className="text-xs text-theme-text-tertiary">
1327
+ {crash.pods.join(", ")} · {crash.logSource.replaceAll("_", " ")}
1328
+ </p>
1329
+ <TerminalBlock label="Selected crash line">{crash.logLine}</TerminalBlock>
1330
+ </div>
1331
+ );
1332
+ }
1333
+
1334
+ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1335
+ const replicas = data.resourceContext?.workloadSummary?.replicas;
1336
+ // SealedSecret's dedicated body renders the resource's conditions beside
1337
+ // its controller state, so repeating the derived summary here adds noise.
1338
+ const conditions =
1339
+ data.resource.kind.toLowerCase() === "sealedsecret"
1340
+ ? []
1341
+ : (data.resourceContext?.statusSummary?.conditions ?? []);
1342
+ const desired = replicas?.desired;
1343
+ const ready = replicas ? (replicas.ready ?? 0) : undefined;
1344
+ const shortfall =
1345
+ desired !== undefined && ready !== undefined && ready < desired;
1346
+ return (
1347
+ <div className="space-y-3">
1348
+ <InvestigationResourceEvidence resource={data.resource} />
1349
+ {data.gitOpsDiagnosis ? (
1350
+ <GitOpsStatusBody status={data.gitOpsDiagnosis} />
1351
+ ) : null}
1352
+ {desired !== undefined && ready !== undefined ? (
1353
+ <dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
1354
+ <div>
1355
+ <dt className="text-theme-text-tertiary">Ready replicas</dt>
1356
+ <dd
1357
+ className={clsx(
1358
+ "font-mono font-semibold tabular-nums",
1359
+ shortfall ? "text-warning-text" : "text-theme-text-primary",
1360
+ )}
1361
+ >
1362
+ {ready}/{desired}
1363
+ </dd>
1364
+ </div>
1365
+ <ResourceFact label="Available" value={replicas?.available} />
1366
+ <ResourceFact label="Updated" value={replicas?.updated} />
1367
+ <ResourceFact label="Unavailable" value={replicas?.unavailable} />
1368
+ <ResourceFact
1369
+ label="Phase"
1370
+ value={data.resourceContext?.statusSummary?.phase}
1371
+ />
1372
+ </dl>
1373
+ ) : null}
1374
+ {conditions.length > 0 ? (
1375
+ <div>
1376
+ <div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1377
+ Conditions
1378
+ </div>
1379
+ <div className="max-h-52 space-y-1.5 overflow-y-auto pr-1">
1380
+ {conditions.map((condition) => (
1381
+ <div
1382
+ key={`${condition.type}-${condition.status}-${condition.reason ?? ""}`}
1383
+ className="flex min-w-0 items-start gap-2 text-xs"
1384
+ >
1385
+ <StatusDot
1386
+ tone={conditionStatusTone(condition)}
1387
+ className="mt-1 shrink-0"
1388
+ />
1389
+ <p className="min-w-0 text-theme-text-secondary">
1390
+ <span className="font-medium text-theme-text-primary">
1391
+ {condition.type}={condition.status}
1392
+ </span>
1393
+ {condition.reason ? ` · ${condition.reason}` : ""}
1394
+ {condition.message ? ` — ${condition.message}` : ""}
1395
+ </p>
1396
+ </div>
1397
+ ))}
1398
+ </div>
1399
+ </div>
1400
+ ) : null}
1401
+ {data.warnings.length > 0 ? (
1402
+ <ul className="space-y-1 text-xs text-theme-text-secondary">
1403
+ {data.warnings.map((warning) => (
1404
+ <li key={warning} className="flex items-start gap-1.5">
1405
+ <Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1406
+ <span>{warning}</span>
1407
+ </li>
1408
+ ))}
1409
+ </ul>
1410
+ ) : null}
1411
+ </div>
1412
+ );
1413
+ }
1414
+
1415
+ function GitOpsStatusBody({
1416
+ status,
1417
+ }: {
1418
+ status: NonNullable<EvidenceDataOf<"resource">["gitOpsDiagnosis"]>;
1419
+ }) {
1420
+ const fields = [
1421
+ ["Sync", status.sync],
1422
+ ["Health", status.health],
1423
+ ["Operation", status.operationPhase],
1424
+ ["Ready", status.ready],
1425
+ ] as const;
1426
+ return (
1427
+ <div className="rounded-md border border-theme-border bg-theme-base/40 p-2.5">
1428
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
1429
+ <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1430
+ GitOps controller status
1431
+ </span>
1432
+ <Badge tone="note" size="sm">
1433
+ {status.tool === "argocd" ? "Argo CD" : "Flux"}
1434
+ </Badge>
1435
+ {status.suspended ? (
1436
+ <Badge severity="info" size="sm">
1437
+ Suspended
1438
+ </Badge>
1439
+ ) : null}
1440
+ </div>
1441
+ <div className="flex flex-wrap gap-1.5">
1442
+ {fields.map(([label, value]) =>
1443
+ value ? (
1444
+ <Badge
1445
+ key={label}
1446
+ severity={gitOpsValueSeverity(label, value)}
1447
+ size="sm"
1448
+ >
1449
+ {label}: {value}
1450
+ </Badge>
1451
+ ) : null,
1452
+ )}
1453
+ {status.appliedRevision ? (
1454
+ <Badge tone="structural" size="sm">
1455
+ {status.appliedRevision}
1456
+ </Badge>
1457
+ ) : null}
1458
+ </div>
1459
+ </div>
1460
+ );
1461
+ }
1462
+
1463
+ function gitOpsValueSeverity(label: string, value: string) {
1464
+ const normalized = value.toLowerCase();
1465
+ if (
1466
+ normalized === "healthy" ||
1467
+ normalized === "synced" ||
1468
+ normalized === "succeeded" ||
1469
+ (label === "Ready" && normalized.startsWith("true"))
1470
+ ) {
1471
+ return "success" as const;
1472
+ }
1473
+ if (
1474
+ normalized === "degraded" ||
1475
+ normalized === "missing" ||
1476
+ normalized === "failed" ||
1477
+ normalized === "error" ||
1478
+ (label === "Ready" && normalized.startsWith("false"))
1479
+ ) {
1480
+ return "error" as const;
1481
+ }
1482
+ if (normalized === "outofsync") return "warning" as const;
1483
+ if (normalized === "progressing" || normalized === "running")
1484
+ return "info" as const;
1485
+ return "neutral" as const;
1486
+ }
1487
+
1488
+ function ResourceFact({ label, value }: { label: string; value: unknown }) {
1489
+ if (value === undefined || value === null || value === "") return null;
1490
+ return (
1491
+ <div className="flex justify-between gap-2 @min-[560px]/evidence:block">
1492
+ <dt className="text-theme-text-tertiary">{label}</dt>
1493
+ <dd className="font-mono font-medium text-theme-text-primary">
1494
+ {String(value)}
1495
+ </dd>
1496
+ </div>
1497
+ );
1498
+ }
1499
+
1500
+ function conditionStatusTone(condition: {
1501
+ type: string;
1502
+ status: string;
1503
+ }): "healthy" | "degraded" | "unhealthy" | "unknown" {
1504
+ switch (defaultConditionTone(condition)) {
1505
+ case "ok":
1506
+ return "healthy";
1507
+ case "warning":
1508
+ return "degraded";
1509
+ case "fail":
1510
+ return "unhealthy";
1511
+ case "unknown":
1512
+ return "unknown";
1513
+ }
1514
+ }
1515
+
1516
+ function LogsBody({ data }: { data: EvidenceDataOf<"logs"> }) {
1517
+ const lines = data.logs?.lines ?? [];
1518
+ const visibleLines = lines
1519
+ .slice(-VISIBLE_LOG_EVIDENCE_LINES)
1520
+ .map((line) => stripAnsi(line));
1521
+ const omittedLines = lines.length - visibleLines.length;
1522
+ return (
1523
+ <div className="space-y-2">
1524
+ <div className="flex flex-wrap items-center gap-1.5">
1525
+ <Badge tone="structural" size="sm">
1526
+ {data.pod} / {data.container}
1527
+ </Badge>
1528
+ <Badge severity="neutral" size="sm">
1529
+ {data.previous ? "previous instance" : "current instance"}
1530
+ </Badge>
1531
+ {data.logs?.fallback ? (
1532
+ <Badge severity="warning" size="sm">
1533
+ Unfiltered log tail
1534
+ </Badge>
1535
+ ) : null}
1536
+ {data.logs ? (
1537
+ <span className="text-xs text-theme-text-tertiary">
1538
+ {data.logs.matchedLines} matching lines · {data.logs.totalLines}{" "}
1539
+ processed from the requested log tail
1540
+ </span>
1541
+ ) : null}
1542
+ </div>
1543
+ {visibleLines.length > 0 ? (
1544
+ <TerminalBlock
1545
+ label={
1546
+ omittedLines > 0
1547
+ ? `Selected log excerpt · last ${visibleLines.length} of ${lines.length} lines`
1548
+ : "Selected log excerpt"
1549
+ }
1550
+ >
1551
+ {visibleLines.join("\n")}
1552
+ </TerminalBlock>
1553
+ ) : (
1554
+ <p className="text-xs italic text-theme-text-tertiary">
1555
+ No lines were captured from this stream.
1556
+ </p>
1557
+ )}
1558
+ {data.error ? (
1559
+ <p className="text-xs leading-relaxed text-red-400">{data.error}</p>
1560
+ ) : null}
1561
+ {data.warnings.map((warning) => (
1562
+ <p key={warning} className="text-xs text-warning-text">
1563
+ {warning}
1564
+ </p>
1565
+ ))}
1566
+ </div>
1567
+ );
1568
+ }
1569
+
1570
+ function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
1571
+ return (
1572
+ <div>
1573
+ <p className="mb-2 text-xs text-theme-text-tertiary">{data.scope}</p>
1574
+ <ol className="max-h-[28rem] space-y-0 overflow-y-auto pr-1">
1575
+ {data.events.map((event, index) => (
1576
+ <li
1577
+ key={`${event.reason}-${event.lastTimestamp}-${index}`}
1578
+ className="relative flex gap-3 pb-3 last:pb-0"
1579
+ >
1580
+ {index < data.events.length - 1 ? (
1581
+ <span className="absolute bottom-0 left-[5px] top-3 w-px bg-theme-border" />
1582
+ ) : null}
1583
+ <span className="relative mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full border-2 border-amber-500 bg-theme-surface" />
1584
+ <div className="min-w-0 flex-1">
1585
+ <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
1586
+ <span className="text-xs font-semibold text-theme-text-primary">
1587
+ {event.reason}
1588
+ {event.count > 1 ? ` ×${event.count}` : ""}
1589
+ </span>
1590
+ <Tooltip
1591
+ content={new Date(event.lastTimestamp).toLocaleString()}
1592
+ delay={150}
1593
+ position="left"
1594
+ >
1595
+ <time
1596
+ dateTime={event.lastTimestamp}
1597
+ className="text-xs text-theme-text-tertiary"
1598
+ >
1599
+ {formatRelativeAgeTime(event.lastTimestamp)}
1600
+ </time>
1601
+ </Tooltip>
1602
+ </div>
1603
+ <p className="mt-0.5 text-xs leading-relaxed text-theme-text-secondary">
1604
+ {event.message}
1605
+ </p>
1606
+ </div>
1607
+ </li>
1608
+ ))}
1609
+ </ol>
1610
+ </div>
1611
+ );
1612
+ }
1613
+
1614
+ function ChangesBody({ data }: { data: EvidenceDataOf<"changes"> }) {
1615
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
1616
+ return (
1617
+ <div className="space-y-2.5">
1618
+ {data.changeContext?.evidence ? (
1619
+ <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
1620
+ {data.changeContext.evidence}
1621
+ </p>
1622
+ ) : null}
1623
+ {data.changes.map((change, index) => (
1624
+ <div
1625
+ key={`${change.kind}-${change.namespace ?? ""}-${change.name}-${change.timestamp}-${index}`}
1626
+ className="rounded-md border border-theme-border/70 bg-theme-base/30 p-2.5"
1627
+ >
1628
+ <div className="flex flex-wrap items-center gap-1.5">
1629
+ <Badge tone="structural" size="sm">
1630
+ {change.kind}
1631
+ </Badge>
1632
+ <span className="font-mono text-xs">
1633
+ <ResourceLink
1634
+ name={change.name}
1635
+ kind={change.kind}
1636
+ namespace={change.namespace ?? ""}
1637
+ group={
1638
+ change.apiVersion
1639
+ ? apiVersionToGroup(change.apiVersion)
1640
+ : undefined
1641
+ }
1642
+ label={`${change.namespace ? `${change.namespace}/` : ""}${change.name}`}
1643
+ onNavigate={
1644
+ change.apiVersion && onOpenResource
1645
+ ? (ref) => onOpenResource(ref)
1646
+ : undefined
1647
+ }
1648
+ />
1649
+ </span>
1650
+ <Badge tone="note" size="sm">
1651
+ {change.changeType.replaceAll("_", " ")}
1652
+ </Badge>
1653
+ <Tooltip
1654
+ content={new Date(change.timestamp).toLocaleString()}
1655
+ delay={150}
1656
+ position="left"
1657
+ wrapperClassName="ml-auto"
1658
+ >
1659
+ <time
1660
+ dateTime={change.timestamp}
1661
+ className="text-xs text-theme-text-tertiary"
1662
+ >
1663
+ {formatRelativeAgeTime(change.timestamp)}
1664
+ </time>
1665
+ </Tooltip>
1666
+ </div>
1667
+ {change.summary ? (
1668
+ <p className="mt-1.5 text-xs text-theme-text-secondary">
1669
+ {change.summary}
1670
+ </p>
1671
+ ) : null}
1672
+ {change.fields?.length ? (
1673
+ <div className="mt-2">
1674
+ <DiffViewer
1675
+ diff={{
1676
+ summary: `${change.fields.length} changed ${change.fields.length === 1 ? "field" : "fields"}`,
1677
+ fields: change.fields.map((field) => ({
1678
+ path: field.path,
1679
+ oldValue: field.oldValue ?? null,
1680
+ newValue: field.newValue ?? null,
1681
+ })),
1682
+ }}
1683
+ />
1684
+ </div>
1685
+ ) : null}
1686
+ </div>
1687
+ ))}
1688
+ </div>
1689
+ );
1690
+ }
1691
+
1692
+ function DNSBody({ data }: { data: EvidenceDataOf<"dns"> }) {
1693
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
1694
+ return (
1695
+ <div className="space-y-2">
1696
+ {(data.dns.signals ?? []).map((signal) => (
1697
+ <p
1698
+ key={signal}
1699
+ className="text-xs leading-relaxed text-theme-text-secondary"
1700
+ >
1701
+ {signal}
1702
+ </p>
1703
+ ))}
1704
+ {(data.dns.coreDNSFindings ?? []).map((finding) => (
1705
+ <div
1706
+ key={`${finding.kind}-${finding.namespace}-${finding.name}-${finding.reason}`}
1707
+ className="rounded-md border border-theme-border bg-theme-base/40 p-2"
1708
+ >
1709
+ <div className="flex flex-wrap gap-1.5">
1710
+ <Badge tone="structural" size="sm">
1711
+ {finding.kind}
1712
+ </Badge>
1713
+ <span className="font-mono text-xs">
1714
+ <ResourceLink
1715
+ name={finding.name}
1716
+ kind={finding.kind}
1717
+ namespace={finding.namespace}
1718
+ group=""
1719
+ label={`${finding.namespace}/${finding.name}`}
1720
+ onNavigate={
1721
+ finding.kind.toLowerCase() === "configmap" && onOpenResource
1722
+ ? (ref) => onOpenResource(ref)
1723
+ : undefined
1724
+ }
1725
+ />
1726
+ </span>
1727
+ <Badge severity={severityBadge(finding.severity)} size="sm">
1728
+ {finding.severity}
1729
+ </Badge>
1730
+ </div>
1731
+ <p className="mt-1 text-xs text-theme-text-secondary">
1732
+ {finding.reason}
1733
+ {finding.message ? ` — ${finding.message}` : ""}
1734
+ </p>
1735
+ </div>
1736
+ ))}
1737
+ </div>
1738
+ );
1739
+ }
1740
+
1741
+ function NetworkBody({ data }: { data: EvidenceDataOf<"network"> }) {
1742
+ const { network } = data;
1743
+ const stats = [
1744
+ ["Tested", network.summary.tested],
1745
+ ["Passed", network.summary.passed],
1746
+ ["Failed", network.summary.failed],
1747
+ ["Inferred", network.summary.derived ?? 0],
1748
+ ["Skipped", network.summary.skipped],
1749
+ ] as const;
1750
+ return (
1751
+ <div className="space-y-3">
1752
+ <div className="grid grid-cols-3 gap-1.5 @min-[620px]/evidence:grid-cols-5">
1753
+ {stats.map(([label, value]) => (
1754
+ <div
1755
+ key={label}
1756
+ className="rounded-md border border-theme-border bg-theme-base/40 px-2 py-1.5 text-center"
1757
+ >
1758
+ <div className="font-mono text-sm font-semibold text-theme-text-primary">
1759
+ {value}
1760
+ </div>
1761
+ <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
1762
+ {label}
1763
+ </div>
1764
+ </div>
1765
+ ))}
1766
+ </div>
1767
+ {network.diagnosis ? (
1768
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
1769
+ <p className="text-xs font-medium leading-relaxed text-theme-text-primary">
1770
+ {network.diagnosis.summary}
1771
+ </p>
1772
+ {network.diagnosis.nextAction ? (
1773
+ <p className="mt-1 border-l-2 border-accent/50 pl-2 text-xs leading-relaxed text-theme-text-secondary">
1774
+ Next check: {network.diagnosis.nextAction}
1775
+ </p>
1776
+ ) : null}
1777
+ </div>
1778
+ ) : (
1779
+ <p className="text-xs leading-relaxed text-theme-text-secondary">
1780
+ {network.summary.headline}
1781
+ </p>
1782
+ )}
1783
+ {network.routes.length > 0 ? (
1784
+ <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
1785
+ {network.routes.map((route, index) => (
1786
+ <li
1787
+ key={`${route.route}-${route.target ?? ""}-${index}`}
1788
+ className="flex min-w-0 items-start gap-2 rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
1789
+ >
1790
+ <StatusDot
1791
+ tone={networkOutcomeTone(route.outcome, route.benign)}
1792
+ className="mt-1 shrink-0"
1793
+ />
1794
+ <span className="min-w-0 flex-1">
1795
+ <span className="block truncate font-mono text-xs text-theme-text-primary">
1796
+ {route.route}
1797
+ {route.target ? ` → ${route.target}` : ""}
1798
+ </span>
1799
+ {route.evidence ? (
1800
+ <span className="mt-0.5 block text-xs leading-relaxed text-theme-text-secondary">
1801
+ {route.evidence}
1802
+ </span>
1803
+ ) : null}
1804
+ </span>
1805
+ <Badge
1806
+ severity={networkOutcomeSeverity(route.outcome, route.benign)}
1807
+ size="sm"
1808
+ >
1809
+ {route.benign
1810
+ ? "intentional"
1811
+ : route.outcome.replaceAll("_", " ")}
1812
+ </Badge>
1813
+ </li>
1814
+ ))}
1815
+ </ol>
1816
+ ) : null}
1817
+ </div>
1818
+ );
1819
+ }
1820
+
1821
+ function networkOutcomeTone(
1822
+ outcome: string,
1823
+ benign?: boolean,
1824
+ ): "healthy" | "degraded" | "unhealthy" | "unknown" {
1825
+ if (benign) return "degraded";
1826
+ const normalized = outcome.toLowerCase();
1827
+ if (normalized.includes("verified") || normalized.includes("reached"))
1828
+ return "healthy";
1829
+ if (normalized.includes("fail") || normalized.includes("unreachable"))
1830
+ return "unhealthy";
1831
+ if (normalized.includes("skip") || normalized.includes("not"))
1832
+ return "unknown";
1833
+ return "degraded";
1834
+ }
1835
+
1836
+ function networkOutcomeSeverity(outcome: string, benign?: boolean) {
1837
+ switch (networkOutcomeTone(outcome, benign)) {
1838
+ case "healthy":
1839
+ return "success" as const;
1840
+ case "degraded":
1841
+ return "warning" as const;
1842
+ case "unhealthy":
1843
+ return "error" as const;
1844
+ case "unknown":
1845
+ return "neutral" as const;
1846
+ }
1847
+ }
1848
+
1849
+ function RelationshipsBody({
1850
+ data,
1851
+ }: {
1852
+ data: EvidenceDataOf<"relationships">;
1853
+ }) {
1854
+ return (
1855
+ <div className="space-y-2.5">
1856
+ <div className="flex flex-wrap items-center gap-1.5">
1857
+ <Badge tone="structural" size="sm">
1858
+ {data.root.kind}
1859
+ </Badge>
1860
+ <span className="font-mono text-xs text-theme-text-secondary">
1861
+ {data.root.namespace ? `${data.root.namespace}/` : ""}
1862
+ {data.root.name}
1863
+ </span>
1864
+ <span className="text-xs text-theme-text-tertiary">
1865
+ {data.nodes.length} resources · {data.edges.length} direct
1866
+ relationships
1867
+ </span>
1868
+ </div>
1869
+ <div className="max-h-44 overflow-y-auto pr-1">
1870
+ <div className="flex flex-wrap gap-1.5">
1871
+ {data.nodes.map((node) => (
1872
+ <span
1873
+ key={node.id}
1874
+ className="inline-flex items-center gap-1 rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs"
1875
+ >
1876
+ <Badge tone="structural" size="sm">
1877
+ {node.kind}
1878
+ </Badge>
1879
+ <span className="font-mono text-theme-text-secondary">
1880
+ {node.name}
1881
+ </span>
1882
+ </span>
1883
+ ))}
1884
+ </div>
1885
+ </div>
1886
+ {data.edges.length > 0 ? (
1887
+ <div className="grid max-h-52 gap-1 overflow-y-auto pr-1 @min-[680px]/evidence:grid-cols-2">
1888
+ {data.edges.map((edge, index) => (
1889
+ <div
1890
+ key={
1891
+ edge.id ?? `${edge.source}-${edge.target}-${edge.type}-${index}`
1892
+ }
1893
+ className="flex min-w-0 items-center gap-1.5 rounded bg-theme-base/50 px-2 py-1.5 font-mono text-xs text-theme-text-secondary"
1894
+ >
1895
+ <span className="truncate">{edge.source}</span>
1896
+ <span className="shrink-0 text-theme-text-tertiary">→</span>
1897
+ <span className="truncate">{edge.target}</span>
1898
+ <Badge tone="structural" size="sm">
1899
+ {edge.label || edge.type}
1900
+ </Badge>
1901
+ </div>
1902
+ ))}
1903
+ </div>
1904
+ ) : null}
1905
+ </div>
1906
+ );
1907
+ }
1908
+
1909
+ function TopologyBody({ data }: { data: EvidenceDataOf<"topology"> }) {
1910
+ return (
1911
+ <div className="space-y-2.5">
1912
+ <div className="grid grid-cols-2 gap-2">
1913
+ <TopologyStat label="Nodes" value={data.stats.nodes} />
1914
+ <TopologyStat label="Relationships" value={data.stats.edges} />
1915
+ </div>
1916
+ {data.problems.map((problem) => (
1917
+ <p
1918
+ key={problem}
1919
+ className="flex items-start gap-1.5 text-xs text-warning-text"
1920
+ >
1921
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
1922
+ {problem}
1923
+ </p>
1924
+ ))}
1925
+ <div className="max-h-64 space-y-2 overflow-y-auto pr-1">
1926
+ {data.namespaces.map((namespace) => (
1927
+ <div key={namespace.namespace}>
1928
+ <div className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1929
+ {namespace.namespace || "cluster-scoped"}
1930
+ </div>
1931
+ <ul className="mt-1 space-y-1 font-mono text-xs text-theme-text-secondary">
1932
+ {namespace.chains.map((chain) => (
1933
+ <li key={chain}>{chain}</li>
1934
+ ))}
1935
+ </ul>
1936
+ </div>
1937
+ ))}
1938
+ </div>
1939
+ {data.warnings.map((warning) => (
1940
+ <p key={warning} className="text-xs text-warning-text">
1941
+ {warning}
1942
+ </p>
1943
+ ))}
1944
+ </div>
1945
+ );
1946
+ }
1947
+
1948
+ function TopologyStat({ label, value }: { label: string; value: number }) {
1949
+ return (
1950
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-3 py-2">
1951
+ <div className="font-mono text-lg font-semibold text-theme-text-primary">
1952
+ {value}
1953
+ </div>
1954
+ <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
1955
+ {label}
1956
+ </div>
1957
+ </div>
1958
+ );
1959
+ }
1960
+
1961
+ function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
1962
+ return (
1963
+ <div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
1964
+ {data.resources.map((resource, index) => (
1965
+ <div
1966
+ key={`${resource.kind}-${resource.namespace ?? ""}-${resource.name}`}
1967
+ className={clsx(
1968
+ "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
1969
+ index > 0 && "border-t border-theme-border/60",
1970
+ )}
1971
+ >
1972
+ <StatusDot
1973
+ tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
1974
+ className="shrink-0"
1975
+ />
1976
+ <Badge tone="structural" size="sm">
1977
+ {resource.kind}
1978
+ </Badge>
1979
+ <span className="min-w-0 flex-1">
1980
+ <span className="block truncate font-mono text-xs text-theme-text-secondary">
1981
+ {resource.namespace ? `${resource.namespace}/` : ""}
1982
+ {resource.name}
1983
+ </span>
1984
+ {resource.issue ? (
1985
+ <span className="block truncate text-xs text-warning-text">
1986
+ {resource.issue}
1987
+ </span>
1988
+ ) : null}
1989
+ </span>
1990
+ {resource.ready || resource.status ? (
1991
+ <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
1992
+ {resource.ready || resource.status}
1993
+ </span>
1994
+ ) : null}
1995
+ {(resource.summaryContext?.issueCount ?? 0) > 0 ? (
1996
+ <Badge severity="warning" size="sm">
1997
+ {resource.summaryContext?.issueCount} issues
1998
+ </Badge>
1999
+ ) : null}
2000
+ </div>
2001
+ ))}
2002
+ </div>
2003
+ );
2004
+ }
2005
+
2006
+ function RevisionHistory({
2007
+ observations,
2008
+ citedOrder,
2009
+ reveal,
2010
+ revealRequestId,
2011
+ onViewSource,
2012
+ }: {
2013
+ observations: InvestigationEvidenceObservation[];
2014
+ citedOrder?: number;
2015
+ reveal: boolean;
2016
+ revealRequestId?: number;
2017
+ onViewSource: (
2018
+ sourceId: string,
2019
+ excerpt?: InvestigationSourceExcerpt,
2020
+ ) => void;
2021
+ }) {
2022
+ const [open, setOpen] = useState(false);
2023
+ const regionId = useId();
2024
+ const { elementRef, revealAfterToggle } =
2025
+ useDisclosureReveal<HTMLDivElement>();
2026
+ useLayoutEffect(() => {
2027
+ if (reveal) setOpen(true);
2028
+ }, [reveal, revealRequestId]);
2029
+ return (
2030
+ <div>
2031
+ <button
2032
+ type="button"
2033
+ aria-expanded={open}
2034
+ aria-controls={regionId}
2035
+ onClick={() => {
2036
+ setOpen(!open);
2037
+ revealAfterToggle(!open);
2038
+ }}
2039
+ className="flex items-center gap-1.5 rounded-md px-1 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary"
2040
+ >
2041
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
2042
+ Previous observations · {observations.length}
2043
+ </button>
2044
+ <div id={regionId} ref={elementRef}>
2045
+ <Collapse open={open}>
2046
+ <ol className="space-y-3 pt-2">
2047
+ {observations.map((observation) => (
2048
+ <li
2049
+ key={`${observation.source.id}-${observation.revision}`}
2050
+ className="flex min-w-0 items-start gap-2 text-xs"
2051
+ >
2052
+ <span className="mt-0.5 shrink-0 text-theme-text-tertiary">
2053
+ <span className="font-medium text-theme-text-secondary">
2054
+ {observation.source.order === citedOrder
2055
+ ? "Used for assessment"
2056
+ : phaseLabel(observation.source.phase)}
2057
+ </span>
2058
+ </span>
2059
+ <div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
2060
+ <p>{observation.summary || observation.title}</p>
2061
+ {evidenceHasDetails(
2062
+ observation.data,
2063
+ observation.summary,
2064
+ ) && (
2065
+ <EvidenceBody
2066
+ data={observation.data}
2067
+ cardSummary={observation.summary}
2068
+ />
2069
+ )}
2070
+ </div>
2071
+ <SourceButton
2072
+ ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
2073
+ onClick={() =>
2074
+ onViewSource(
2075
+ observation.source.id,
2076
+ evidenceSourceExcerpt(observation.data),
2077
+ )
2078
+ }
2079
+ />
2080
+ </li>
2081
+ ))}
2082
+ </ol>
2083
+ </Collapse>
2084
+ </div>
2085
+ </div>
2086
+ );
2087
+ }
2088
+
2089
+ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
2090
+ let text: string | undefined;
2091
+ if (data.type === "events") {
2092
+ text =
2093
+ "Events support the timeline; proximity alone does not establish cause.";
2094
+ } else if (data.type === "changes") {
2095
+ return (
2096
+ <Tooltip
2097
+ content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
2098
+ position="left"
2099
+ className="pointer-events-none"
2100
+ wrapperClassName="flex shrink-0"
2101
+ >
2102
+ <button
2103
+ type="button"
2104
+ aria-label="About change evidence"
2105
+ className="flex h-7 w-7 items-center justify-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
2106
+ >
2107
+ <Info className="h-3.5 w-3.5" aria-hidden />
2108
+ </button>
2109
+ </Tooltip>
2110
+ );
2111
+ } else if (data.type === "relationships" || data.type === "topology") {
2112
+ text =
2113
+ "This shows direct relationships Radar found, not an inferred blast radius.";
2114
+ }
2115
+ if (!text) return null;
2116
+ return (
2117
+ <p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
2118
+ <Info className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
2119
+ {text}
2120
+ </p>
2121
+ );
2122
+ }
2123
+
2124
+ function EvidenceIcon({
2125
+ observation,
2126
+ prominence,
2127
+ }: {
2128
+ observation: InvestigationEvidenceObservation;
2129
+ prominence: "primary" | "supporting" | "secondary";
2130
+ }) {
2131
+ const Icon = evidenceIcon(observation.data.type);
2132
+ return (
2133
+ <span
2134
+ className={clsx(
2135
+ "flex shrink-0 items-center justify-center text-theme-text-tertiary",
2136
+ prominence === "primary" ? "h-7 w-7" : "h-6 w-6",
2137
+ )}
2138
+ >
2139
+ <Icon
2140
+ className={prominence === "primary" ? "h-4 w-4" : "h-3.5 w-3.5"}
2141
+ aria-hidden
2142
+ />
2143
+ </span>
2144
+ );
2145
+ }
2146
+
2147
+ function SourceButton({
2148
+ ariaLabel,
2149
+ buttonLabel = "View source",
2150
+ compact = false,
2151
+ onClick,
2152
+ }: {
2153
+ ariaLabel?: string;
2154
+ buttonLabel?: string;
2155
+ compact?: boolean;
2156
+ onClick: () => void;
2157
+ }) {
2158
+ const tooltip = "Open the original tool result";
2159
+ return (
2160
+ <Tooltip
2161
+ content={tooltip}
2162
+ delay={350}
2163
+ position="left"
2164
+ wrapperClassName="flex shrink-0"
2165
+ >
2166
+ <button
2167
+ type="button"
2168
+ aria-label={ariaLabel ?? tooltip}
2169
+ onClick={onClick}
2170
+ className={clsx(
2171
+ "flex h-7 shrink-0 items-center justify-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
2172
+ "gap-1 px-2 text-xs font-medium",
2173
+ )}
2174
+ >
2175
+ <FileSearch className="h-3.5 w-3.5" aria-hidden />
2176
+ <span
2177
+ className={compact ? "hidden @min-[540px]/card:inline" : undefined}
2178
+ >
2179
+ {buttonLabel}
2180
+ </span>
2181
+ </button>
2182
+ </Tooltip>
2183
+ );
2184
+ }
2185
+
2186
+ function evidenceIcon(type: InvestigationEvidenceData["type"]) {
2187
+ switch (type) {
2188
+ case "issue":
2189
+ return CircleAlert;
2190
+ case "startup":
2191
+ return ShieldAlert;
2192
+ case "crash":
2193
+ return Bug;
2194
+ case "resource":
2195
+ return Boxes;
2196
+ case "logs":
2197
+ return ScrollText;
2198
+ case "events":
2199
+ return Clock3;
2200
+ case "changes":
2201
+ return FileClock;
2202
+ case "dns":
2203
+ return Activity;
2204
+ case "network":
2205
+ return Network;
2206
+ case "relationships":
2207
+ case "topology":
2208
+ return Network;
2209
+ case "inventory":
2210
+ return ListTree;
2211
+ case "receipt":
2212
+ return CheckCircle2;
2213
+ }
2214
+ }
2215
+
2216
+ function severityBadge(value: string) {
2217
+ const tone = value.toLowerCase();
2218
+ if (tone === "error" || tone === "critical" || tone === "failed")
2219
+ return "error" as const;
2220
+ if (tone === "alert" || tone === "high") return "alert" as const;
2221
+ if (tone === "warning" || tone === "medium") return "warning" as const;
2222
+ if (tone === "info" || tone === "low") return "info" as const;
2223
+ return "neutral" as const;
2224
+ }
2225
+
2226
+ function phaseLabel(
2227
+ phase: InvestigationEvidenceObservation["source"]["phase"],
2228
+ ): string {
2229
+ switch (phase) {
2230
+ case "initial":
2231
+ return "Initial";
2232
+ case "followup":
2233
+ return "Follow-up";
2234
+ case "verification":
2235
+ return "Verification";
2236
+ case "apply":
2237
+ return "Apply";
2238
+ }
2239
+ }
2240
+
2241
+ function toneBorder(
2242
+ tone: InvestigationEvidenceObservation["tone"],
2243
+ tier: InvestigationEvidenceTier,
2244
+ prominence: "primary" | "supporting" | "secondary",
2245
+ ): string {
2246
+ if (prominence !== "primary") return "border-theme-border/70";
2247
+ if (tier !== "key") return "border-theme-border";
2248
+ if (tone === "error")
2249
+ return "border-l-[3px] border-l-red-500 border-theme-border";
2250
+ if (tone === "alert")
2251
+ return "border-l-[3px] border-l-orange-500 border-theme-border";
2252
+ return "border-l-[3px] border-l-amber-500 border-theme-border";
2253
+ }