@skyhook-io/radar-app 1.13.3 → 1.13.5

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 (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -2,6 +2,7 @@ import {
2
2
  createContext,
3
3
  useCallback,
4
4
  useContext,
5
+ useEffect,
5
6
  useId,
6
7
  useLayoutEffect,
7
8
  useRef,
@@ -9,22 +10,18 @@ import {
9
10
  type ReactNode,
10
11
  } from "react";
11
12
  import { clsx } from "clsx";
13
+ import {
14
+ EVIDENCE_KIND_TRAITS,
15
+ evidenceKindIsFocused,
16
+ } from "./investigationEvidenceKinds";
12
17
  import {
13
18
  Activity,
14
19
  AlertTriangle,
15
- Boxes,
16
- Bug,
17
- CheckCircle2,
18
20
  CircleAlert,
19
- Clock3,
20
21
  FileClock,
21
22
  FileSearch,
22
23
  Info,
23
- ListTree,
24
- Network,
25
- ScrollText,
26
24
  SearchCheck,
27
- ShieldAlert,
28
25
  SquareArrowOutUpRight,
29
26
  } from "lucide-react";
30
27
  import {
@@ -37,12 +34,27 @@ import {
37
34
  defaultConditionTone,
38
35
  displayKind,
39
36
  formatRelativeAgeTime,
37
+ HPADiagnosisSummary,
40
38
  mapHealthToTone,
41
39
  ResourceLink,
42
40
  stripAnsi,
43
41
  } from "@skyhook-io/k8s-ui";
42
+ import {
43
+ AreaChart,
44
+ SERIES_COLORS,
45
+ SeriesLegend,
46
+ formatMetricValue,
47
+ seriesDisplayLabels,
48
+ seriesFill,
49
+ type TimeSeries,
50
+ } from "@skyhook-io/k8s-ui/components/charts";
44
51
  import { apiVersionToGroup } from "../../utils/navigation";
45
52
  import { parseLogLine } from "../../utils/log-format";
53
+ import {
54
+ metricsChangeCoverage,
55
+ metricsDomain,
56
+ type MetricsChangeCoverage,
57
+ } from "./investigationMetrics";
46
58
  import {
47
59
  evidenceDisplaySnapshot,
48
60
  groupEvidenceCoverage,
@@ -52,6 +64,7 @@ import {
52
64
  import {
53
65
  investigationEvidenceSubjectRef,
54
66
  investigationEvidenceSourceDomId,
67
+ investigationSourceArgs,
55
68
  type InvestigationEvidenceData,
56
69
  type InvestigationEvidenceGroup,
57
70
  type InvestigationEvidenceObservation,
@@ -60,12 +73,23 @@ import {
60
73
  type InvestigationEvidenceSource,
61
74
  type InvestigationEvidenceTier,
62
75
  } from "./investigationEvidence";
63
- import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
76
+ import type {
77
+ DiagnosisResourceContext,
78
+ DiagnosisResourceRef,
79
+ DiagnosisScalerRef,
80
+ } from "./diagnoseEvidenceTypes";
64
81
  import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
65
82
  import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
66
83
  import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
67
84
  import { evidenceSourceExcerpt } from "./investigationSourceFocus";
68
85
  import { Tooltip } from "../ui/Tooltip";
86
+ import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
87
+ import {
88
+ investigationCaseObservationKey,
89
+ type InvestigationCaseItem,
90
+ type InvestigationCaseResolution,
91
+ } from "./investigationCase";
92
+ import type { DiagnosisEvidenceRole } from "../../api/diagnose";
69
93
 
70
94
  import {
71
95
  investigationDisclosureSettleDelay,
@@ -79,19 +103,71 @@ export {
79
103
  } from "./useDisclosureReveal";
80
104
  export const VISIBLE_LOG_EVIDENCE_LINES = 12;
81
105
 
106
+ /** The Timeline scope a changes card can open: one resource name in one namespace. */
107
+ export interface InvestigationTimelineScope {
108
+ namespace?: string;
109
+ name: string;
110
+ }
111
+
82
112
  const EvidenceNavigationContext = createContext<{
83
113
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
114
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
84
115
  revealSourceId?: string;
85
116
  revealRequestId?: number;
86
117
  expandedGroupIds?: ReadonlySet<string>;
87
118
  onGroupOpenChange?: (id: string, open: boolean) => void;
88
119
  citedOrderByGroup?: ReadonlyMap<string, number>;
120
+ /** Change markers for each metrics observation, keyed by its source id. */
121
+ metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
122
+ /** Card- and revision-placed agent items, keyed by group id. */
123
+ caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
124
+ /** The hypothesis each `rules_out` item excludes, keyed by item. */
125
+ excludedByItem?: ReadonlyMap<string, string>;
89
126
  }>({});
90
127
 
128
+ /** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
129
+ export function investigationCaseItemKey(item: InvestigationCaseItem): string {
130
+ return `${item.source.id}\u0000${item.index}`;
131
+ }
132
+
133
+ const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
134
+ cause: 0,
135
+ symptom: 1,
136
+ // A ruled-out card keeps Radar's place in the list; the role only labels it.
137
+ rules_out: 2,
138
+ context: 3,
139
+ benign: 4,
140
+ demoted: 5,
141
+ };
142
+ const UNLABELLED_RANK = 2;
143
+
144
+ function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
145
+ const args = investigationSourceArgs(source);
146
+ return (
147
+ typeof args?.kind === "string" &&
148
+ args.kind !== "" &&
149
+ typeof args.name === "string" &&
150
+ args.name !== ""
151
+ );
152
+ }
153
+
154
+ export function investigationCaseByGroup(
155
+ investigationCase?: InvestigationCaseResolution,
156
+ ): Map<string, InvestigationCaseItem[]> {
157
+ const byGroup = new Map<string, InvestigationCaseItem[]>();
158
+ for (const item of investigationCase?.items ?? []) {
159
+ if (!item.groupId || item.placement === "source") continue;
160
+ const items = byGroup.get(item.groupId) ?? [];
161
+ items.push(item);
162
+ byGroup.set(item.groupId, items);
163
+ }
164
+ return byGroup;
165
+ }
166
+
91
167
  function evidenceTypePrefersFullRow(
92
168
  type: InvestigationEvidenceData["type"],
93
169
  ): boolean {
94
- return type === "logs" || type === "events";
170
+ return EVIDENCE_KIND_TRAITS[type].fullRow;
95
171
  }
96
172
 
97
173
  // Supporting evidence becomes a two-column grid when the pane is wide enough.
@@ -119,12 +195,31 @@ type EvidenceCollection = "main" | "workload" | "earlier";
119
195
  export function partitionInvestigationEvidence(
120
196
  groups: InvestigationEvidenceGroup[],
121
197
  resolution?: InvestigationRootCauseEvidenceResolution,
198
+ investigationCase?: InvestigationCaseResolution,
199
+ /**
200
+ * What a later turn cited, with the source that cited it. A follow-up adds
201
+ * to what the displayed assessment selected and never takes a selection
202
+ * away. The source travels with it because promoting a broader card needs
203
+ * one: an id alone selects the group but leaves the broader gate below
204
+ * unable to find who cited it, and the card stays withheld.
205
+ */
206
+ alsoSelected?: readonly {
207
+ groupId: string;
208
+ source: InvestigationEvidenceSource;
209
+ }[],
122
210
  ) {
123
- const selected = new Set(
124
- resolution?.status === "linked"
211
+ // Selection: legacy root-cause links plus every placed agent item, of any
212
+ // role (placement promotes a group into main the way a citation does; the
213
+ // role does not matter here). Ordering below is the only place roles act,
214
+ // and nothing the agent sends can remove a group from a collection.
215
+ const caseByGroup = investigationCaseByGroup(investigationCase);
216
+ const selected = new Set([
217
+ ...(resolution?.status === "linked"
125
218
  ? resolution.links.map((link) => link.originalGroupId)
126
- : [],
127
- );
219
+ : []),
220
+ ...caseByGroup.keys(),
221
+ ...(alsoSelected ?? []).map((entry) => entry.groupId),
222
+ ]);
128
223
  const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
129
224
  {
130
225
  main: [],
@@ -134,30 +229,51 @@ export function partitionInvestigationEvidence(
134
229
  const collectionByGroup = new Map<string, EvidenceCollection>();
135
230
  const adverse = (group: InvestigationEvidenceGroup) =>
136
231
  ["error", "alert", "warning"].includes(group.latest.tone);
232
+ // Broader cards are facts about something other than the target. They are
233
+ // withheld unless cited, and the pane says how many were withheld so a
234
+ // reader knows the agent looked at things it did not build its case on.
235
+ let hiddenBroader = 0;
236
+ // Broader metrics are facts about something other than the target. They are
237
+ // withheld unless cited, and the pane says how many were withheld so a
238
+ // reader knows the agent ran queries it did not build its case on.
239
+ let hiddenMetrics = 0;
137
240
  for (const group of groups) {
138
241
  const broader = group.latest.relevance === "broader";
139
242
  // Citations select tool results, not individual rows in a broad search.
140
243
  // 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
244
+ // An agent item names this observation when its subject does, or when the
245
+ // call it cites was itself scoped to one resource; a subject-less item on
246
+ // a broad query only proves the query returned one row and is gated like
247
+ // a citation of its source.
248
+ const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
249
+ (item) => item.subject || sourceNamesOneResource(item.source),
250
+ );
251
+ if (broader && !namedByAgent) {
252
+ const citingSource =
253
+ resolution?.links.find((item) => item.originalGroupId === group.id)
254
+ ?.source ??
255
+ caseByGroup.get(group.id)?.[0]?.source ??
256
+ alsoSelected?.find((entry) => entry.groupId === group.id)?.source;
257
+ const focused = evidenceKindIsFocused(group.latest.data.type);
258
+ const sourceGroups = citingSource
149
259
  ? groups.filter(
150
260
  (candidate) =>
151
- ["resource", "logs", "crash"].includes(
152
- candidate.latest.data.type,
153
- ) &&
261
+ evidenceKindIsFocused(candidate.latest.data.type) &&
154
262
  candidate.observations.some(
155
- (observation) => observation.source.id === link.source.id,
263
+ (observation) => observation.source.id === citingSource.id,
156
264
  ),
157
265
  )
158
266
  : [];
159
- if (!selected.has(group.id) || !focused || sourceGroups.length !== 1)
267
+ if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
268
+ // The two counts head separate lines in the pane, so they have to be
269
+ // disjoint: a withheld chart announced by both would read as two
270
+ // withheld results.
271
+ if (!group.historical) {
272
+ if (group.latest.data.type === "metrics") hiddenMetrics += 1;
273
+ else hiddenBroader += 1;
274
+ }
160
275
  continue;
276
+ }
161
277
  }
162
278
  const main =
163
279
  !group.historical &&
@@ -173,14 +289,51 @@ export function partitionInvestigationEvidence(
173
289
  collections[collection].push(group);
174
290
  collectionByGroup.set(group.id, collection);
175
291
  }
176
- collections.main.sort(
177
- (left, right) =>
292
+ // A group takes its strongest role (lowest rank); within one role the
293
+ // agent's own item order decides. Unlabelled and ruled-out groups keep
294
+ // Radar's order among themselves.
295
+ const roleOrder = (group: InvestigationEvidenceGroup) => {
296
+ let rank: number | undefined;
297
+ let itemIndex = Number.POSITIVE_INFINITY;
298
+ for (const item of caseByGroup.get(group.id) ?? []) {
299
+ const itemRank = EVIDENCE_ROLE_RANK[item.role];
300
+ if (rank === undefined || itemRank < rank) {
301
+ rank = itemRank;
302
+ itemIndex = item.index;
303
+ } else if (itemRank === rank) {
304
+ itemIndex = Math.min(itemIndex, item.index);
305
+ }
306
+ }
307
+ if (rank === undefined || rank === UNLABELLED_RANK) {
308
+ return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
309
+ }
310
+ return { rank, itemIndex };
311
+ };
312
+ collections.main.sort((left, right) => {
313
+ const leftRole = roleOrder(left);
314
+ const rightRole = roleOrder(right);
315
+ const agentOrder =
316
+ Number.isFinite(leftRole.itemIndex) ||
317
+ Number.isFinite(rightRole.itemIndex)
318
+ ? leftRole.itemIndex - rightRole.itemIndex
319
+ : 0;
320
+ return (
321
+ leftRole.rank - rightRole.rank ||
322
+ agentOrder ||
178
323
  Number(right.latest.tier === "key") -
179
324
  Number(left.latest.tier === "key") ||
180
325
  Number(adverse(right)) - Number(adverse(left)) ||
181
- left.firstOrder - right.firstOrder,
326
+ left.firstOrder - right.firstOrder
327
+ );
328
+ });
329
+ // A healthy workload's collection opens with several "nothing here"
330
+ // receipts; the cards that carry facts (its vitals above all) come first,
331
+ // and the receipts keep their order among themselves.
332
+ collections.workload.sort(
333
+ (left, right) =>
334
+ Number(left.kind === "receipt") - Number(right.kind === "receipt"),
182
335
  );
183
- return { ...collections, collectionByGroup };
336
+ return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
184
337
  }
185
338
 
186
339
  export function investigationEvidenceRevealCollection(
@@ -205,11 +358,14 @@ export function investigationEvidenceRevealCollection(
205
358
  export function InvestigationEvidencePane({
206
359
  projection,
207
360
  rootCauseEvidence,
361
+ alsoSelectedGroupIds,
362
+ investigationCase,
208
363
  collecting,
209
364
  animateGroupIds,
210
365
  onViewSource,
211
366
  onViewActivity,
212
367
  onOpenResource,
368
+ onOpenTimeline,
213
369
  afterEvidence,
214
370
  revealRequest,
215
371
  onRevealReady,
@@ -217,6 +373,13 @@ export function InvestigationEvidencePane({
217
373
  projection: InvestigationEvidenceProjection;
218
374
  /** Server-validated links for the current root cause; absent without one. */
219
375
  rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
376
+ /** What a later turn cited; adds to the assessment's selection. */
377
+ alsoSelectedGroupIds?: readonly {
378
+ groupId: string;
379
+ source: InvestigationEvidenceSource;
380
+ }[];
381
+ /** The current assessment's agent case, resolved against this projection. */
382
+ investigationCase?: InvestigationCaseResolution;
220
383
  collecting: boolean;
221
384
  animateGroupIds: ReadonlySet<string>;
222
385
  onViewSource: (
@@ -227,6 +390,8 @@ export function InvestigationEvidencePane({
227
390
  onViewActivity: () => void;
228
391
  /** Opens an evidence subject in Radar when the producer identified it exactly. */
229
392
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
393
+ /** Opens Radar's Timeline filtered to the resource a changes card is about. */
394
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
230
395
  /** Actions follow the complete evidence section, including its disclosures. */
231
396
  afterEvidence?: ReactNode;
232
397
  /** Explicit Activity → Findings navigation, including repeat clicks. */
@@ -253,9 +418,57 @@ export function InvestigationEvidencePane({
253
418
  const partition = partitionInvestigationEvidence(
254
419
  projection.groups,
255
420
  rootCauseEvidence,
421
+ investigationCase,
422
+ alsoSelectedGroupIds,
256
423
  );
257
424
  const hasCurrentEvidence =
258
425
  partition.main.length + partition.workload.length > 0;
426
+ // A "Ruled out" link is an in-pane navigation to the placed observation. It
427
+ // reuses the Activity → Findings reveal path (open the card, open history
428
+ // when the observation is a superseded revision) and yields to a newer
429
+ // request from the parent.
430
+ const [caseReveal, setCaseReveal] = useState<
431
+ { sourceId: string; requestId: number } | undefined
432
+ >(undefined);
433
+ // A ruled-out reveal belongs to the case that produced it; a new parent
434
+ // request or a new assessment's case supersedes it.
435
+ useEffect(() => {
436
+ setCaseReveal(undefined);
437
+ }, [revealRequest?.requestId, investigationCase]);
438
+ const collectionByGroup = partition.collectionByGroup;
439
+ // A placed item can still sit on a withheld broader card; a link to it
440
+ // would have nowhere to go.
441
+ const visibleRuledOut = (investigationCase?.ruledOut ?? []).filter(
442
+ (entry) => entry.item.groupId && collectionByGroup.has(entry.item.groupId),
443
+ );
444
+ const revealCaseItem = useCallback(
445
+ (item: InvestigationCaseItem) => {
446
+ const { groupId, observation } = item;
447
+ if (!groupId || !observation) return;
448
+ // A placed group is normally in main, but a historical one lives in the
449
+ // nested "Previous observations" collection; open the way to it first.
450
+ const collection = collectionByGroup.get(groupId);
451
+ let settle = 0;
452
+ if (collection === "workload" || collection === "earlier") {
453
+ setWorkloadOpen(true);
454
+ settle = investigationDisclosureSettleDelay(prefersReducedMotion());
455
+ }
456
+ if (collection === "earlier") setEarlierOpen(true);
457
+ onGroupOpenChange(groupId, true);
458
+ setCaseReveal({ sourceId: observation.source.id, requestId: Date.now() });
459
+ window.setTimeout(() => {
460
+ window.requestAnimationFrame(() => {
461
+ const element = document.getElementById(groupId);
462
+ element?.scrollIntoView({
463
+ block: "start",
464
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
465
+ });
466
+ element?.focus({ preventScroll: true });
467
+ });
468
+ }, settle);
469
+ },
470
+ [onGroupOpenChange, collectionByGroup],
471
+ );
259
472
  const revealCollection = revealRequest
260
473
  ? investigationEvidenceRevealCollection(
261
474
  projection,
@@ -376,6 +589,13 @@ export function InvestigationEvidencePane({
376
589
  />
377
590
  ) : null}
378
591
 
592
+ {/* What the agent considered and rejected belongs with the conclusion
593
+ it argues for, not after the evidence list among the withheld
594
+ counts, where it read as bookkeeping. */}
595
+ {visibleRuledOut.length > 0 ? (
596
+ <RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
597
+ ) : null}
598
+
379
599
  <div className="grid items-start gap-2.5">
380
600
  {partition.main.map((group) => (
381
601
  <EvidenceCard
@@ -388,6 +608,28 @@ export function InvestigationEvidencePane({
388
608
  ))}
389
609
  </div>
390
610
 
611
+ {partition.hiddenBroader > 0 ? (
612
+ <p
613
+ className="text-xs text-theme-text-tertiary"
614
+ data-testid="investigation-hidden-evidence"
615
+ >
616
+ {partition.hiddenBroader === 1
617
+ ? "1 result about another resource is not shown; it appears here when the assessment cites it."
618
+ : `${partition.hiddenBroader} results about other resources are not shown; they appear here when the assessment cites them.`}
619
+ </p>
620
+ ) : null}
621
+
622
+ {partition.hiddenMetrics > 0 ? (
623
+ <p
624
+ className="text-xs text-theme-text-tertiary"
625
+ data-testid="investigation-hidden-metrics"
626
+ >
627
+ {partition.hiddenMetrics === 1
628
+ ? "1 broader metric result is not shown; it appears here when the assessment cites it."
629
+ : `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
630
+ </p>
631
+ ) : null}
632
+
391
633
  {!hasCurrentEvidence ? (
392
634
  <EmptyCollection
393
635
  collecting={collecting}
@@ -426,10 +668,32 @@ export function InvestigationEvidencePane({
426
668
  <EvidenceNavigationContext.Provider
427
669
  value={{
428
670
  onOpenResource,
671
+ onOpenTimeline,
429
672
  expandedGroupIds,
430
673
  onGroupOpenChange,
431
- revealSourceId: revealRequest?.sourceId,
432
- revealRequestId: revealRequest?.requestId,
674
+ revealSourceId: caseReveal?.sourceId ?? revealRequest?.sourceId,
675
+ revealRequestId: caseReveal?.requestId ?? revealRequest?.requestId,
676
+ caseByGroup: investigationCaseByGroup(investigationCase),
677
+ excludedByItem: new Map(
678
+ (investigationCase?.ruledOut ?? []).map((entry) => [
679
+ investigationCaseItemKey(entry.item),
680
+ entry.hypothesis,
681
+ ]),
682
+ ),
683
+ metricsMarkersBySource: new Map(
684
+ projection.groups.flatMap((group) =>
685
+ group.observations.flatMap((observation) =>
686
+ observation.data.type === "metrics"
687
+ ? [
688
+ [
689
+ observation.source.id,
690
+ metricsChangeCoverage(projection.groups, observation),
691
+ ] as const,
692
+ ]
693
+ : [],
694
+ ),
695
+ ),
696
+ ),
433
697
  citedOrderByGroup: new Map(
434
698
  rootCauseEvidence?.links.flatMap((link) =>
435
699
  link.originalGroupId
@@ -445,6 +709,63 @@ export function InvestigationEvidencePane({
445
709
  );
446
710
  }
447
711
 
712
+ /**
713
+ * Hypotheses the agent dropped, each pointing at the Radar observation whose
714
+ * result contradicted it. Entries whose item could not be placed on an
715
+ * observation are filtered out by the resolver and never rendered here.
716
+ */
717
+ function RuledOutBlock({
718
+ entries,
719
+ onReveal,
720
+ }: {
721
+ entries: InvestigationCaseResolution["ruledOut"];
722
+ onReveal: (item: InvestigationCaseItem) => void;
723
+ }) {
724
+ const headingId = useId();
725
+ return (
726
+ <section
727
+ aria-labelledby={headingId}
728
+ data-testid="investigation-ruled-out"
729
+ className="rounded-lg border border-theme-border/80 bg-theme-base/20 px-3 py-2.5"
730
+ >
731
+ <h3
732
+ id={headingId}
733
+ className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
734
+ >
735
+ Ruled out
736
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
737
+ Agent
738
+ </span>
739
+ </h3>
740
+ <ul className="mt-1.5 space-y-1.5">
741
+ {entries.map((entry, position) => {
742
+ const observation = entry.item.observation!;
743
+ return (
744
+ <li
745
+ key={position}
746
+ className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs"
747
+ >
748
+ <span className="min-w-0 text-theme-text-secondary [overflow-wrap:anywhere]">
749
+ {entry.hypothesis}
750
+ </span>
751
+ <button
752
+ type="button"
753
+ onClick={() => onReveal(entry.item)}
754
+ className="shrink-0 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
755
+ >
756
+ See {observation.title}
757
+ {entry.item.placement === "revision"
758
+ ? " (earlier observation)"
759
+ : ""}
760
+ </button>
761
+ </li>
762
+ );
763
+ })}
764
+ </ul>
765
+ </section>
766
+ );
767
+ }
768
+
448
769
  function AssessmentEvidenceQualification({
449
770
  resolution,
450
771
  onViewActivity,
@@ -633,6 +954,7 @@ function CoverageStrip({
633
954
  }) {
634
955
  const hasError = groups.some((group) => group.hasError);
635
956
  const historyOnly = groups.every((group) => group.historyOnly);
957
+ const quiet = historyOnly;
636
958
  const { elementRef, revealAfterToggle } =
637
959
  useDisclosureReveal<HTMLDivElement>();
638
960
  const regionId = "investigation-evidence-coverage";
@@ -652,7 +974,7 @@ function CoverageStrip({
652
974
  }}
653
975
  className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
654
976
  >
655
- {historyOnly ? (
977
+ {quiet ? (
656
978
  <Info
657
979
  className="h-4 w-4 shrink-0 text-theme-text-tertiary"
658
980
  aria-hidden
@@ -669,7 +991,7 @@ function CoverageStrip({
669
991
  <span
670
992
  className={clsx(
671
993
  "block text-xs",
672
- historyOnly
994
+ quiet
673
995
  ? "font-medium text-theme-text-secondary"
674
996
  : "font-semibold text-theme-text-primary",
675
997
  )}
@@ -735,6 +1057,65 @@ function CoverageGroupRow({
735
1057
  const regionId = useId();
736
1058
  const { elementRef, revealAfterToggle } =
737
1059
  useDisclosureReveal<HTMLDivElement>();
1060
+ // One limitation whose message is the whole summary would repeat itself as
1061
+ // its own detail row; show its source link on the summary row instead.
1062
+ const single =
1063
+ group.limitations.length === 1 &&
1064
+ group.limitations[0].message === group.summary
1065
+ ? group.limitations[0]
1066
+ : undefined;
1067
+ const anchors = sourceIds.map((id) => (
1068
+ <span
1069
+ key={id}
1070
+ id={investigationEvidenceSourceDomId(id)}
1071
+ className="sr-only scroll-mt-14"
1072
+ aria-hidden
1073
+ />
1074
+ ));
1075
+ if (single) {
1076
+ return (
1077
+ <div
1078
+ ref={elementRef}
1079
+ data-evidence-source-container
1080
+ tabIndex={-1}
1081
+ aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
1082
+ className="flex items-center gap-2 px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-accent/40"
1083
+ >
1084
+ {anchors}
1085
+ {group.hasError ? (
1086
+ <CircleAlert
1087
+ className="h-3.5 w-3.5 shrink-0 text-red-400"
1088
+ aria-hidden
1089
+ />
1090
+ ) : single.kind === "truncated" ? (
1091
+ <AlertTriangle
1092
+ className="h-3.5 w-3.5 shrink-0 text-amber-500"
1093
+ aria-hidden
1094
+ />
1095
+ ) : (
1096
+ <Info
1097
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1098
+ aria-hidden
1099
+ />
1100
+ )}
1101
+ <p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
1102
+ <span className="font-medium text-theme-text-primary">
1103
+ {group.label}:
1104
+ </span>{" "}
1105
+ {group.summary}
1106
+ </p>
1107
+ {single.sources.at(-1) ? (
1108
+ <SourceButton
1109
+ ariaLabel={`View source for ${group.label}`}
1110
+ buttonLabel={
1111
+ single.sources.length > 1 ? "View latest in Activity" : undefined
1112
+ }
1113
+ onClick={() => onViewSource(single.sources.at(-1)!.id)}
1114
+ />
1115
+ ) : null}
1116
+ </div>
1117
+ );
1118
+ }
738
1119
  return (
739
1120
  <div
740
1121
  ref={elementRef}
@@ -743,14 +1124,7 @@ function CoverageGroupRow({
743
1124
  aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
744
1125
  className="outline-none focus:ring-2 focus:ring-accent/40"
745
1126
  >
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
- ))}
1127
+ {anchors}
754
1128
  <button
755
1129
  type="button"
756
1130
  aria-expanded={open}
@@ -833,14 +1207,25 @@ function CoverageGroupRow({
833
1207
  function previousDifferentObservations(
834
1208
  group: InvestigationEvidenceGroup,
835
1209
  citedOrder?: number,
1210
+ boundOrders: ReadonlySet<number> = new Set(),
836
1211
  ) {
837
1212
  const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
1213
+ const pinned = (observation: InvestigationEvidenceObservation) =>
1214
+ observation.source.order === citedOrder ||
1215
+ boundOrders.has(observation.source.order);
838
1216
  return [...group.observations].reverse().filter((observation) => {
1217
+ if (observation === group.latest) return false;
1218
+ // An observation bound to an agent item always keeps its own row: display
1219
+ // equivalence is a folding rule for unpinned rechecks, never a reason to
1220
+ // move a claim onto different content.
1221
+ if (boundOrders.has(observation.source.order)) return true;
1222
+ // A pinned observation wins over an unpinned twin with the same display
1223
+ // content.
839
1224
  if (
840
- observation.source.order !== citedOrder &&
1225
+ !pinned(observation) &&
841
1226
  group.observations.some(
842
1227
  (other) =>
843
- other.source.order === citedOrder &&
1228
+ pinned(other) &&
844
1229
  evidenceDisplaySnapshot(other) ===
845
1230
  evidenceDisplaySnapshot(observation),
846
1231
  )
@@ -856,6 +1241,8 @@ function previousDifferentObservations(
856
1241
  export function investigationEvidenceShouldRevealHistory(
857
1242
  group: InvestigationEvidenceGroup,
858
1243
  sourceId?: string,
1244
+ /** Sources whose observation carries an agent item and so always has a row. */
1245
+ boundSourceIds: ReadonlySet<string> = new Set(),
859
1246
  ): boolean {
860
1247
  return (
861
1248
  Boolean(sourceId) &&
@@ -863,8 +1250,9 @@ export function investigationEvidenceShouldRevealHistory(
863
1250
  group.observations.some(
864
1251
  (observation) =>
865
1252
  observation.source.id === sourceId &&
866
- evidenceDisplaySnapshot(observation) !==
867
- evidenceDisplaySnapshot(group.latest),
1253
+ (boundSourceIds.has(observation.source.id) ||
1254
+ evidenceDisplaySnapshot(observation) !==
1255
+ evidenceDisplaySnapshot(group.latest)),
868
1256
  )
869
1257
  );
870
1258
  }
@@ -891,11 +1279,15 @@ function EvidenceCard({
891
1279
  }) {
892
1280
  const {
893
1281
  onOpenResource,
1282
+ onOpenTimeline,
894
1283
  revealSourceId,
895
1284
  revealRequestId,
896
1285
  citedOrderByGroup,
897
1286
  expandedGroupIds,
898
1287
  onGroupOpenChange,
1288
+ metricsMarkersBySource,
1289
+ caseByGroup,
1290
+ excludedByItem,
899
1291
  } = useContext(EvidenceNavigationContext);
900
1292
  const open = expandedGroupIds?.has(group.id) ?? false;
901
1293
  const setOpen = useCallback(
@@ -909,7 +1301,17 @@ function EvidenceCard({
909
1301
  ? parseLogLine(observation.summary).content
910
1302
  : observation.summary;
911
1303
  const citedOrder = citedOrderByGroup?.get(group.id);
912
- const previousObservations = previousDifferentObservations(group, citedOrder);
1304
+ const caseItems = caseByGroup?.get(group.id) ?? [];
1305
+ const cardItems = caseItems.filter((item) => item.placement === "card");
1306
+ const revisionItems = caseItems.filter(
1307
+ (item) => item.placement === "revision",
1308
+ );
1309
+ const agentCause = cardItems.some((item) => item.role === "cause");
1310
+ const previousObservations = previousDifferentObservations(
1311
+ group,
1312
+ citedOrder,
1313
+ new Set(revisionItems.map((item) => item.observation!.source.order)),
1314
+ );
913
1315
  const meaningfulHistory = previousObservations.length > 0;
914
1316
  const citedObservation = group.observations.find(
915
1317
  (item) => item.source.order === citedOrder,
@@ -927,6 +1329,7 @@ function EvidenceCard({
927
1329
  const revealHistory = investigationEvidenceShouldRevealHistory(
928
1330
  group,
929
1331
  revealSourceId,
1332
+ new Set(revisionItems.map((item) => item.observation!.source.id)),
930
1333
  );
931
1334
  useLayoutEffect(() => {
932
1335
  const destination = revealSourceId
@@ -1007,7 +1410,14 @@ function EvidenceCard({
1007
1410
  aria-label={`${observation.title} evidence`}
1008
1411
  className={clsx(
1009
1412
  "@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",
1413
+ "rounded-lg border",
1414
+ // The card the agent calls the cause sorts first and is often the
1415
+ // calmest thing on screen: a cause is frequently a Secret or a
1416
+ // ConfigMap that Radar has no reason to colour, while every card
1417
+ // echoing the failure below it carries red. Lifting the surface marks
1418
+ // it without borrowing a severity hue, and without touching the left
1419
+ // rule (Radar's severity) or the ring (the source you are viewing).
1420
+ agentCause ? "bg-theme-elevated shadow-theme-sm" : "bg-theme-surface",
1011
1421
  toneBorder(observation.tone, observation.tier, prominence),
1012
1422
  wide && "@min-[760px]/evidence:col-span-2",
1013
1423
  animateArrival && "animate-transcript-enter",
@@ -1072,8 +1482,32 @@ function EvidenceCard({
1072
1482
  onOpenResource={onOpenResource}
1073
1483
  compact
1074
1484
  />
1485
+ {observation.data.type === "changes" && observation.data.subject ? (
1486
+ <OpenTimelineButton
1487
+ scope={observation.data.subject}
1488
+ onOpenTimeline={onOpenTimeline}
1489
+ />
1490
+ ) : null}
1075
1491
  </div>
1076
1492
  </div>
1493
+ {cardItems.some((item) => item.claim || item.role) ? (
1494
+ <div
1495
+ className={clsx(
1496
+ "space-y-1.5",
1497
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
1498
+ )}
1499
+ >
1500
+ {cardItems.map((item) => (
1501
+ <AgentClaimNote
1502
+ key={item.index}
1503
+ claim={item.claim}
1504
+ role={item.role}
1505
+ excludes={excludedByItem?.get(investigationCaseItemKey(item))}
1506
+ className="pt-1.5"
1507
+ />
1508
+ ))}
1509
+ </div>
1510
+ ) : null}
1077
1511
  {canExpand ? (
1078
1512
  <div id={bodyId}>
1079
1513
  <Collapse open={open}>
@@ -1087,12 +1521,16 @@ function EvidenceCard({
1087
1521
  <EvidenceBody
1088
1522
  data={observation.data}
1089
1523
  cardSummary={observation.summary}
1524
+ changeCoverage={metricsMarkersBySource?.get(
1525
+ observation.source.id,
1526
+ )}
1090
1527
  />
1091
1528
  ) : null}
1092
1529
  {meaningfulHistory ? (
1093
1530
  <RevisionHistory
1094
1531
  observations={previousObservations}
1095
1532
  citedOrder={citedOrder}
1533
+ caseItems={revisionItems}
1096
1534
  reveal={revealHistory}
1097
1535
  revealRequestId={revealRequestId}
1098
1536
  onViewSource={onViewSource}
@@ -1146,6 +1584,36 @@ function OpenResourceButton({
1146
1584
  );
1147
1585
  }
1148
1586
 
1587
+ function OpenTimelineButton({
1588
+ scope,
1589
+ onOpenTimeline,
1590
+ }: {
1591
+ scope: InvestigationTimelineScope;
1592
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
1593
+ }) {
1594
+ if (!onOpenTimeline) return null;
1595
+ const identity = `${scope.namespace ? `${scope.namespace}/` : ""}${scope.name}`;
1596
+ const label = `Open the Timeline for ${identity}`;
1597
+ return (
1598
+ <Tooltip
1599
+ content={label}
1600
+ delay={350}
1601
+ position="left"
1602
+ wrapperClassName="flex shrink-0"
1603
+ >
1604
+ <button
1605
+ type="button"
1606
+ aria-label={label}
1607
+ onClick={() => onOpenTimeline(scope)}
1608
+ className="flex h-7 shrink-0 items-center justify-center gap-1 rounded px-2 text-xs font-medium 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"
1609
+ >
1610
+ <FileClock className="h-3.5 w-3.5" aria-hidden />
1611
+ <span className="hidden @min-[540px]/card:inline">Timeline</span>
1612
+ </button>
1613
+ </Tooltip>
1614
+ );
1615
+ }
1616
+
1149
1617
  function uniquePrimarySources(
1150
1618
  group: InvestigationEvidenceGroup,
1151
1619
  ): InvestigationEvidenceSource[] {
@@ -1164,9 +1632,12 @@ function uniquePrimarySources(
1164
1632
  function EvidenceBody({
1165
1633
  data,
1166
1634
  cardSummary,
1635
+ changeCoverage,
1167
1636
  }: {
1168
1637
  data: InvestigationEvidenceData;
1169
1638
  cardSummary?: string;
1639
+ /** Change markers for a metrics chart; derived by the pane, never by data. */
1640
+ changeCoverage?: MetricsChangeCoverage;
1170
1641
  }) {
1171
1642
  switch (data.type) {
1172
1643
  case "issue":
@@ -1194,9 +1665,20 @@ function EvidenceBody({
1194
1665
  case "inventory":
1195
1666
  return <InventoryBody data={data} />;
1196
1667
  case "receipt":
1197
- return (
1668
+ // The title and the scope above it are the answer. A body appears only
1669
+ // when it adds the reason or the limit, so an absent one renders nothing
1670
+ // rather than an empty line.
1671
+ return data.message ? (
1198
1672
  <p className="text-xs text-theme-text-secondary">{data.message}</p>
1199
- );
1673
+ ) : null;
1674
+ case "alerts":
1675
+ return <AlertsBody data={data} />;
1676
+ case "helm":
1677
+ return <HelmBody data={data} />;
1678
+ case "permissions":
1679
+ return <PermissionsBody data={data} />;
1680
+ case "metrics":
1681
+ return <MetricsBody data={data} changeCoverage={changeCoverage} />;
1200
1682
  }
1201
1683
  }
1202
1684
 
@@ -1211,10 +1693,12 @@ function evidenceHasDetails(
1211
1693
  const message = data.issue.message?.trim();
1212
1694
  return Boolean(
1213
1695
  (cause && cause !== summary) ||
1214
- (message && message !== summary && message !== cause),
1696
+ (message && message !== summary && message !== cause) ||
1697
+ data.pods?.length,
1215
1698
  );
1216
1699
  }
1217
1700
  case "startup":
1701
+ return (data.pods?.length ?? 0) > 1;
1218
1702
  case "receipt":
1219
1703
  return false;
1220
1704
  case "resource": {
@@ -1223,6 +1707,7 @@ function evidenceHasDetails(
1223
1707
  investigationResourceEvidenceHasDetails(data.resource) ||
1224
1708
  replicas?.desired !== undefined ||
1225
1709
  data.resourceContext?.statusSummary?.conditions?.length ||
1710
+ diagnosedScalers(data.resourceContext).length ||
1226
1711
  data.gitOpsDiagnosis ||
1227
1712
  data.warnings.length,
1228
1713
  );
@@ -1271,6 +1756,18 @@ function IssueBody({
1271
1756
  {issue.namespace ? `${issue.namespace}/` : ""}
1272
1757
  {issue.name}
1273
1758
  </Badge>
1759
+ {data.pods && data.pods.length > 0 ? (
1760
+ <>
1761
+ <Badge tone="structural" size="sm">
1762
+ {data.pods.length === 1 ? "1 Pod" : `${data.pods.length} Pods`}
1763
+ </Badge>
1764
+ {data.pods.map((pod) => (
1765
+ <Badge key={pod} tone="structural" size="sm">
1766
+ {pod}
1767
+ </Badge>
1768
+ ))}
1769
+ </>
1770
+ ) : null}
1274
1771
  </div>
1275
1772
  {showCause ? (
1276
1773
  <p className="text-sm font-medium leading-relaxed text-theme-text-primary">
@@ -1288,15 +1785,18 @@ function IssueBody({
1288
1785
 
1289
1786
  function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
1290
1787
  const blocker = data.blocker;
1788
+ const pods = data.pods && data.pods.length > 1 ? data.pods : [blocker.name];
1291
1789
  return (
1292
1790
  <div className="space-y-2">
1293
1791
  <div className="flex flex-wrap gap-1.5">
1294
1792
  <Badge tone="structural" size="sm">
1295
- {blocker.kind}
1296
- </Badge>
1297
- <Badge tone="structural" size="sm">
1298
- {blocker.name}
1793
+ {pods.length > 1 ? `${pods.length} ${blocker.kind}s` : blocker.kind}
1299
1794
  </Badge>
1795
+ {pods.map((pod) => (
1796
+ <Badge key={pod} tone="structural" size="sm">
1797
+ {pod}
1798
+ </Badge>
1799
+ ))}
1300
1800
  <Badge severity={severityBadge(blocker.severity)} size="sm">
1301
1801
  {blocker.severity}
1302
1802
  </Badge>
@@ -1343,6 +1843,7 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1343
1843
  const ready = replicas ? (replicas.ready ?? 0) : undefined;
1344
1844
  const shortfall =
1345
1845
  desired !== undefined && ready !== undefined && ready < desired;
1846
+ const scalers = diagnosedScalers(data.resourceContext);
1346
1847
  return (
1347
1848
  <div className="space-y-3">
1348
1849
  <InvestigationResourceEvidence resource={data.resource} />
@@ -1398,6 +1899,12 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1398
1899
  </div>
1399
1900
  </div>
1400
1901
  ) : null}
1902
+ {scalers.length > 0 ? (
1903
+ <ScaledBySection
1904
+ scalers={scalers}
1905
+ allScalers={data.resourceContext?.scaledBy ?? []}
1906
+ />
1907
+ ) : null}
1401
1908
  {data.warnings.length > 0 ? (
1402
1909
  <ul className="space-y-1 text-xs text-theme-text-secondary">
1403
1910
  {data.warnings.map((warning) => (
@@ -1412,6 +1919,75 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1412
1919
  );
1413
1920
  }
1414
1921
 
1922
+ type DiagnosedScaler = DiagnosisScalerRef &
1923
+ Required<Pick<DiagnosisScalerRef, "hpaSummary">>;
1924
+
1925
+ function diagnosedScalers(
1926
+ context: DiagnosisResourceContext | undefined,
1927
+ ): DiagnosedScaler[] {
1928
+ return (context?.scaledBy ?? []).filter(
1929
+ (scaler): scaler is DiagnosedScaler => scaler.hpaSummary !== undefined,
1930
+ );
1931
+ }
1932
+
1933
+ function ScaledBySection({
1934
+ scalers,
1935
+ allScalers,
1936
+ }: {
1937
+ scalers: DiagnosedScaler[];
1938
+ allScalers: DiagnosisScalerRef[];
1939
+ }) {
1940
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
1941
+ // The ScaledObject is only a link when Radar listed it as a scaler too; a
1942
+ // name read off the HPA's owner reference alone is not a resource we hold.
1943
+ const listedScaler = (ref: DiagnosisResourceRef) =>
1944
+ allScalers.some(
1945
+ (scaler) =>
1946
+ scaler.kind === ref.kind &&
1947
+ scaler.name === ref.name &&
1948
+ (scaler.namespace ?? "") === (ref.namespace ?? ""),
1949
+ );
1950
+ return (
1951
+ <div>
1952
+ <div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1953
+ Scaled by
1954
+ </div>
1955
+ <div className="space-y-2">
1956
+ {scalers.map((scaler) => (
1957
+ <HPADiagnosisSummary
1958
+ key={`${scaler.namespace ?? ""}/${scaler.name}`}
1959
+ diagnosis={scaler.hpaSummary}
1960
+ variant="inline"
1961
+ header={
1962
+ <>
1963
+ <span className="font-medium text-theme-text-primary">
1964
+ {displayKind(scaler.kind)} {scaler.name}
1965
+ </span>
1966
+ {scaler.managedBy ? (
1967
+ <span className="text-theme-text-tertiary">
1968
+ managed by KEDA {displayKind(scaler.managedBy.kind)}{" "}
1969
+ <ResourceLink
1970
+ name={scaler.managedBy.name}
1971
+ kind={scaler.managedBy.kind}
1972
+ namespace={scaler.managedBy.namespace ?? ""}
1973
+ group={scaler.managedBy.group}
1974
+ onNavigate={
1975
+ onOpenResource && listedScaler(scaler.managedBy)
1976
+ ? (ref) => onOpenResource(ref)
1977
+ : undefined
1978
+ }
1979
+ />
1980
+ </span>
1981
+ ) : null}
1982
+ </>
1983
+ }
1984
+ />
1985
+ ))}
1986
+ </div>
1987
+ </div>
1988
+ );
1989
+ }
1990
+
1415
1991
  function GitOpsStatusBody({
1416
1992
  status,
1417
1993
  }: {
@@ -1585,7 +2161,11 @@ function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
1585
2161
  <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
1586
2162
  <span className="text-xs font-semibold text-theme-text-primary">
1587
2163
  {event.reason}
1588
- {event.count > 1 ? ` ×${event.count}` : ""}
2164
+ {event.count > 1 ? (
2165
+ <span className="ml-1.5 font-mono text-[11px] font-normal text-theme-text-tertiary">
2166
+ ×{event.count}
2167
+ </span>
2168
+ ) : null}
1589
2169
  </span>
1590
2170
  <Tooltip
1591
2171
  content={new Date(event.lastTimestamp).toLocaleString()}
@@ -1958,6 +2538,222 @@ function TopologyStat({ label, value }: { label: string; value: number }) {
1958
2538
  );
1959
2539
  }
1960
2540
 
2541
+ const METRICS_AXIS_LABEL_MAX_CHARS = 72;
2542
+
2543
+ function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
2544
+ return series.dataPoints.filter((point) => typeof point.value === "number");
2545
+ }
2546
+
2547
+ function MetricsValueTable({
2548
+ series,
2549
+ labels,
2550
+ unit,
2551
+ withTime,
2552
+ }: {
2553
+ series: TimeSeries[];
2554
+ /** Display name per series, derived from the complete result. */
2555
+ labels: string[];
2556
+ unit: string;
2557
+ withTime: boolean;
2558
+ }) {
2559
+ return (
2560
+ <table className="w-full text-xs">
2561
+ <tbody>
2562
+ {series.map((item, index) => {
2563
+ const sample = finiteSamples(item).at(-1);
2564
+ return (
2565
+ <tr
2566
+ key={`${labels[index]}-${index}`}
2567
+ className="border-b border-theme-border/60 last:border-b-0"
2568
+ >
2569
+ <td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
2570
+ {labels[index]}
2571
+ </td>
2572
+ {withTime ? (
2573
+ <td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
2574
+ {sample
2575
+ ? new Date(sample.timestamp * 1000).toLocaleTimeString()
2576
+ : ""}
2577
+ </td>
2578
+ ) : null}
2579
+ <td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
2580
+ {sample && typeof sample.value === "number"
2581
+ ? formatMetricValue(sample.value, unit)
2582
+ : "no value"}
2583
+ </td>
2584
+ </tr>
2585
+ );
2586
+ })}
2587
+ </tbody>
2588
+ </table>
2589
+ );
2590
+ }
2591
+
2592
+ function MetricsBody({
2593
+ data,
2594
+ changeCoverage,
2595
+ }: {
2596
+ data: EvidenceDataOf<"metrics">;
2597
+ changeCoverage?: MetricsChangeCoverage;
2598
+ }) {
2599
+ const annotations = changeCoverage?.markers;
2600
+ const unit = data.unit ?? "";
2601
+ const axisLabel = data.label ?? data.query;
2602
+ const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
2603
+ const axisText = axisTruncated
2604
+ ? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
2605
+ : axisLabel;
2606
+ const domain = metricsDomain(data);
2607
+ const windowText = domain
2608
+ ? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
2609
+ : undefined;
2610
+ if (data.series.length === 0) {
2611
+ return (
2612
+ <div className="space-y-2">
2613
+ <p className="text-xs text-theme-text-secondary">
2614
+ No series matched this query
2615
+ {data.mode === "range" ? " in the window" : ""}.
2616
+ </p>
2617
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2618
+ {data.query}
2619
+ </pre>
2620
+ {data.note ? (
2621
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2622
+ ) : null}
2623
+ </div>
2624
+ );
2625
+ }
2626
+ if (data.mode === "instant") {
2627
+ return (
2628
+ <div className="space-y-2">
2629
+ <MetricsValueTable
2630
+ series={data.series}
2631
+ labels={seriesDisplayLabels(data.series)}
2632
+ unit={unit}
2633
+ withTime={false}
2634
+ />
2635
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2636
+ {data.query}
2637
+ </pre>
2638
+ {data.note ? (
2639
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2640
+ ) : null}
2641
+ </div>
2642
+ );
2643
+ }
2644
+ // Names come from the whole result so two series that differ only by a
2645
+ // label the chart would hide stay distinguishable wherever they are listed.
2646
+ const labels = seriesDisplayLabels(data.series);
2647
+ const indexed = data.series.map((item, index) => ({
2648
+ item,
2649
+ label: labels[index],
2650
+ finite: finiteSamples(item).length,
2651
+ }));
2652
+ // A series with one finite sample has no line to draw, and one with none
2653
+ // (Prometheus serializes NaN and infinities as gaps) has nothing to show.
2654
+ // Listing both keeps what was captured readable and states what was not.
2655
+ const charted = indexed.filter((entry) => entry.finite >= 2);
2656
+ const single = indexed.filter((entry) => entry.finite === 1);
2657
+ const empty = indexed.filter((entry) => entry.finite === 0);
2658
+ return (
2659
+ <div className="space-y-2">
2660
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
2661
+ <Tooltip
2662
+ content={axisLabel}
2663
+ delay={200}
2664
+ position="top"
2665
+ disabled={!axisTruncated}
2666
+ >
2667
+ <span
2668
+ className="min-w-0 truncate font-mono text-theme-text-secondary"
2669
+ data-testid="investigation-metrics-axis-label"
2670
+ >
2671
+ {axisText}
2672
+ </span>
2673
+ </Tooltip>
2674
+ {unit ? (
2675
+ <span className="text-theme-text-tertiary">({unit})</span>
2676
+ ) : null}
2677
+ {windowText ? (
2678
+ <span className="ml-auto text-theme-text-tertiary">{windowText}</span>
2679
+ ) : null}
2680
+ </div>
2681
+ {charted.length > 0 ? (
2682
+ <div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
2683
+ <AreaChart
2684
+ series={charted.map((entry) => entry.item)}
2685
+ seriesLabels={charted.map((entry) => entry.label)}
2686
+ color={SERIES_COLORS[0]}
2687
+ fillColor={seriesFill(0, SERIES_COLORS[0])}
2688
+ unit={unit}
2689
+ annotations={annotations}
2690
+ domain={domain}
2691
+ layout="auto"
2692
+ />
2693
+ {charted.length > 1 ? (
2694
+ <SeriesLegend
2695
+ series={charted.map((entry) => entry.item)}
2696
+ seriesLabels={charted.map((entry) => entry.label)}
2697
+ color={SERIES_COLORS[0]}
2698
+ />
2699
+ ) : null}
2700
+ </div>
2701
+ ) : null}
2702
+ {single.length > 0 ? (
2703
+ <div
2704
+ className="space-y-1"
2705
+ data-testid="investigation-metrics-sparse-series"
2706
+ >
2707
+ <p className="text-xs text-theme-text-tertiary">
2708
+ {single.length === 1
2709
+ ? "1 series has a single sample in this window, listed with its time:"
2710
+ : `${single.length} series have a single sample in this window, listed with their times:`}
2711
+ </p>
2712
+ <MetricsValueTable
2713
+ series={single.map((entry) => entry.item)}
2714
+ labels={single.map((entry) => entry.label)}
2715
+ unit={unit}
2716
+ withTime
2717
+ />
2718
+ </div>
2719
+ ) : null}
2720
+ {empty.length > 0 ? (
2721
+ <p
2722
+ className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
2723
+ data-testid="investigation-metrics-empty-series"
2724
+ >
2725
+ {empty.length === 1
2726
+ ? "1 series had no usable samples in this window: "
2727
+ : `${empty.length} series had no usable samples in this window: `}
2728
+ <span className="font-mono">
2729
+ {empty.map((entry) => entry.label).join("; ")}
2730
+ </span>
2731
+ </p>
2732
+ ) : null}
2733
+ {changeCoverage?.checked && charted.length > 0 ? (
2734
+ <p className="text-xs text-theme-text-tertiary">
2735
+ {annotations?.length === 1
2736
+ ? "1 change recorded in this window is marked on the chart."
2737
+ : annotations?.length
2738
+ ? `${annotations.length} changes recorded in this window are marked on the chart.`
2739
+ : // Only that the changes Radar read miss this window — not that
2740
+ // the window was fully covered. The change lookup has its own
2741
+ // window, which need not span the chart's.
2742
+ "None of the changes Radar read fall in this window."}
2743
+ </p>
2744
+ ) : null}
2745
+ {axisTruncated ? (
2746
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2747
+ {data.query}
2748
+ </pre>
2749
+ ) : null}
2750
+ {data.note ? (
2751
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2752
+ ) : null}
2753
+ </div>
2754
+ );
2755
+ }
2756
+
1961
2757
  function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
1962
2758
  return (
1963
2759
  <div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
@@ -2003,15 +2799,456 @@ function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
2003
2799
  );
2004
2800
  }
2005
2801
 
2802
+ const VISIBLE_ALERT_LABELS = 6;
2803
+
2804
+ function alertStateSeverity(state: string | undefined) {
2805
+ switch ((state ?? "").toLowerCase()) {
2806
+ case "firing":
2807
+ return "error" as const;
2808
+ case "pending":
2809
+ return "warning" as const;
2810
+ case "inactive":
2811
+ return "success" as const;
2812
+ default:
2813
+ return "neutral" as const;
2814
+ }
2815
+ }
2816
+
2817
+ function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
2818
+ const { rule, instances, annotations } = data;
2819
+ const health = rule.health?.toLowerCase();
2820
+ // Target instances lead; the rest stay visible in producer order.
2821
+ const ordered = [...instances].sort(
2822
+ (left, right) => Number(right.namesTarget) - Number(left.namesTarget),
2823
+ );
2824
+ const annotationEntries = Object.entries(annotations);
2825
+ return (
2826
+ <div className="space-y-2.5">
2827
+ <div className="flex flex-wrap items-center gap-1.5">
2828
+ {rule.state ? (
2829
+ <Badge severity={alertStateSeverity(rule.state)} size="sm">
2830
+ {rule.state}
2831
+ </Badge>
2832
+ ) : null}
2833
+ {rule.labels.severity ? (
2834
+ <Badge severity={severityBadge(rule.labels.severity)} size="sm">
2835
+ severity {rule.labels.severity}
2836
+ </Badge>
2837
+ ) : null}
2838
+ <Badge tone="structural" size="sm">
2839
+ {rule.group}
2840
+ </Badge>
2841
+ {health && health !== "ok" ? (
2842
+ <Badge severity={health === "err" ? "error" : "neutral"} size="sm">
2843
+ health {health}
2844
+ </Badge>
2845
+ ) : null}
2846
+ </div>
2847
+ {ordered.length > 0 ? (
2848
+ <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
2849
+ {ordered.map((instance, index) => {
2850
+ const labels = Object.entries(instance.labels).filter(
2851
+ ([key]) => key !== "alertname" && key !== "severity",
2852
+ );
2853
+ const hidden = labels.length - VISIBLE_ALERT_LABELS;
2854
+ return (
2855
+ <li
2856
+ key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
2857
+ className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
2858
+ >
2859
+ <div className="flex flex-wrap items-center gap-1.5">
2860
+ <Badge
2861
+ severity={alertStateSeverity(instance.state)}
2862
+ size="sm"
2863
+ >
2864
+ {instance.state}
2865
+ </Badge>
2866
+ {instance.namesTarget ? (
2867
+ <Badge severity="info" size="sm">
2868
+ names this resource
2869
+ </Badge>
2870
+ ) : null}
2871
+ {instance.value ? (
2872
+ <span className="font-mono text-xs text-theme-text-secondary">
2873
+ value {instance.value}
2874
+ </span>
2875
+ ) : null}
2876
+ {instance.activeAt ? (
2877
+ <Tooltip
2878
+ content={new Date(instance.activeAt).toLocaleString()}
2879
+ delay={150}
2880
+ position="left"
2881
+ wrapperClassName="ml-auto"
2882
+ >
2883
+ <time
2884
+ dateTime={instance.activeAt}
2885
+ className="text-xs text-theme-text-tertiary"
2886
+ >
2887
+ active {formatRelativeAgeTime(instance.activeAt)}
2888
+ </time>
2889
+ </Tooltip>
2890
+ ) : null}
2891
+ </div>
2892
+ {labels.length > 0 ? (
2893
+ <div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
2894
+ {labels
2895
+ .slice(0, VISIBLE_ALERT_LABELS)
2896
+ .map(([key, value]) => (
2897
+ <span
2898
+ key={key}
2899
+ className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
2900
+ >
2901
+ {key}={value}
2902
+ </span>
2903
+ ))}
2904
+ {hidden > 0 ? (
2905
+ <span className="px-1 py-0.5 text-theme-text-tertiary">
2906
+ +{hidden} more
2907
+ </span>
2908
+ ) : null}
2909
+ </div>
2910
+ ) : null}
2911
+ </li>
2912
+ );
2913
+ })}
2914
+ </ol>
2915
+ ) : (
2916
+ <p className="text-xs italic text-theme-text-tertiary">
2917
+ No active instances were reported for this rule.
2918
+ </p>
2919
+ )}
2920
+ {annotationEntries.length > 0 ? (
2921
+ <dl className="space-y-1 text-xs">
2922
+ {annotationEntries.map(([key, value]) => (
2923
+ <div key={key} className="flex min-w-0 gap-2">
2924
+ <dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
2925
+ <dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2926
+ {value}
2927
+ </dd>
2928
+ </div>
2929
+ ))}
2930
+ </dl>
2931
+ ) : null}
2932
+ {rule.query ? (
2933
+ <TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
2934
+ ) : null}
2935
+ </div>
2936
+ );
2937
+ }
2938
+
2939
+ function helmStatusSeverity(status: string) {
2940
+ const normalized = status.toLowerCase();
2941
+ if (normalized === "deployed") return "success" as const;
2942
+ if (normalized.includes("failed")) return "error" as const;
2943
+ if (normalized.startsWith("pending") || normalized === "uninstalling")
2944
+ return "info" as const;
2945
+ return "warning" as const;
2946
+ }
2947
+
2948
+ function HelmBody({ data }: { data: EvidenceDataOf<"helm"> }) {
2949
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
2950
+ const { release } = data;
2951
+ const operation = release.lastOperation;
2952
+ return (
2953
+ <div className="space-y-3">
2954
+ <div className="flex flex-wrap items-center gap-1.5">
2955
+ <Badge severity={helmStatusSeverity(release.status)} size="sm">
2956
+ {release.status}
2957
+ </Badge>
2958
+ <Badge tone="structural" size="sm">
2959
+ {release.chart}
2960
+ {release.chartVersion ? ` ${release.chartVersion}` : ""}
2961
+ </Badge>
2962
+ <Badge tone="structural" size="sm">
2963
+ revision {release.revision}
2964
+ </Badge>
2965
+ {release.appVersion ? (
2966
+ <Badge tone="note" size="sm">
2967
+ app {release.appVersion}
2968
+ </Badge>
2969
+ ) : null}
2970
+ {release.resourceHealth ? (
2971
+ <Badge
2972
+ severity={
2973
+ mapHealthToTone(release.resourceHealth) === "healthy"
2974
+ ? "success"
2975
+ : mapHealthToTone(release.resourceHealth) === "unhealthy"
2976
+ ? "error"
2977
+ : "warning"
2978
+ }
2979
+ size="sm"
2980
+ >
2981
+ resources {release.resourceHealth}
2982
+ </Badge>
2983
+ ) : null}
2984
+ <Tooltip
2985
+ content={new Date(release.updated).toLocaleString()}
2986
+ delay={150}
2987
+ position="left"
2988
+ wrapperClassName="ml-auto"
2989
+ >
2990
+ <time
2991
+ dateTime={release.updated}
2992
+ className="text-xs text-theme-text-tertiary"
2993
+ >
2994
+ updated {formatRelativeAgeTime(release.updated)}
2995
+ </time>
2996
+ </Tooltip>
2997
+ </div>
2998
+ {release.healthIssue ? (
2999
+ <p className="text-xs leading-relaxed text-warning-text">
3000
+ {release.healthIssue}
3001
+ </p>
3002
+ ) : null}
3003
+ {release.healthSummary &&
3004
+ release.healthSummary !== release.healthIssue ? (
3005
+ <p className="text-xs leading-relaxed text-theme-text-secondary">
3006
+ {release.healthSummary}
3007
+ </p>
3008
+ ) : null}
3009
+ {operation ? (
3010
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
3011
+ <div className="flex flex-wrap items-center gap-1.5">
3012
+ <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
3013
+ Last operation
3014
+ </span>
3015
+ <Badge tone="note" size="sm">
3016
+ {operation.kind.replaceAll("_", " ")}
3017
+ </Badge>
3018
+ <Badge
3019
+ severity={
3020
+ operation.status === "completed"
3021
+ ? "success"
3022
+ : operation.status === "failed"
3023
+ ? "error"
3024
+ : "warning"
3025
+ }
3026
+ size="sm"
3027
+ >
3028
+ {operation.status.replaceAll("_", " ")}
3029
+ </Badge>
3030
+ </div>
3031
+ {operation.message ? (
3032
+ <p className="mt-1 text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3033
+ {operation.message}
3034
+ </p>
3035
+ ) : null}
3036
+ </div>
3037
+ ) : null}
3038
+ {release.description ? (
3039
+ <p className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
3040
+ {release.description}
3041
+ </p>
3042
+ ) : null}
3043
+ {release.storageNamespace &&
3044
+ release.storageNamespace !== release.namespace ? (
3045
+ <p className="text-xs text-theme-text-tertiary">
3046
+ Release metadata stored in namespace {release.storageNamespace}
3047
+ </p>
3048
+ ) : null}
3049
+ {release.managedByFluxHelmRelease ? (
3050
+ <p className="text-xs text-theme-text-tertiary">
3051
+ Managed by Flux HelmRelease {release.managedByFluxHelmRelease}
3052
+ </p>
3053
+ ) : null}
3054
+ {release.resources.length > 0 ? (
3055
+ <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
3056
+ {release.resources.map((owned, index) => (
3057
+ <div
3058
+ key={`${owned.kind}-${owned.namespace}-${owned.name}`}
3059
+ className={clsx(
3060
+ "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
3061
+ index > 0 && "border-t border-theme-border/60",
3062
+ )}
3063
+ >
3064
+ <StatusDot
3065
+ tone={mapHealthToTone(
3066
+ owned.issue ? "unhealthy" : (owned.status ?? ""),
3067
+ )}
3068
+ className="shrink-0"
3069
+ />
3070
+ <Badge tone="structural" size="sm">
3071
+ {owned.kind}
3072
+ </Badge>
3073
+ <span className="min-w-0 flex-1">
3074
+ <span className="block truncate font-mono text-xs">
3075
+ <ResourceLink
3076
+ name={owned.name}
3077
+ kind={owned.kind}
3078
+ namespace={owned.namespace}
3079
+ group={
3080
+ owned.apiVersion
3081
+ ? apiVersionToGroup(owned.apiVersion)
3082
+ : undefined
3083
+ }
3084
+ label={`${owned.namespace ? `${owned.namespace}/` : ""}${owned.name}`}
3085
+ onNavigate={
3086
+ owned.apiVersion && onOpenResource
3087
+ ? (ref) => onOpenResource(ref)
3088
+ : undefined
3089
+ }
3090
+ />
3091
+ </span>
3092
+ {owned.issue ? (
3093
+ <span className="block truncate text-xs text-warning-text">
3094
+ {owned.issue}
3095
+ </span>
3096
+ ) : owned.summary || owned.message ? (
3097
+ <span className="block truncate text-xs text-theme-text-tertiary">
3098
+ {owned.summary || owned.message}
3099
+ </span>
3100
+ ) : null}
3101
+ </span>
3102
+ {owned.ready || owned.status ? (
3103
+ <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
3104
+ {owned.ready || owned.status}
3105
+ </span>
3106
+ ) : null}
3107
+ </div>
3108
+ ))}
3109
+ </div>
3110
+ ) : null}
3111
+ </div>
3112
+ );
3113
+ }
3114
+
3115
+ function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
3116
+ const { subject, accessCheck } = data;
3117
+ const subjectBadges = (
3118
+ <>
3119
+ <Badge tone="structural" size="sm">
3120
+ {subject.kind}
3121
+ </Badge>
3122
+ <span className="font-mono text-xs text-theme-text-secondary">
3123
+ {subject.namespace ? `${subject.namespace}/` : ""}
3124
+ {subject.name}
3125
+ </span>
3126
+ </>
3127
+ );
3128
+ if (accessCheck) {
3129
+ const facts = [
3130
+ ["Verb", accessCheck.verb],
3131
+ ["Resource", accessCheck.resource],
3132
+ ["Subresource", accessCheck.subresource],
3133
+ ["API group", accessCheck.group || "core"],
3134
+ ["Namespace", accessCheck.namespace || "cluster-wide"],
3135
+ ["Name", accessCheck.resourceName],
3136
+ ] as const;
3137
+ return (
3138
+ <div className="space-y-2.5">
3139
+ <div className="flex flex-wrap items-center gap-1.5">
3140
+ {subjectBadges}
3141
+ <Badge
3142
+ severity={accessCheck.allowed ? "success" : "warning"}
3143
+ size="sm"
3144
+ >
3145
+ {accessCheck.allowed
3146
+ ? "allowed"
3147
+ : accessCheck.denied
3148
+ ? "denied"
3149
+ : "not allowed"}
3150
+ </Badge>
3151
+ </div>
3152
+ <dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
3153
+ {facts.map(([label, value]) => (
3154
+ <ResourceFact key={label} label={label} value={value} />
3155
+ ))}
3156
+ </dl>
3157
+ {accessCheck.reason ? (
3158
+ <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3159
+ {accessCheck.reason}
3160
+ </p>
3161
+ ) : null}
3162
+ {accessCheck.evaluationError ? (
3163
+ <p className="text-xs leading-relaxed text-semantic-error">
3164
+ {accessCheck.evaluationError}
3165
+ </p>
3166
+ ) : null}
3167
+ </div>
3168
+ );
3169
+ }
3170
+ const bindings = data.bindings ?? [];
3171
+ const usedByPods = data.usedByPods ?? [];
3172
+ return (
3173
+ <div className="space-y-2.5">
3174
+ <div className="flex flex-wrap items-center gap-1.5">
3175
+ {subjectBadges}
3176
+ <Badge tone="note" size="sm">
3177
+ {data.flatRulesCount ?? 0}
3178
+ {data.truncated ? "+" : ""} effective rules
3179
+ </Badge>
3180
+ {data.truncated ? (
3181
+ <Badge severity="warning" size="sm">
3182
+ rule list truncated
3183
+ </Badge>
3184
+ ) : null}
3185
+ </div>
3186
+ {bindings.length > 0 ? (
3187
+ <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
3188
+ {bindings.map((binding, index) => (
3189
+ <div
3190
+ key={`${binding.bindingKind}-${binding.bindingNamespace ?? ""}-${binding.bindingName}`}
3191
+ className={clsx(
3192
+ "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs",
3193
+ index > 0 && "border-t border-theme-border/60",
3194
+ )}
3195
+ >
3196
+ <Badge tone="structural" size="sm">
3197
+ {binding.bindingKind}
3198
+ </Badge>
3199
+ <span className="min-w-0 truncate font-mono text-theme-text-secondary">
3200
+ {binding.bindingNamespace ? `${binding.bindingNamespace}/` : ""}
3201
+ {binding.bindingName}
3202
+ </span>
3203
+ <span className="text-theme-text-tertiary">→</span>
3204
+ <Badge tone="structural" size="sm">
3205
+ {binding.roleKind}
3206
+ </Badge>
3207
+ <span className="min-w-0 truncate font-mono text-theme-text-primary">
3208
+ {binding.roleNamespace ? `${binding.roleNamespace}/` : ""}
3209
+ {binding.roleName}
3210
+ </span>
3211
+ <span className="ml-auto shrink-0 font-mono text-theme-text-tertiary">
3212
+ {binding.rulesCount} rule{binding.rulesCount === 1 ? "" : "s"}
3213
+ </span>
3214
+ {binding.inheritedFromGroup ? (
3215
+ <Badge tone="note" size="sm">
3216
+ via {binding.inheritedFromGroup}
3217
+ </Badge>
3218
+ ) : null}
3219
+ </div>
3220
+ ))}
3221
+ </div>
3222
+ ) : (
3223
+ <p className="text-xs italic text-theme-text-tertiary">
3224
+ No RoleBinding or ClusterRoleBinding grants this subject anything.
3225
+ </p>
3226
+ )}
3227
+ {usedByPods.length > 0 ? (
3228
+ <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3229
+ <span className="text-theme-text-tertiary">Used by pods: </span>
3230
+ <span className="font-mono">{usedByPods.join(", ")}</span>
3231
+ {data.podsTotal && data.podsTotal > usedByPods.length
3232
+ ? ` and ${data.podsTotal - usedByPods.length} more`
3233
+ : ""}
3234
+ </p>
3235
+ ) : null}
3236
+ </div>
3237
+ );
3238
+ }
3239
+
2006
3240
  function RevisionHistory({
2007
3241
  observations,
2008
3242
  citedOrder,
3243
+ caseItems = [],
2009
3244
  reveal,
2010
3245
  revealRequestId,
2011
3246
  onViewSource,
2012
3247
  }: {
2013
3248
  observations: InvestigationEvidenceObservation[];
2014
3249
  citedOrder?: number;
3250
+ /** Agent items bound to a superseded observation of this group. */
3251
+ caseItems?: InvestigationCaseItem[];
2015
3252
  reveal: boolean;
2016
3253
  revealRequestId?: number;
2017
3254
  onViewSource: (
@@ -2021,6 +3258,7 @@ function RevisionHistory({
2021
3258
  }) {
2022
3259
  const [open, setOpen] = useState(false);
2023
3260
  const regionId = useId();
3261
+ const { metricsMarkersBySource } = useContext(EvidenceNavigationContext);
2024
3262
  const { elementRef, revealAfterToggle } =
2025
3263
  useDisclosureReveal<HTMLDivElement>();
2026
3264
  useLayoutEffect(() => {
@@ -2044,41 +3282,73 @@ function RevisionHistory({
2044
3282
  <div id={regionId} ref={elementRef}>
2045
3283
  <Collapse open={open}>
2046
3284
  <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)}
3285
+ {observations.map((observation) => {
3286
+ const key = investigationCaseObservationKey(observation);
3287
+ const rowItems = caseItems.filter(
3288
+ (item) =>
3289
+ item.observation &&
3290
+ investigationCaseObservationKey(item.observation) === key,
3291
+ );
3292
+ const rowRoles = [...new Set(rowItems.map((item) => item.role))];
3293
+ return (
3294
+ <li
3295
+ key={`${observation.source.id}-${observation.revision}`}
3296
+ data-case-observation={rowItems.length > 0 ? key : undefined}
3297
+ className="flex min-w-0 items-start gap-2 text-xs"
3298
+ >
3299
+ <span className="mt-0.5 shrink-0 text-theme-text-tertiary">
3300
+ <span className="font-medium text-theme-text-secondary">
3301
+ {observation.source.order === citedOrder
3302
+ ? "Used for assessment"
3303
+ : phaseLabel(observation.source.phase)}
3304
+ </span>
2057
3305
  </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
- ))}
3306
+ <div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
3307
+ {rowRoles.length > 0 ? (
3308
+ <p className="flex flex-wrap items-center gap-1.5">
3309
+ <span>{observation.summary || observation.title}</span>
3310
+ {rowRoles.map((role) => (
3311
+ <AgentRoleChip key={role} role={role} />
3312
+ ))}
3313
+ </p>
3314
+ ) : (
3315
+ <p>{observation.summary || observation.title}</p>
3316
+ )}
3317
+ {rowItems
3318
+ .filter((item) => item.claim)
3319
+ .map((item) => (
3320
+ <AgentClaimNote
3321
+ key={item.index}
3322
+ claim={item.claim}
3323
+ role={rowRoles.length > 1 ? item.role : undefined}
3324
+ className="pt-1"
3325
+ />
3326
+ ))}
3327
+ {evidenceHasDetails(
3328
+ observation.data,
3329
+ observation.summary,
3330
+ ) && (
3331
+ <EvidenceBody
3332
+ data={observation.data}
3333
+ cardSummary={observation.summary}
3334
+ changeCoverage={metricsMarkersBySource?.get(
3335
+ observation.source.id,
3336
+ )}
3337
+ />
3338
+ )}
3339
+ </div>
3340
+ <SourceButton
3341
+ ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
3342
+ onClick={() =>
3343
+ onViewSource(
3344
+ observation.source.id,
3345
+ evidenceSourceExcerpt(observation.data),
3346
+ )
3347
+ }
3348
+ />
3349
+ </li>
3350
+ );
3351
+ })}
2082
3352
  </ol>
2083
3353
  </Collapse>
2084
3354
  </div>
@@ -2087,11 +3357,9 @@ function RevisionHistory({
2087
3357
  }
2088
3358
 
2089
3359
  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") {
3360
+ // Changes get a tooltip rather than a sentence because the wording depends
3361
+ // on whether the reported age was collected with the change.
3362
+ if (data.type === "changes") {
2095
3363
  return (
2096
3364
  <Tooltip
2097
3365
  content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
@@ -2108,10 +3376,8 @@ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
2108
3376
  </button>
2109
3377
  </Tooltip>
2110
3378
  );
2111
- } else if (data.type === "relationships" || data.type === "topology") {
2112
- text =
2113
- "This shows direct relationships Radar found, not an inferred blast radius.";
2114
3379
  }
3380
+ const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
2115
3381
  if (!text) return null;
2116
3382
  return (
2117
3383
  <p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
@@ -2184,33 +3450,7 @@ function SourceButton({
2184
3450
  }
2185
3451
 
2186
3452
  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
- }
3453
+ return EVIDENCE_KIND_TRAITS[type].icon;
2214
3454
  }
2215
3455
 
2216
3456
  function severityBadge(value: string) {
@@ -2244,6 +3484,13 @@ function toneBorder(
2244
3484
  prominence: "primary" | "supporting" | "secondary",
2245
3485
  ): string {
2246
3486
  if (prominence !== "primary") return "border-theme-border/70";
3487
+ // A supporting adverse card is what the healthy-conflict banner points at,
3488
+ // so it must be tellable from its neutral neighbours: a thin rule, lighter
3489
+ // than the Key tier's.
3490
+ if (tier === "supporting" && tone === "error")
3491
+ return "border-l-2 border-l-semantic-error border-theme-border";
3492
+ if (tier === "supporting" && (tone === "warning" || tone === "alert"))
3493
+ return "border-l-2 border-l-semantic-warning border-theme-border";
2247
3494
  if (tier !== "key") return "border-theme-border";
2248
3495
  if (tone === "error")
2249
3496
  return "border-l-[3px] border-l-red-500 border-theme-border";