@skyhook-io/radar-app 1.14.2 → 1.14.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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. package/src/components/ui/Disclosure.tsx +0 -47
@@ -1,366 +1,61 @@
1
1
  import {
2
- createContext,
3
2
  useCallback,
4
- useContext,
5
3
  useEffect,
6
- useId,
7
4
  useLayoutEffect,
8
5
  useMemo,
9
6
  useRef,
10
7
  useState,
11
8
  type ReactNode,
12
9
  } from "react";
13
- import { clsx } from "clsx";
14
- import {
15
- EVIDENCE_KIND_TRAITS,
16
- evidenceKindIsFocused,
17
- } from "./investigationEvidenceKinds";
18
- import {
19
- Activity,
20
- AlertTriangle,
21
- CircleAlert,
22
- FileClock,
23
- FileSearch,
24
- Info,
25
- SearchCheck,
26
- SquareArrowOutUpRight,
27
- } from "lucide-react";
28
- import {
29
- Badge,
30
- Collapse,
31
- CollapseChevron,
32
- DiffViewer,
33
- StatusDot,
34
- TerminalBlock,
35
- TerminalBlockLabel,
36
- defaultConditionTone,
37
- displayKind,
38
- formatRelativeAgeTime,
39
- HPADiagnosisSummary,
40
- mapHealthToTone,
41
- ResourceLink,
42
- stripAnsi,
43
- } from "@skyhook-io/k8s-ui";
44
- import {
45
- AreaChart,
46
- SERIES_COLORS,
47
- SeriesLegend,
48
- formatMetricValue,
49
- seriesDisplayLabels,
50
- seriesFill,
51
- type TimeSeries,
52
- } from "@skyhook-io/k8s-ui/components/charts";
53
- import { apiVersionToGroup } from "../../utils/navigation";
54
10
  import {
55
11
  AnalysisStory,
56
12
  resolveStoryPlacements,
57
13
  type StoryPlacementLoss,
58
14
  type StoryPlacementTarget,
59
15
  } from "./AnalysisStory";
16
+ import type { DiagnosisEvidenceItem } from "../../api/diagnose";
17
+ import { metricsChangeCoverage } from "./investigationMetrics";
18
+ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
60
19
  import type {
61
- DiagnosisEvidenceItem,
62
- DiagnosisEvidenceSubject,
63
- } from "../../api/diagnose";
64
- import { parseLogLine } from "../../utils/log-format";
65
- import {
66
- metricsChangeCoverage,
67
- metricsDomain,
68
- type MetricsChangeCoverage,
69
- } from "./investigationMetrics";
70
- import {
71
- evidenceDisplaySnapshot,
72
- groupEvidenceCoverage,
73
- type EvidenceCoverageGroup,
74
- } from "./investigationEvidencePresentation";
75
-
76
- import {
77
- investigationEvidenceSubjectRef,
78
- investigationEvidenceSourceDomId,
79
- investigationSourceArgs,
80
- type InvestigationEvidenceData,
81
- type InvestigationEvidenceGroup,
82
- type InvestigationResourceSummary,
83
- type InvestigationEvidenceObservation,
84
- type InvestigationEvidenceProjection,
85
- type InvestigationRootCauseEvidenceResolution,
86
- type InvestigationEvidenceSource,
87
- type InvestigationEvidenceTier,
20
+ InvestigationEvidenceProjection,
21
+ InvestigationRootCauseEvidenceResolution,
88
22
  } from "./investigationEvidence";
89
- import type {
90
- DiagnosisResourceContext,
91
- DiagnosisResourceRef,
92
- DiagnosisScalerRef,
93
- } from "./diagnoseEvidenceTypes";
94
- import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
95
- import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
23
+ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
96
24
  import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
97
- import { evidenceSourceExcerpt } from "./investigationSourceFocus";
98
- import { Tooltip } from "../ui/Tooltip";
99
- import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
100
- import {
101
- investigationCaseObservationKey,
102
- type InvestigationCaseItem,
103
- type InvestigationCaseResolution,
104
- sameKind,
25
+ import type {
26
+ InvestigationCaseItem,
27
+ InvestigationCaseResolution,
105
28
  } from "./investigationCase";
106
- import type { DiagnosisEvidenceRole } from "../../api/diagnose";
107
-
108
29
  import {
109
30
  investigationDisclosureSettleDelay,
110
31
  prefersReducedMotion,
111
- useDisclosureReveal,
112
32
  } from "./useDisclosureReveal";
33
+ import {
34
+ EvidenceNavigationContext,
35
+ type InvestigationTimelineScope,
36
+ } from "./investigationEvidence/navigation";
37
+ import {
38
+ investigationCaseByGroup,
39
+ investigationCaseItemKey,
40
+ investigationEvidenceRevealCollection,
41
+ partitionInvestigationEvidence,
42
+ } from "./investigationEvidencePartition";
43
+ import { EvidenceCard } from "./EvidenceCard";
44
+ import {
45
+ AssessmentEvidenceQualification,
46
+ CollapsedEvidenceCollection,
47
+ CoverageStrip,
48
+ EmptyCollection,
49
+ RuledOutBlock,
50
+ } from "./InvestigationEvidenceSections";
51
+
113
52
  export {
114
53
  INVESTIGATION_DISCLOSURE_SETTLE_MS,
115
54
  investigationDisclosureSettleDelay,
116
55
  investigationDisclosureScrollTop,
117
56
  } from "./useDisclosureReveal";
118
- export const VISIBLE_LOG_EVIDENCE_LINES = 12;
119
-
120
- /** The Timeline scope a changes card can open: one resource name in one namespace. */
121
- export interface InvestigationTimelineScope {
122
- namespace?: string;
123
- name: string;
124
- }
125
-
126
- const EvidenceNavigationContext = createContext<{
127
- onOpenResource?: (ref: DiagnosisResourceRef) => void;
128
- onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
129
- revealSourceId?: string;
130
- revealRequestId?: number;
131
- expandedGroupIds?: ReadonlySet<string>;
132
- onGroupOpenChange?: (id: string, open: boolean) => void;
133
- citedOrderByGroup?: ReadonlyMap<string, number>;
134
- /** Change markers for each metrics observation, keyed by its source id. */
135
- metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
136
- /** Card- and revision-placed agent items, keyed by group id. */
137
- caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
138
- /** The hypothesis each `rules_out` item excludes, keyed by item. */
139
- excludedByItem?: ReadonlyMap<string, string>;
140
- }>({});
141
-
142
- /** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
143
- export function investigationCaseItemKey(item: InvestigationCaseItem): string {
144
- return `${item.source.id}\u0000${item.index}`;
145
- }
146
-
147
- const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
148
- cause: 0,
149
- symptom: 1,
150
- // A ruled-out card keeps Radar's place in the list; the role only labels it.
151
- rules_out: 2,
152
- context: 3,
153
- benign: 4,
154
- demoted: 5,
155
- };
156
- const UNLABELLED_RANK = 2;
157
-
158
- function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
159
- const args = investigationSourceArgs(source);
160
- return (
161
- typeof args?.kind === "string" &&
162
- args.kind !== "" &&
163
- typeof args.name === "string" &&
164
- args.name !== ""
165
- );
166
- }
167
-
168
- export function investigationCaseByGroup(
169
- investigationCase?: InvestigationCaseResolution,
170
- ): Map<string, InvestigationCaseItem[]> {
171
- const byGroup = new Map<string, InvestigationCaseItem[]>();
172
- for (const item of investigationCase?.items ?? []) {
173
- if (!item.groupId || item.placement === "source") continue;
174
- const items = byGroup.get(item.groupId) ?? [];
175
- items.push(item);
176
- byGroup.set(item.groupId, items);
177
- }
178
- return byGroup;
179
- }
180
-
181
- function evidenceTypePrefersFullRow(
182
- type: InvestigationEvidenceData["type"],
183
- ): boolean {
184
- return EVIDENCE_KIND_TRAITS[type].fullRow;
185
- }
186
57
 
187
- // Supporting evidence becomes a two-column grid when the pane is wide enough.
188
- // Keep compact cards paired only with an adjacent compact card. Otherwise a
189
- // full-row card between them strands a conspicuous empty half-row (and makes the
190
- // visual order look accidental), as does an odd card at the end of a run.
191
- export function investigationEvidenceFullRowFlags(
192
- types: readonly InvestigationEvidenceData["type"][],
193
- ): boolean[] {
194
- const fullRow = types.map(evidenceTypePrefersFullRow);
195
- let compactRunStart = 0;
196
-
197
- for (let index = 0; index <= types.length; index += 1) {
198
- if (index < types.length && !fullRow[index]) continue;
199
- const compactRunLength = index - compactRunStart;
200
- if (compactRunLength % 2 === 1) fullRow[index - 1] = true;
201
- compactRunStart = index + 1;
202
- }
203
-
204
- return fullRow;
205
- }
206
-
207
- type EvidenceCollection = "main" | "workload" | "earlier";
208
-
209
- export function partitionInvestigationEvidence(
210
- groups: InvestigationEvidenceGroup[],
211
- resolution?: InvestigationRootCauseEvidenceResolution,
212
- investigationCase?: InvestigationCaseResolution,
213
- /**
214
- * Under a story every captured result about the target belongs in the one
215
- * inventory the reader opens to see what Radar recorded; nothing about the
216
- * target is folded into a second tier. Broader results stay cited-only.
217
- */
218
- inventory = false,
219
- ) {
220
- // Selection: legacy root-cause links plus every placed agent item, of any
221
- // role (placement promotes a group into main the way a citation does; the
222
- // role does not matter here). Ordering below is the only place roles act,
223
- // and nothing the agent sends can remove a group from a collection.
224
- const caseByGroup = investigationCaseByGroup(investigationCase);
225
- const selected = new Set([
226
- ...(resolution?.status === "linked"
227
- ? resolution.links.map((link) => link.originalGroupId)
228
- : []),
229
- ...caseByGroup.keys(),
230
- ]);
231
- const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
232
- {
233
- main: [],
234
- workload: [],
235
- earlier: [],
236
- };
237
- const collectionByGroup = new Map<string, EvidenceCollection>();
238
- const adverse = (group: InvestigationEvidenceGroup) =>
239
- ["error", "alert", "warning"].includes(group.latest.tone);
240
- // Broader cards are facts about something other than the target. They are
241
- // withheld unless cited, and the pane says how many were withheld so a
242
- // reader knows the agent looked at things it did not build its case on.
243
- let hiddenBroader = 0;
244
- // Broader metrics are facts about something other than the target. They are
245
- // withheld unless cited, and the pane says how many were withheld so a
246
- // reader knows the agent ran queries it did not build its case on.
247
- let hiddenMetrics = 0;
248
- for (const group of groups) {
249
- const broader = group.latest.relevance === "broader";
250
- // Citations select tool results, not individual rows in a broad search.
251
- // Only a focused, unambiguous fact can be promoted by a source citation.
252
- // An agent item names this observation when its subject does, or when the
253
- // call it cites was itself scoped to one resource; a subject-less item on
254
- // a broad query only proves the query returned one row and is gated like
255
- // a citation of its source.
256
- const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
257
- (item) => item.subject || sourceNamesOneResource(item.source),
258
- );
259
- if (broader && !namedByAgent) {
260
- const citingSource =
261
- resolution?.links.find((item) => item.originalGroupId === group.id)
262
- ?.source ?? caseByGroup.get(group.id)?.[0]?.source;
263
- const focused = evidenceKindIsFocused(group.latest.data.type);
264
- const sourceGroups = citingSource
265
- ? groups.filter(
266
- (candidate) =>
267
- evidenceKindIsFocused(candidate.latest.data.type) &&
268
- candidate.observations.some(
269
- (observation) => observation.source.id === citingSource.id,
270
- ),
271
- )
272
- : [];
273
- if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
274
- // The two counts head separate lines in the pane, so they have to be
275
- // disjoint: a withheld chart announced by both would read as two
276
- // withheld results.
277
- if (!group.historical) {
278
- if (group.latest.data.type === "metrics") hiddenMetrics += 1;
279
- else hiddenBroader += 1;
280
- }
281
- continue;
282
- }
283
- }
284
- const main =
285
- !group.historical &&
286
- (selected.has(group.id) ||
287
- (!broader &&
288
- (inventory ||
289
- group.latest.tier === "key" ||
290
- (group.latest.tier === "supporting" && adverse(group)))));
291
- const collection = main
292
- ? "main"
293
- : group.historical
294
- ? "earlier"
295
- : "workload";
296
- collections[collection].push(group);
297
- collectionByGroup.set(group.id, collection);
298
- }
299
- // A group takes its strongest role (lowest rank); within one role the
300
- // agent's own item order decides. Unlabelled and ruled-out groups keep
301
- // Radar's order among themselves.
302
- const roleOrder = (group: InvestigationEvidenceGroup) => {
303
- let rank: number | undefined;
304
- let itemIndex = Number.POSITIVE_INFINITY;
305
- for (const item of caseByGroup.get(group.id) ?? []) {
306
- const itemRank = EVIDENCE_ROLE_RANK[item.role];
307
- if (rank === undefined || itemRank < rank) {
308
- rank = itemRank;
309
- itemIndex = item.index;
310
- } else if (itemRank === rank) {
311
- itemIndex = Math.min(itemIndex, item.index);
312
- }
313
- }
314
- if (rank === undefined || rank === UNLABELLED_RANK) {
315
- return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
316
- }
317
- return { rank, itemIndex };
318
- };
319
- collections.main.sort((left, right) => {
320
- const leftRole = roleOrder(left);
321
- const rightRole = roleOrder(right);
322
- const agentOrder =
323
- Number.isFinite(leftRole.itemIndex) ||
324
- Number.isFinite(rightRole.itemIndex)
325
- ? leftRole.itemIndex - rightRole.itemIndex
326
- : 0;
327
- return (
328
- leftRole.rank - rightRole.rank ||
329
- agentOrder ||
330
- Number(right.latest.tier === "key") -
331
- Number(left.latest.tier === "key") ||
332
- Number(adverse(right)) - Number(adverse(left)) ||
333
- left.firstOrder - right.firstOrder
334
- );
335
- });
336
- // A healthy workload's collection opens with several "nothing here"
337
- // receipts; the cards that carry facts (its vitals above all) come first,
338
- // and the receipts keep their order among themselves.
339
- collections.workload.sort(
340
- (left, right) =>
341
- Number(left.kind === "receipt") - Number(right.kind === "receipt"),
342
- );
343
- return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
344
- }
345
-
346
- export function investigationEvidenceRevealCollection(
347
- projection: InvestigationEvidenceProjection,
348
- sourceId: string,
349
- partition = partitionInvestigationEvidence(projection.groups),
350
- ): Exclude<EvidenceCollection, "main"> | "coverage" | undefined {
351
- const source = projection.sources.find((item) => item.id === sourceId);
352
- const collection = source?.primaryGroupId
353
- ? partition.collectionByGroup.get(source.primaryGroupId)
354
- : undefined;
355
- if (collection) return collection === "main" ? undefined : collection;
356
- if (
357
- projection.limitations.some((limitation) =>
358
- limitation.sources.some((source) => source.id === sourceId),
359
- )
360
- )
361
- return "coverage";
362
- return undefined;
363
- }
58
+ export type { InvestigationTimelineScope } from "./investigationEvidence/navigation";
364
59
 
365
60
  export function InvestigationEvidencePane({
366
61
  projection,
@@ -905,11 +600,9 @@ export function InvestigationEvidencePane({
905
600
  </span>
906
601
  ) : null}
907
602
  </div>
908
- {story ? (
603
+ {story && storyPlacements ? (
909
604
  <AnalysisStory
910
- report={story.report}
911
- resolveItem={resolveStoryItem}
912
- resolveRef={resolveStoryRef}
605
+ placements={storyPlacements}
913
606
  trailing={
914
607
  visibleRuledOut.length > 0 ? (
915
608
  <RuledOutBlock
@@ -1032,3110 +725,3 @@ export function InvestigationEvidencePane({
1032
725
  </EvidenceNavigationContext.Provider>
1033
726
  );
1034
727
  }
1035
-
1036
- /**
1037
- * Hypotheses the agent dropped, each pointing at the Radar observation whose
1038
- * result contradicted it. Entries whose item could not be placed on an
1039
- * observation are filtered out by the resolver and never rendered here.
1040
- */
1041
- function RuledOutBlock({
1042
- entries,
1043
- onReveal,
1044
- }: {
1045
- entries: InvestigationCaseResolution["ruledOut"];
1046
- onReveal: (item: InvestigationCaseItem) => void;
1047
- }) {
1048
- const headingId = useId();
1049
- return (
1050
- <section
1051
- aria-labelledby={headingId}
1052
- data-testid="investigation-ruled-out"
1053
- className="rounded-lg border border-theme-border/80 bg-theme-base/20 px-3 py-2.5"
1054
- >
1055
- <h3
1056
- id={headingId}
1057
- className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
1058
- >
1059
- Also checked
1060
- <span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
1061
- Agent
1062
- </span>
1063
- </h3>
1064
- <ul className="mt-1.5 space-y-1.5">
1065
- {entries.map((entry, position) => {
1066
- const observation = entry.item.observation!;
1067
- return (
1068
- <li
1069
- key={position}
1070
- className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs"
1071
- >
1072
- <span className="min-w-0 text-theme-text-secondary [overflow-wrap:anywhere]">
1073
- {entry.hypothesis}
1074
- </span>
1075
- <button
1076
- type="button"
1077
- onClick={() => onReveal(entry.item)}
1078
- className="shrink-0 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
1079
- >
1080
- See {observation.title}
1081
- {entry.item.placement === "revision"
1082
- ? " (earlier observation)"
1083
- : ""}
1084
- </button>
1085
- </li>
1086
- );
1087
- })}
1088
- </ul>
1089
- </section>
1090
- );
1091
- }
1092
-
1093
- function AssessmentEvidenceQualification({
1094
- resolution,
1095
- onViewActivity,
1096
- }: {
1097
- resolution: InvestigationRootCauseEvidenceResolution;
1098
- onViewActivity: () => void;
1099
- }) {
1100
- return (
1101
- <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5">
1102
- <AlertTriangle
1103
- className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
1104
- aria-hidden
1105
- />
1106
- <div className="min-w-0 flex-1">
1107
- <p className="text-xs font-medium text-theme-text-primary">
1108
- {resolution.status === "invalid"
1109
- ? "Assessment references could not be matched"
1110
- : "Assessment does not cite specific Radar evidence"}
1111
- </p>
1112
- <p className="mt-0.5 text-xs leading-relaxed text-theme-text-tertiary">
1113
- {resolution.status === "invalid"
1114
- ? "Radar could not match the assessment’s references to this investigation. Review Activity before acting."
1115
- : "Review Activity and the Radar evidence below before acting on the agent’s conclusion."}
1116
- </p>
1117
- </div>
1118
- <button
1119
- type="button"
1120
- onClick={onViewActivity}
1121
- 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"
1122
- >
1123
- View Activity
1124
- </button>
1125
- </div>
1126
- );
1127
- }
1128
-
1129
- function EmptyCollection({
1130
- collecting,
1131
- hasEarlierEvidence,
1132
- onViewActivity,
1133
- }: {
1134
- collecting: boolean;
1135
- hasEarlierEvidence: boolean;
1136
- onViewActivity: () => void;
1137
- }) {
1138
- return (
1139
- <div className="rounded-lg border border-dashed border-theme-border px-4 py-5 text-center">
1140
- {collecting ? (
1141
- <Activity className="mx-auto h-5 w-5 text-accent" aria-hidden />
1142
- ) : (
1143
- <SearchCheck
1144
- className="mx-auto h-5 w-5 text-theme-text-tertiary"
1145
- aria-hidden
1146
- />
1147
- )}
1148
- <p className="mt-2 text-sm font-medium text-theme-text-secondary">
1149
- {collecting
1150
- ? "Evidence will appear here"
1151
- : hasEarlierEvidence
1152
- ? "No current evidence was captured during verification"
1153
- : "No relevant evidence to show yet"}
1154
- </p>
1155
- <p className="mx-auto mt-1 max-w-md text-xs leading-relaxed text-theme-text-tertiary">
1156
- {collecting
1157
- ? "The Activity pane remains the live record while the agent investigates."
1158
- : hasEarlierEvidence
1159
- ? "Earlier observations remain below. This does not prove that those conditions resolved."
1160
- : "Investigation details are still available in Activity. This does not mean the resource is healthy."}
1161
- </p>
1162
- <button
1163
- type="button"
1164
- onClick={onViewActivity}
1165
- 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"
1166
- >
1167
- View Activity
1168
- </button>
1169
- </div>
1170
- );
1171
- }
1172
-
1173
- function CollapsedEvidenceCollection({
1174
- id,
1175
- title,
1176
- description,
1177
- groups,
1178
- animateGroupIds,
1179
- onViewSource,
1180
- open,
1181
- onOpenChange,
1182
- totalCount = groups.length,
1183
- keepWhenEmpty = false,
1184
- children,
1185
- }: {
1186
- id: string;
1187
- title: string;
1188
- description: string;
1189
- groups: InvestigationEvidenceGroup[];
1190
- animateGroupIds: ReadonlySet<string>;
1191
- onViewSource: (
1192
- sourceId: string,
1193
- excerpt?: InvestigationSourceExcerpt,
1194
- ) => void;
1195
- open: boolean;
1196
- onOpenChange: (open: boolean) => void;
1197
- totalCount?: number;
1198
- /** Render at zero: the children carry the empty state and the withheld-results note. */
1199
- keepWhenEmpty?: boolean;
1200
- children?: ReactNode;
1201
- }) {
1202
- const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
1203
- if (totalCount === 0 && !keepWhenEmpty) return null;
1204
- const fullRowFlags = investigationEvidenceFullRowFlags(
1205
- groups.map((group) => group.latest.data.type),
1206
- );
1207
- return (
1208
- <section
1209
- ref={elementRef}
1210
- className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
1211
- >
1212
- <button
1213
- type="button"
1214
- aria-expanded={open}
1215
- aria-controls={id}
1216
- onClick={() => {
1217
- onOpenChange(!open);
1218
- revealAfterToggle(!open);
1219
- }}
1220
- 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"
1221
- >
1222
- <span className="min-w-0 flex-1">
1223
- <span className="block text-xs font-semibold text-theme-text-secondary">
1224
- {title}
1225
- </span>
1226
- {description ? (
1227
- <span className="block text-xs text-theme-text-tertiary">
1228
- {description}
1229
- </span>
1230
- ) : null}
1231
- </span>
1232
- <span className="shrink-0 font-mono text-xs text-theme-text-tertiary">
1233
- {totalCount}
1234
- </span>
1235
- <CollapseChevron open={open} className="h-4 w-4" />
1236
- </button>
1237
- <div id={id}>
1238
- <Collapse open={open}>
1239
- <div
1240
- className={clsx(
1241
- "grid items-start gap-2.5 @min-[760px]/evidence:grid-cols-2",
1242
- "border-t border-theme-border/60 p-2.5",
1243
- )}
1244
- >
1245
- {groups.map((group, index) => (
1246
- <EvidenceCard
1247
- key={group.id}
1248
- group={group}
1249
-
1250
- animateArrival={animateGroupIds.has(group.id)}
1251
- onViewSource={onViewSource}
1252
- spanFullRow={fullRowFlags[index]}
1253
- prominence="secondary"
1254
- />
1255
- ))}
1256
- {children ? <div className="col-span-full">{children}</div> : null}
1257
- </div>
1258
- </Collapse>
1259
- </div>
1260
- </section>
1261
- );
1262
- }
1263
-
1264
- function CoverageStrip({
1265
- groups,
1266
- visibleGroupIds,
1267
- summary,
1268
- onViewSource,
1269
- open,
1270
- onOpenChange,
1271
- }: {
1272
- groups: EvidenceCoverageGroup[];
1273
- visibleGroupIds: ReadonlySet<string>;
1274
- summary: string;
1275
- onViewSource: (
1276
- sourceId: string,
1277
- excerpt?: InvestigationSourceExcerpt,
1278
- ) => void;
1279
- open: boolean;
1280
- onOpenChange: (open: boolean) => void;
1281
- }) {
1282
- const hasError = groups.some((group) => group.hasError);
1283
- const historyOnly = groups.every((group) => group.historyOnly);
1284
- const quiet = historyOnly;
1285
- const { elementRef, revealAfterToggle } =
1286
- useDisclosureReveal<HTMLDivElement>();
1287
- const regionId = "investigation-evidence-coverage";
1288
- const anchoredSources = new Set<string>();
1289
- return (
1290
- <div
1291
- ref={elementRef}
1292
- className="overflow-hidden rounded-lg border border-theme-border/80 bg-theme-base/20"
1293
- >
1294
- <button
1295
- type="button"
1296
- aria-expanded={open}
1297
- aria-controls={regionId}
1298
- onClick={() => {
1299
- onOpenChange(!open);
1300
- revealAfterToggle(!open);
1301
- }}
1302
- className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-theme-hover/50"
1303
- >
1304
- {quiet ? (
1305
- <Info
1306
- className="h-4 w-4 shrink-0 text-theme-text-tertiary"
1307
- aria-hidden
1308
- />
1309
- ) : hasError ? (
1310
- <CircleAlert className="h-4 w-4 shrink-0 text-red-400" aria-hidden />
1311
- ) : (
1312
- <AlertTriangle
1313
- className="h-4 w-4 shrink-0 text-amber-500"
1314
- aria-hidden
1315
- />
1316
- )}
1317
- <span className="min-w-0 flex-1">
1318
- <span
1319
- className={clsx(
1320
- "block text-xs",
1321
- quiet
1322
- ? "font-medium text-theme-text-secondary"
1323
- : "font-semibold text-theme-text-primary",
1324
- )}
1325
- >
1326
- {historyOnly
1327
- ? "Change history is limited"
1328
- : "Evidence coverage is incomplete"}
1329
- </span>
1330
- {!historyOnly && !open ? (
1331
- <span className="line-clamp-2 text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
1332
- {summary}
1333
- </span>
1334
- ) : null}
1335
- </span>
1336
- <CollapseChevron open={open} className="h-4 w-4" />
1337
- </button>
1338
- <div id={regionId}>
1339
- <Collapse open={open}>
1340
- <div className="divide-y divide-theme-border/60 border-t border-theme-border/60">
1341
- {groups.map((group) => {
1342
- const sourceIds = group.limitations
1343
- .flatMap((item) => item.sources)
1344
- .filter((source) => {
1345
- if (
1346
- (source.primaryGroupId &&
1347
- visibleGroupIds.has(source.primaryGroupId)) ||
1348
- anchoredSources.has(source.id)
1349
- )
1350
- return false;
1351
- anchoredSources.add(source.id);
1352
- return true;
1353
- })
1354
- .map((source) => source.id);
1355
- return (
1356
- <CoverageGroupRow
1357
- key={group.label}
1358
- group={group}
1359
- sourceIds={sourceIds}
1360
- onViewSource={onViewSource}
1361
- />
1362
- );
1363
- })}
1364
- </div>
1365
- </Collapse>
1366
- </div>
1367
- </div>
1368
- );
1369
- }
1370
-
1371
- function CoverageGroupRow({
1372
- group,
1373
- sourceIds,
1374
- onViewSource,
1375
- }: {
1376
- group: EvidenceCoverageGroup;
1377
- sourceIds: string[];
1378
- onViewSource: (
1379
- sourceId: string,
1380
- excerpt?: InvestigationSourceExcerpt,
1381
- ) => void;
1382
- }) {
1383
- const [open, setOpen] = useState(false);
1384
- const regionId = useId();
1385
- const { elementRef, revealAfterToggle } =
1386
- useDisclosureReveal<HTMLDivElement>();
1387
- // One limitation whose message is the whole summary would repeat itself as
1388
- // its own detail row; show its source link on the summary row instead.
1389
- const single =
1390
- group.limitations.length === 1 &&
1391
- group.limitations[0].message === group.summary
1392
- ? group.limitations[0]
1393
- : undefined;
1394
- const anchors = sourceIds.map((id) => (
1395
- <span
1396
- key={id}
1397
- id={investigationEvidenceSourceDomId(id)}
1398
- className="sr-only scroll-mt-14"
1399
- aria-hidden
1400
- />
1401
- ));
1402
- if (single) {
1403
- return (
1404
- <div
1405
- ref={elementRef}
1406
- data-evidence-source-container
1407
- tabIndex={-1}
1408
- aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
1409
- className="flex items-center gap-2 px-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-accent/40"
1410
- >
1411
- {anchors}
1412
- {group.hasError ? (
1413
- <CircleAlert
1414
- className="h-3.5 w-3.5 shrink-0 text-red-400"
1415
- aria-hidden
1416
- />
1417
- ) : single.kind === "truncated" ? (
1418
- <AlertTriangle
1419
- className="h-3.5 w-3.5 shrink-0 text-amber-500"
1420
- aria-hidden
1421
- />
1422
- ) : (
1423
- <Info
1424
- className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1425
- aria-hidden
1426
- />
1427
- )}
1428
- <p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
1429
- <span className="font-medium text-theme-text-primary">
1430
- {group.label}:
1431
- </span>{" "}
1432
- {group.summary}
1433
- </p>
1434
- {single.sources.at(-1) ? (
1435
- <SourceButton
1436
- ariaLabel={`View result for ${group.label}`}
1437
- buttonLabel={
1438
- single.sources.length > 1 ? "View latest in Activity" : undefined
1439
- }
1440
- onClick={() => onViewSource(single.sources.at(-1)!.id)}
1441
- />
1442
- ) : null}
1443
- </div>
1444
- );
1445
- }
1446
- return (
1447
- <div
1448
- ref={elementRef}
1449
- data-evidence-source-container
1450
- tabIndex={-1}
1451
- aria-label={`Evidence limitation for ${group.label}: ${group.summary}`}
1452
- className="outline-none focus:ring-2 focus:ring-accent/40"
1453
- >
1454
- {anchors}
1455
- <button
1456
- type="button"
1457
- aria-expanded={open}
1458
- aria-controls={regionId}
1459
- onClick={() => {
1460
- setOpen(!open);
1461
- revealAfterToggle(!open);
1462
- }}
1463
- className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs hover:bg-theme-hover/50"
1464
- >
1465
- {group.hasError ? (
1466
- <CircleAlert
1467
- className="h-3.5 w-3.5 shrink-0 text-red-400"
1468
- aria-hidden
1469
- />
1470
- ) : (
1471
- <Info
1472
- className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1473
- aria-hidden
1474
- />
1475
- )}
1476
- <span className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
1477
- <span className="font-medium text-theme-text-primary">
1478
- {group.label}:{" "}
1479
- </span>
1480
- {group.summary}
1481
- </span>
1482
- <CollapseChevron open={open} className="h-3.5 w-3.5 shrink-0" />
1483
- </button>
1484
- <div id={regionId}>
1485
- <Collapse open={open}>
1486
- <ul className="space-y-2 border-t border-theme-border/60 px-3 py-2.5">
1487
- {group.limitations.map((limitation, index) => (
1488
- <li
1489
- key={index}
1490
- className="flex min-w-0 items-start gap-2 text-xs"
1491
- >
1492
- {limitation.kind === "error" ? (
1493
- <CircleAlert
1494
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400"
1495
- aria-hidden
1496
- />
1497
- ) : limitation.kind === "truncated" ? (
1498
- <AlertTriangle
1499
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500"
1500
- aria-hidden
1501
- />
1502
- ) : (
1503
- <Info
1504
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1505
- aria-hidden
1506
- />
1507
- )}
1508
- <p className="min-w-0 flex-1 leading-relaxed text-theme-text-secondary">
1509
- <span className="font-medium text-theme-text-primary">
1510
- {limitation.source}:
1511
- </span>{" "}
1512
- {limitation.message}
1513
- </p>
1514
- {limitation.sources.at(-1) ? (
1515
- <SourceButton
1516
- ariaLabel={`View result for ${limitation.source}`}
1517
- buttonLabel={
1518
- limitation.sources.length > 1
1519
- ? "View latest in Activity"
1520
- : undefined
1521
- }
1522
- onClick={() => onViewSource(limitation.sources.at(-1)!.id)}
1523
- />
1524
- ) : null}
1525
- </li>
1526
- ))}
1527
- </ul>
1528
- </Collapse>
1529
- </div>
1530
- </div>
1531
- );
1532
- }
1533
-
1534
- function previousDifferentObservations(
1535
- group: InvestigationEvidenceGroup,
1536
- citedOrder?: number,
1537
- boundOrders: ReadonlySet<number> = new Set(),
1538
- ) {
1539
- const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
1540
- const pinned = (observation: InvestigationEvidenceObservation) =>
1541
- observation.source.order === citedOrder ||
1542
- boundOrders.has(observation.source.order);
1543
- return [...group.observations].reverse().filter((observation) => {
1544
- if (observation === group.latest) return false;
1545
- // An observation bound to an agent item always keeps its own row: display
1546
- // equivalence is a folding rule for unpinned rechecks, never a reason to
1547
- // move a claim onto different content.
1548
- if (boundOrders.has(observation.source.order)) return true;
1549
- // A pinned observation wins over an unpinned twin with the same display
1550
- // content.
1551
- if (
1552
- !pinned(observation) &&
1553
- group.observations.some(
1554
- (other) =>
1555
- pinned(other) &&
1556
- evidenceDisplaySnapshot(other) ===
1557
- evidenceDisplaySnapshot(observation),
1558
- )
1559
- )
1560
- return false;
1561
- const snapshot = evidenceDisplaySnapshot(observation);
1562
- if (seen.has(snapshot)) return false;
1563
- seen.add(snapshot);
1564
- return true;
1565
- });
1566
- }
1567
-
1568
- export function investigationEvidenceShouldRevealHistory(
1569
- group: InvestigationEvidenceGroup,
1570
- sourceId?: string,
1571
- /** Sources whose observation carries an agent item and so always has a row. */
1572
- boundSourceIds: ReadonlySet<string> = new Set(),
1573
- ): boolean {
1574
- return (
1575
- Boolean(sourceId) &&
1576
- group.latest.source.id !== sourceId &&
1577
- group.observations.some(
1578
- (observation) =>
1579
- observation.source.id === sourceId &&
1580
- (boundSourceIds.has(observation.source.id) ||
1581
- evidenceDisplaySnapshot(observation) !==
1582
- evidenceDisplaySnapshot(group.latest)),
1583
- )
1584
- );
1585
- }
1586
-
1587
- function EvidenceCard({
1588
- group,
1589
- observation: observationOverride,
1590
- domId = group.id,
1591
- animateArrival,
1592
- onViewSource,
1593
- spanFullRow = false,
1594
- prominence = "primary",
1595
- compact = false,
1596
- placedInStory = false,
1597
- gapRoles,
1598
- noteMode = "full",
1599
- }: {
1600
- group: InvestigationEvidenceGroup;
1601
- /**
1602
- * Render this exact observation instead of the group's current one — a
1603
- * story places the read the agent cited, which may since have been
1604
- * superseded, and says so rather than swapping in today's.
1605
- */
1606
- observation?: InvestigationEvidenceObservation;
1607
- /** Stable layout/scroll identity when an existing card changes section. */
1608
- domId?: string;
1609
- animateArrival: boolean;
1610
- onViewSource: (
1611
- sourceId: string,
1612
- excerpt?: InvestigationSourceExcerpt,
1613
- ) => void;
1614
- /** Fill both columns when this card has no compact row partner. */
1615
- spanFullRow?: boolean;
1616
- prominence?: "primary" | "supporting" | "secondary";
1617
- /**
1618
- * Header and summary only, so the collapsed story preview never slices a
1619
- * card. A story log card keeps its cited lines: they are the card.
1620
- */
1621
- compact?: boolean;
1622
- /** This card also appears inside the story above. */
1623
- placedInStory?: boolean;
1624
- /** Roles whose "does not cover" line is shown; undefined shows every gap. */
1625
- gapRoles?: ReadonlySet<string>;
1626
- /**
1627
- * Inside the story the prose above the card is the agent's reading of it,
1628
- * so the note row keeps only the role chip (and an excluded hypothesis);
1629
- * repeating the sentence under the card doubled the text for nothing.
1630
- */
1631
- noteMode?: "full" | "chip";
1632
- }) {
1633
- const {
1634
- onOpenResource,
1635
- onOpenTimeline,
1636
- revealSourceId,
1637
- revealRequestId,
1638
- citedOrderByGroup,
1639
- expandedGroupIds,
1640
- onGroupOpenChange,
1641
- metricsMarkersBySource,
1642
- caseByGroup,
1643
- excludedByItem,
1644
- } = useContext(EvidenceNavigationContext);
1645
- const open = expandedGroupIds?.has(group.id) ?? false;
1646
- const setOpen = useCallback(
1647
- (value: boolean) => onGroupOpenChange?.(group.id, value),
1648
- [onGroupOpenChange, group.id],
1649
- );
1650
- const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
1651
- const observation = observationOverride ?? group.latest;
1652
- const supersededRead =
1653
- observationOverride !== undefined && observationOverride !== group.latest;
1654
- const displaySummary =
1655
- observation.data.type === "crash" && observation.summary
1656
- ? parseLogLine(observation.summary).content
1657
- : observation.summary;
1658
- const citedOrder = citedOrderByGroup?.get(group.id);
1659
- const caseItems = caseByGroup?.get(group.id) ?? [];
1660
- // Annotations belong to the observation on screen: a card rendering an
1661
- // earlier read shows the note the agent attached to that read, not the
1662
- // latest one's.
1663
- const cardItems = caseItems.filter((item) =>
1664
- observationOverride
1665
- ? item.observation !== undefined &&
1666
- item.observation.source.id === observation.source.id &&
1667
- item.observation.revision === observation.revision
1668
- : item.placement === "card",
1669
- );
1670
- const revisionItems = caseItems.filter(
1671
- (item) => item.placement === "revision",
1672
- );
1673
- const agentCause = cardItems.some((item) => item.role === "cause");
1674
- // A card placed in the story: the prose carries the claim, so the card
1675
- // shows its role beside the title, its scope line if any, and for a log
1676
- // stream the decisive lines themselves; the rest waits behind the chevron.
1677
- const storyCard = noteMode === "chip";
1678
- const storyGaps = storyCard
1679
- ? cardItems
1680
- .map((item) =>
1681
- item.gap && (!gapRoles || gapRoles.has(item.role)) ? item.gap : "",
1682
- )
1683
- .filter(Boolean)
1684
- : [];
1685
- // A compact card keeps its single row; the agent's clause on what the
1686
- // result does not cover rides on it after the status, cut to the row, whole
1687
- // on hover. The clause is written to stand alone, so it carries no label.
1688
- const gapOnRow = compact && storyGaps.length > 0;
1689
- // What a rules_out card excludes is the point of placing it; it stays on
1690
- // the story card as its one line.
1691
- const storyExcludes = storyCard
1692
- ? cardItems
1693
- .filter((item) => item.role === "rules_out")
1694
- .map((item) => excludedByItem?.get(investigationCaseItemKey(item)))
1695
- .filter((entry): entry is string => Boolean(entry))
1696
- : [];
1697
- const previousObservations = previousDifferentObservations(
1698
- group,
1699
- citedOrder,
1700
- new Set(revisionItems.map((item) => item.observation!.source.order)),
1701
- );
1702
- const meaningfulHistory = previousObservations.length > 0;
1703
- const citedObservation = group.observations.find(
1704
- (item) => item.source.order === citedOrder,
1705
- );
1706
- const differsFromAssessment =
1707
- citedObservation &&
1708
- evidenceDisplaySnapshot(citedObservation) !==
1709
- evidenceDisplaySnapshot(observation);
1710
- const bodyId = `${domId}-body`;
1711
- const hasEvidenceDetails = evidenceHasDetails(
1712
- observation.data,
1713
- observation.summary,
1714
- );
1715
- const storyLogLines =
1716
- storyCard && observation.data.type === "logs"
1717
- ? (observation.data.logs?.lines ?? [])
1718
- .slice(-VISIBLE_LOG_EVIDENCE_LINES)
1719
- .map((line) => stripAnsi(line))
1720
- : [];
1721
- // A story log card shows the newest two selected lines always — the ones a
1722
- // story cites from a tail. Expanding appends the earlier lines below them,
1723
- // under a divider, so nothing above them moves.
1724
- const storyLogHead = storyLogLines.slice(-2);
1725
- const storyLogRest = storyLogLines.slice(0, -2);
1726
- // A story log card carries its cited lines inline, so the only thing left to
1727
- // expand is the earlier lines; the record keeps the full logs body.
1728
- const inlineLogs = storyCard && observation.data.type === "logs";
1729
- // A chart placed as the cause or the symptom is the card: it shows inline,
1730
- // and the fold has nothing left to add.
1731
- const inlineMetrics =
1732
- storyCard &&
1733
- !compact &&
1734
- observation.data.type === "metrics" &&
1735
- cardItems.some((item) => item.role === "cause" || item.role === "symptom");
1736
- // Each row a citation names is the part of the listing the sentence rests
1737
- // on; an entry the listing does not hold is often the point, so the card
1738
- // says so rather than showing another row.
1739
- const storyInventoryRows =
1740
- storyCard && !compact && observation.data.type === "inventory"
1741
- ? namedInventoryRows(
1742
- observation.data.resources,
1743
- cardItems,
1744
- listingScopeNamespace(observation.source),
1745
- )
1746
- : [];
1747
- const hasExpandableBody =
1748
- (hasEvidenceDetails && !inlineLogs && !inlineMetrics) || meaningfulHistory;
1749
- const canExpand = !compact && (hasExpandableBody || storyLogLines.length > 2);
1750
- const revealHistory = investigationEvidenceShouldRevealHistory(
1751
- group,
1752
- revealSourceId,
1753
- new Set(revisionItems.map((item) => item.observation!.source.id)),
1754
- );
1755
- useLayoutEffect(() => {
1756
- const destination = revealSourceId
1757
- ? document.getElementById(
1758
- investigationEvidenceSourceDomId(revealSourceId),
1759
- )
1760
- : null;
1761
- if (
1762
- revealHistory ||
1763
- (destination && elementRef.current?.contains(destination))
1764
- )
1765
- setOpen(true);
1766
- }, [revealHistory, revealSourceId, revealRequestId, elementRef, setOpen]);
1767
- const wide = spanFullRow || evidenceTypePrefersFullRow(observation.data.type);
1768
- const resourceRef = investigationEvidenceSubjectRef(observation.data);
1769
- const resourceIdentity = resourceRef
1770
- ? `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`
1771
- : undefined;
1772
- const primarySources = uniquePrimarySources(group);
1773
- const inlineSecretKeys =
1774
- observation.data.type === "resource" &&
1775
- observation.data.resource.kind.toLowerCase() === "secret" &&
1776
- !canExpand;
1777
- const headerContent = (
1778
- <>
1779
- <EvidenceIcon observation={observation} prominence={prominence} />
1780
- <span className="min-w-0 flex-1">
1781
- <span className="flex flex-wrap items-center gap-1.5">
1782
- <span className="text-sm font-semibold leading-snug text-theme-text-primary">
1783
- {observation.title}
1784
- </span>
1785
- {storyCard
1786
- ? [...new Set(cardItems.map((item) => item.role))].map((role) =>
1787
- role ? <AgentRoleChip key={role} role={role} /> : null,
1788
- )
1789
- : null}
1790
- </span>
1791
- {observation.relevance === "broader" &&
1792
- resourceRef &&
1793
- resourceIdentity &&
1794
- !observation.title.includes(resourceIdentity) ? (
1795
- <span className="mt-0.5 block text-xs text-theme-text-secondary">
1796
- {resourceIdentity} · {resourceRef.kind}
1797
- </span>
1798
- ) : null}
1799
- {gapOnRow ? (
1800
- <Tooltip
1801
- content={storyGaps.join(" · ")}
1802
- wrapperClassName="mt-0.5 w-full min-w-0"
1803
- >
1804
- <span className="min-w-0 flex-1 truncate text-xs leading-relaxed text-theme-text-secondary">
1805
- {displaySummary}
1806
- {displaySummary ? " · " : null}
1807
- <span data-agent-gap-hint className="text-theme-text-tertiary">
1808
- {storyGaps.join(" · ")}
1809
- </span>
1810
- </span>
1811
- </Tooltip>
1812
- ) : displaySummary ? (
1813
- <span
1814
- className={clsx(
1815
- "mt-0.5 block text-xs leading-relaxed text-theme-text-secondary",
1816
- !open && !inlineSecretKeys && "line-clamp-2",
1817
- "[overflow-wrap:anywhere]",
1818
- )}
1819
- >
1820
- {displaySummary}
1821
- </span>
1822
- ) : null}
1823
- {supersededRead ? (
1824
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1825
- Captured in turn {observation.source.turnIndex + 1} · a newer read
1826
- of this exists in Captured results
1827
- </span>
1828
- ) : placedInStory ? (
1829
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1830
- In the analysis
1831
- </span>
1832
- ) : group.historical ? (
1833
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1834
- Previous observation · not confirmed by the latest check
1835
- </span>
1836
- ) : differsFromAssessment &&
1837
- citedOrder != null &&
1838
- observation.source.order !== citedOrder ? (
1839
- <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1840
- {observation.source.order > citedOrder
1841
- ? "Observed after the assessment’s source"
1842
- : "Earlier observation retained from a more direct source"}
1843
- </span>
1844
- ) : null}
1845
- </span>
1846
- {canExpand ? (
1847
- <CollapseChevron open={open} className="h-4 w-4 self-center" />
1848
- ) : null}
1849
- </>
1850
- );
1851
- return (
1852
- <article
1853
- data-evidence-source={observation.source.id}
1854
- ref={elementRef}
1855
- id={domId}
1856
- data-evidence-card
1857
- tabIndex={-1}
1858
- aria-label={`${observation.title} evidence`}
1859
- className={clsx(
1860
- "@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",
1861
- "rounded-lg border",
1862
- // The card the agent calls the cause sorts first and is often the
1863
- // calmest thing on screen: a cause is frequently a Secret or a
1864
- // ConfigMap that Radar has no reason to colour, while every card
1865
- // echoing the failure below it carries red. Lifting the surface marks
1866
- // it without borrowing a severity hue, and without touching the left
1867
- // rule (Radar's severity) or the ring (the source you are viewing).
1868
- agentCause ? "bg-theme-elevated shadow-theme-sm" : "bg-theme-surface",
1869
- toneBorder(observation.tone, observation.tier, prominence),
1870
- wide && "@min-[760px]/evidence:col-span-2",
1871
- animateArrival && "animate-transcript-enter",
1872
- )}
1873
- >
1874
- {primarySources.map((source) => (
1875
- <span
1876
- key={source.id}
1877
- id={investigationEvidenceSourceDomId(source.id)}
1878
- className="block scroll-mt-14"
1879
- aria-hidden
1880
- />
1881
- ))}
1882
- <div className="flex min-w-0 items-stretch">
1883
- {canExpand ? (
1884
- <button
1885
- type="button"
1886
- aria-expanded={open}
1887
- aria-controls={bodyId}
1888
- onClick={() => {
1889
- const opening = !open;
1890
- setOpen(opening);
1891
- revealAfterToggle(opening);
1892
- }}
1893
- className={clsx(
1894
- "flex min-w-0 flex-1 items-start text-left hover:bg-theme-hover/60",
1895
- prominence === "primary"
1896
- ? "gap-2.5 px-3 py-2.5"
1897
- : "gap-2 px-2.5 py-2",
1898
- )}
1899
- >
1900
- {headerContent}
1901
- </button>
1902
- ) : (
1903
- <div
1904
- className={clsx(
1905
- "flex min-w-0 flex-1 items-start text-left",
1906
- prominence === "primary"
1907
- ? "gap-2.5 px-3 py-2.5"
1908
- : "gap-2 px-2.5 py-2",
1909
- )}
1910
- >
1911
- {headerContent}
1912
- </div>
1913
- )}
1914
- <div className="flex shrink-0 items-center gap-0.5 px-2">
1915
- {observation.data.type === "changes" ? (
1916
- <EvidenceCaveat data={observation.data} />
1917
- ) : null}
1918
- <SourceButton
1919
- ariaLabel={`View result for ${observation.title}`}
1920
- compact
1921
- onClick={() =>
1922
- onViewSource(
1923
- observation.source.id,
1924
- evidenceSourceExcerpt(observation.data),
1925
- )
1926
- }
1927
- />
1928
- <OpenResourceButton
1929
- resourceRef={resourceRef}
1930
- onOpenResource={onOpenResource}
1931
- compact
1932
- />
1933
- {observation.data.type === "changes" && observation.data.subject ? (
1934
- <OpenTimelineButton
1935
- scope={observation.data.subject}
1936
- onOpenTimeline={onOpenTimeline}
1937
- />
1938
- ) : null}
1939
- </div>
1940
- </div>
1941
- {storyCard ? (
1942
- <>
1943
- {(storyGaps.length > 0 || storyExcludes.length > 0) && !compact ? (
1944
- <div
1945
- className={clsx(
1946
- "space-y-0.5 text-[11px] text-theme-text-tertiary",
1947
- prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
1948
- )}
1949
- >
1950
- {storyExcludes.map((hypothesis) => (
1951
- <p key={hypothesis} data-agent-excludes>
1952
- Rules out: {hypothesis}
1953
- </p>
1954
- ))}
1955
- {storyGaps.map((gap) => (
1956
- <p key={gap} data-agent-gap>
1957
- {gap}
1958
- </p>
1959
- ))}
1960
- </div>
1961
- ) : null}
1962
- {storyLogHead.length > 0 ? (
1963
- <div
1964
- data-story-log-lines
1965
- className={
1966
- prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
1967
- }
1968
- >
1969
- <TerminalBlock
1970
- footer={
1971
- storyLogRest.length > 0 ? (
1972
- <Collapse open={open && canExpand}>
1973
- <TerminalBlockLabel divider>
1974
- Earlier lines
1975
- </TerminalBlockLabel>
1976
- <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 pb-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
1977
- {storyLogRest.join("\n")}
1978
- </pre>
1979
- </Collapse>
1980
- ) : null
1981
- }
1982
- >
1983
- {storyLogHead.join("\n")}
1984
- </TerminalBlock>
1985
- </div>
1986
- ) : null}
1987
- {storyInventoryRows.length > 0 ? (
1988
- <div
1989
- data-story-inventory-rows
1990
- className={
1991
- prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
1992
- }
1993
- >
1994
- <div className="rounded-md border border-theme-border">
1995
- {storyInventoryRows.map((entry, index) =>
1996
- entry.row ? (
1997
- <InventoryRow
1998
- key={entry.key}
1999
- resource={entry.row}
2000
- divider={index > 0}
2001
- />
2002
- ) : (
2003
- <p
2004
- key={entry.key}
2005
- data-story-inventory-absent={
2006
- entry.matches === 0 ? "" : undefined
2007
- }
2008
- className={clsx(
2009
- "px-2.5 py-1.5 font-mono text-xs text-theme-text-secondary",
2010
- index > 0 && "border-t border-theme-border/60",
2011
- )}
2012
- >
2013
- {entry.matches === 0
2014
- ? `Not in this listing: ${entry.label}`
2015
- : `${entry.label}: ${entry.matches} entries match in this listing`}
2016
- </p>
2017
- ),
2018
- )}
2019
- </div>
2020
- </div>
2021
- ) : null}
2022
- {inlineMetrics && observation.data.type === "metrics" ? (
2023
- <div
2024
- data-story-metrics
2025
- className={
2026
- prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
2027
- }
2028
- >
2029
- <MetricsBody
2030
- data={observation.data}
2031
- changeCoverage={metricsMarkersBySource?.get(
2032
- observation.source.id,
2033
- )}
2034
- />
2035
- </div>
2036
- ) : null}
2037
- </>
2038
- ) : cardItems.some((item) => item.claim || item.role) ? (
2039
- <div
2040
- className={clsx(
2041
- "space-y-1.5",
2042
- prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
2043
- )}
2044
- >
2045
- {cardItems.map((item) => (
2046
- <AgentClaimNote
2047
- key={item.index}
2048
- claim={compact ? "" : item.claim}
2049
- role={item.role}
2050
- gap={
2051
- compact || (gapRoles && !gapRoles.has(item.role))
2052
- ? undefined
2053
- : item.gap
2054
- }
2055
- excludes={excludedByItem?.get(investigationCaseItemKey(item))}
2056
- className="pt-1.5"
2057
- />
2058
- ))}
2059
- </div>
2060
- ) : null}
2061
- {canExpand && hasExpandableBody ? (
2062
- <div id={bodyId}>
2063
- <Collapse open={open}>
2064
- <div
2065
- className={clsx(
2066
- "space-y-3 border-t border-theme-border/60",
2067
- prominence === "primary" ? "px-3 py-3" : "px-2.5 py-2.5",
2068
- )}
2069
- >
2070
- {hasEvidenceDetails && !inlineLogs && !inlineMetrics ? (
2071
- <EvidenceBody
2072
- data={observation.data}
2073
- condensed={storyCard}
2074
- cardSummary={observation.summary}
2075
- changeCoverage={metricsMarkersBySource?.get(
2076
- observation.source.id,
2077
- )}
2078
- />
2079
- ) : null}
2080
- {meaningfulHistory ? (
2081
- <RevisionHistory
2082
- observations={previousObservations}
2083
- citedOrder={citedOrder}
2084
- caseItems={revisionItems}
2085
- reveal={revealHistory}
2086
- revealRequestId={revealRequestId}
2087
- onViewSource={onViewSource}
2088
- />
2089
- ) : null}
2090
- {observation.data.type !== "changes" ? (
2091
- <EvidenceCaveat data={observation.data} />
2092
- ) : null}
2093
- </div>
2094
- </Collapse>
2095
- </div>
2096
- ) : null}
2097
- </article>
2098
- );
2099
- }
2100
-
2101
- function OpenResourceButton({
2102
- resourceRef,
2103
- onOpenResource,
2104
- compact = false,
2105
- }: {
2106
- resourceRef?: DiagnosisResourceRef;
2107
- onOpenResource?: (ref: DiagnosisResourceRef) => void;
2108
- compact?: boolean;
2109
- }) {
2110
- if (!resourceRef || !onOpenResource) return null;
2111
- const identity = `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`;
2112
- const label = `Open current ${displayKind(resourceRef.kind)} ${identity} in Radar`;
2113
- return (
2114
- <Tooltip
2115
- content={label}
2116
- delay={350}
2117
- position="left"
2118
- wrapperClassName="flex shrink-0"
2119
- >
2120
- <button
2121
- type="button"
2122
- aria-label={label}
2123
- onClick={() => onOpenResource(resourceRef)}
2124
- className={clsx(
2125
- "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",
2126
- "gap-1 px-2 text-xs font-medium",
2127
- )}
2128
- >
2129
- {compact ? null : (
2130
- <span>Open current {displayKind(resourceRef.kind)}</span>
2131
- )}
2132
- <SquareArrowOutUpRight className="h-3.5 w-3.5" aria-hidden />
2133
- </button>
2134
- </Tooltip>
2135
- );
2136
- }
2137
-
2138
- function OpenTimelineButton({
2139
- scope,
2140
- onOpenTimeline,
2141
- }: {
2142
- scope: InvestigationTimelineScope;
2143
- onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
2144
- }) {
2145
- if (!onOpenTimeline) return null;
2146
- const identity = `${scope.namespace ? `${scope.namespace}/` : ""}${scope.name}`;
2147
- const label = `Open the Timeline for ${identity}`;
2148
- return (
2149
- <Tooltip
2150
- content={label}
2151
- delay={350}
2152
- position="left"
2153
- wrapperClassName="flex shrink-0"
2154
- >
2155
- <button
2156
- type="button"
2157
- aria-label={label}
2158
- onClick={() => onOpenTimeline(scope)}
2159
- 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"
2160
- >
2161
- <FileClock className="h-3.5 w-3.5" aria-hidden />
2162
- <span className="hidden @min-[540px]/card:inline">Timeline</span>
2163
- </button>
2164
- </Tooltip>
2165
- );
2166
- }
2167
-
2168
- function uniquePrimarySources(
2169
- group: InvestigationEvidenceGroup,
2170
- ): InvestigationEvidenceSource[] {
2171
- // One tool call can contribute repeated revisions to the same semantic group.
2172
- // It still owns one navigation destination, and DOM ids must remain unique.
2173
- const sources = new Map<string, InvestigationEvidenceSource>();
2174
- for (const observation of group.observations) {
2175
- const { source } = observation;
2176
- if (source.primaryGroupId === group.id && !sources.has(source.id)) {
2177
- sources.set(source.id, source);
2178
- }
2179
- }
2180
- return [...sources.values()];
2181
- }
2182
-
2183
- function EvidenceBody({
2184
- data,
2185
- cardSummary,
2186
- changeCoverage,
2187
- condensed = false,
2188
- }: {
2189
- data: InvestigationEvidenceData;
2190
- cardSummary?: string;
2191
- /** Change markers for a metrics chart; derived by the pane, never by data. */
2192
- changeCoverage?: MetricsChangeCoverage;
2193
- /** The card's title already names the stream: skip the repeated badges. */
2194
- condensed?: boolean;
2195
- }) {
2196
- switch (data.type) {
2197
- case "issue":
2198
- return <IssueBody data={data} cardSummary={cardSummary} />;
2199
- case "startup":
2200
- return <StartupBody data={data} />;
2201
- case "crash":
2202
- return <CrashBody data={data} />;
2203
- case "resource":
2204
- return <ResourceBody data={data} />;
2205
- case "logs":
2206
- return <LogsBody data={data} condensed={condensed} />;
2207
- case "events":
2208
- return <EventsBody data={data} />;
2209
- case "changes":
2210
- return <ChangesBody data={data} />;
2211
- case "dns":
2212
- return <DNSBody data={data} />;
2213
- case "network":
2214
- return <NetworkBody data={data} />;
2215
- case "relationships":
2216
- return <RelationshipsBody data={data} />;
2217
- case "topology":
2218
- return <TopologyBody data={data} />;
2219
- case "inventory":
2220
- return <InventoryBody data={data} />;
2221
- case "receipt":
2222
- // The title and the scope above it are the answer. A body appears only
2223
- // when it adds the reason or the limit, so an absent one renders nothing
2224
- // rather than an empty line.
2225
- return data.message ? (
2226
- <p className="text-xs text-theme-text-secondary">{data.message}</p>
2227
- ) : null;
2228
- case "alerts":
2229
- return <AlertsBody data={data} />;
2230
- case "helm":
2231
- return <HelmBody data={data} />;
2232
- case "permissions":
2233
- return <PermissionsBody data={data} />;
2234
- case "metrics":
2235
- return <MetricsBody data={data} changeCoverage={changeCoverage} />;
2236
- }
2237
- }
2238
-
2239
- function evidenceHasDetails(
2240
- data: InvestigationEvidenceData,
2241
- cardSummary?: string,
2242
- ): boolean {
2243
- switch (data.type) {
2244
- case "issue": {
2245
- const summary = cardSummary?.trim();
2246
- const cause = data.issue.cause?.trim();
2247
- const message = data.issue.message?.trim();
2248
- return Boolean(
2249
- (cause && cause !== summary) ||
2250
- (message && message !== summary && message !== cause) ||
2251
- data.pods?.length,
2252
- );
2253
- }
2254
- case "startup":
2255
- return (data.pods?.length ?? 0) > 1;
2256
- case "receipt":
2257
- return false;
2258
- case "resource": {
2259
- const replicas = data.resourceContext?.workloadSummary?.replicas;
2260
- return Boolean(
2261
- investigationResourceEvidenceHasDetails(data.resource) ||
2262
- replicas?.desired !== undefined ||
2263
- data.resourceContext?.statusSummary?.conditions?.length ||
2264
- diagnosedScalers(data.resourceContext).length ||
2265
- data.gitOpsDiagnosis ||
2266
- data.warnings.length,
2267
- );
2268
- }
2269
- case "logs":
2270
- return (data.logs?.lines?.length ?? 0) > 0 || Boolean(data.error);
2271
- case "changes":
2272
- return data.changes.length > 0 || Boolean(data.changeContext?.evidence);
2273
- default:
2274
- return true;
2275
- }
2276
- }
2277
-
2278
- type EvidenceDataOf<T extends InvestigationEvidenceData["type"]> = Extract<
2279
- InvestigationEvidenceData,
2280
- { type: T }
2281
- >;
2282
-
2283
- function IssueBody({
2284
- data,
2285
- cardSummary,
2286
- }: {
2287
- data: EvidenceDataOf<"issue">;
2288
- cardSummary?: string;
2289
- }) {
2290
- const issue = data.issue;
2291
- const showCause =
2292
- Boolean(issue.cause) && issue.cause?.trim() !== cardSummary?.trim();
2293
- const showMessage =
2294
- Boolean(issue.message) &&
2295
- issue.message?.trim() !== cardSummary?.trim() &&
2296
- issue.message?.trim() !== issue.cause?.trim();
2297
- return (
2298
- <div className="space-y-2">
2299
- <div className="flex flex-wrap gap-1.5">
2300
- <Badge
2301
- severity={issue.severity === "critical" ? "error" : "warning"}
2302
- size="sm"
2303
- >
2304
- {issue.severity}
2305
- </Badge>
2306
- <Badge tone="structural" size="sm">
2307
- {issue.kind}
2308
- </Badge>
2309
- <Badge tone="structural" size="sm">
2310
- {issue.namespace ? `${issue.namespace}/` : ""}
2311
- {issue.name}
2312
- </Badge>
2313
- {data.pods && data.pods.length > 0 ? (
2314
- <>
2315
- <Badge tone="structural" size="sm">
2316
- {data.pods.length === 1 ? "1 Pod" : `${data.pods.length} Pods`}
2317
- </Badge>
2318
- {data.pods.map((pod) => (
2319
- <Badge key={pod} tone="structural" size="sm">
2320
- {pod}
2321
- </Badge>
2322
- ))}
2323
- </>
2324
- ) : null}
2325
- </div>
2326
- {showCause ? (
2327
- <p className="text-sm font-medium leading-relaxed text-theme-text-primary">
2328
- {issue.cause}
2329
- </p>
2330
- ) : null}
2331
- {showMessage ? (
2332
- <p className="text-xs leading-relaxed text-theme-text-secondary">
2333
- {issue.message}
2334
- </p>
2335
- ) : null}
2336
- </div>
2337
- );
2338
- }
2339
-
2340
- function StartupBody({ data }: { data: EvidenceDataOf<"startup"> }) {
2341
- const blocker = data.blocker;
2342
- const pods = data.pods && data.pods.length > 1 ? data.pods : [blocker.name];
2343
- return (
2344
- <div className="space-y-2">
2345
- <div className="flex flex-wrap gap-1.5">
2346
- <Badge tone="structural" size="sm">
2347
- {pods.length > 1 ? `${pods.length} ${blocker.kind}s` : blocker.kind}
2348
- </Badge>
2349
- {pods.map((pod) => (
2350
- <Badge key={pod} tone="structural" size="sm">
2351
- {pod}
2352
- </Badge>
2353
- ))}
2354
- <Badge severity={severityBadge(blocker.severity)} size="sm">
2355
- {blocker.severity}
2356
- </Badge>
2357
- </div>
2358
- <p className="text-sm leading-relaxed text-theme-text-primary">
2359
- {blocker.message}
2360
- </p>
2361
- </div>
2362
- );
2363
- }
2364
-
2365
- function CrashBody({ data }: { data: EvidenceDataOf<"crash"> }) {
2366
- const crash = data.crash;
2367
- return (
2368
- <div className="space-y-2.5">
2369
- <div className="flex flex-wrap gap-1.5">
2370
- <Badge severity="error" size="sm">
2371
- {crash.reason || crash.state}
2372
- </Badge>
2373
- <Badge tone="structural" size="sm">
2374
- exit {crash.exitCode}
2375
- </Badge>
2376
- <Badge tone="structural" size="sm">
2377
- {crash.container}
2378
- </Badge>
2379
- </div>
2380
- <p className="text-xs text-theme-text-tertiary">
2381
- {crash.pods.join(", ")} · {crash.logSource.replaceAll("_", " ")}
2382
- </p>
2383
- <TerminalBlock label="Selected crash line">{crash.logLine}</TerminalBlock>
2384
- </div>
2385
- );
2386
- }
2387
-
2388
- function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
2389
- const replicas = data.resourceContext?.workloadSummary?.replicas;
2390
- // SealedSecret's dedicated body renders the resource's conditions beside
2391
- // its controller state, so repeating the derived summary here adds noise.
2392
- const conditions =
2393
- data.resource.kind.toLowerCase() === "sealedsecret"
2394
- ? []
2395
- : (data.resourceContext?.statusSummary?.conditions ?? []);
2396
- const desired = replicas?.desired;
2397
- const ready = replicas ? (replicas.ready ?? 0) : undefined;
2398
- const shortfall =
2399
- desired !== undefined && ready !== undefined && ready < desired;
2400
- const scalers = diagnosedScalers(data.resourceContext);
2401
- return (
2402
- <div className="space-y-3">
2403
- <InvestigationResourceEvidence resource={data.resource} />
2404
- {data.gitOpsDiagnosis ? (
2405
- <GitOpsStatusBody status={data.gitOpsDiagnosis} />
2406
- ) : null}
2407
- {desired !== undefined && ready !== undefined ? (
2408
- <dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
2409
- <div>
2410
- <dt className="text-theme-text-tertiary">Ready replicas</dt>
2411
- <dd
2412
- className={clsx(
2413
- "font-mono font-semibold tabular-nums",
2414
- shortfall ? "text-warning-text" : "text-theme-text-primary",
2415
- )}
2416
- >
2417
- {ready}/{desired}
2418
- </dd>
2419
- </div>
2420
- <ResourceFact label="Available" value={replicas?.available} />
2421
- <ResourceFact label="Updated" value={replicas?.updated} />
2422
- <ResourceFact label="Unavailable" value={replicas?.unavailable} />
2423
- <ResourceFact
2424
- label="Phase"
2425
- value={data.resourceContext?.statusSummary?.phase}
2426
- />
2427
- </dl>
2428
- ) : null}
2429
- {conditions.length > 0 ? (
2430
- <div>
2431
- <div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
2432
- Conditions
2433
- </div>
2434
- <div className="max-h-52 space-y-1.5 overflow-y-auto pr-1">
2435
- {conditions.map((condition) => (
2436
- <div
2437
- key={`${condition.type}-${condition.status}-${condition.reason ?? ""}`}
2438
- className="flex min-w-0 items-start gap-2 text-xs"
2439
- >
2440
- <StatusDot
2441
- tone={conditionStatusTone(condition)}
2442
- className="mt-1 shrink-0"
2443
- />
2444
- <p className="min-w-0 text-theme-text-secondary">
2445
- <span className="font-medium text-theme-text-primary">
2446
- {condition.type}={condition.status}
2447
- </span>
2448
- {condition.reason ? ` · ${condition.reason}` : ""}
2449
- {condition.message ? ` — ${condition.message}` : ""}
2450
- </p>
2451
- </div>
2452
- ))}
2453
- </div>
2454
- </div>
2455
- ) : null}
2456
- {scalers.length > 0 ? (
2457
- <ScaledBySection
2458
- scalers={scalers}
2459
- allScalers={data.resourceContext?.scaledBy ?? []}
2460
- />
2461
- ) : null}
2462
- {data.warnings.length > 0 ? (
2463
- <ul className="space-y-1 text-xs text-theme-text-secondary">
2464
- {data.warnings.map((warning) => (
2465
- <li key={warning} className="flex items-start gap-1.5">
2466
- <Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
2467
- <span>{warning}</span>
2468
- </li>
2469
- ))}
2470
- </ul>
2471
- ) : null}
2472
- </div>
2473
- );
2474
- }
2475
-
2476
- type DiagnosedScaler = DiagnosisScalerRef &
2477
- Required<Pick<DiagnosisScalerRef, "hpaSummary">>;
2478
-
2479
- function diagnosedScalers(
2480
- context: DiagnosisResourceContext | undefined,
2481
- ): DiagnosedScaler[] {
2482
- return (context?.scaledBy ?? []).filter(
2483
- (scaler): scaler is DiagnosedScaler => scaler.hpaSummary !== undefined,
2484
- );
2485
- }
2486
-
2487
- function ScaledBySection({
2488
- scalers,
2489
- allScalers,
2490
- }: {
2491
- scalers: DiagnosedScaler[];
2492
- allScalers: DiagnosisScalerRef[];
2493
- }) {
2494
- const { onOpenResource } = useContext(EvidenceNavigationContext);
2495
- // The ScaledObject is only a link when Radar listed it as a scaler too; a
2496
- // name read off the HPA's owner reference alone is not a resource we hold.
2497
- const listedScaler = (ref: DiagnosisResourceRef) =>
2498
- allScalers.some(
2499
- (scaler) =>
2500
- scaler.kind === ref.kind &&
2501
- scaler.name === ref.name &&
2502
- (scaler.namespace ?? "") === (ref.namespace ?? ""),
2503
- );
2504
- return (
2505
- <div>
2506
- <div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
2507
- Scaled by
2508
- </div>
2509
- <div className="space-y-2">
2510
- {scalers.map((scaler) => (
2511
- <HPADiagnosisSummary
2512
- key={`${scaler.namespace ?? ""}/${scaler.name}`}
2513
- diagnosis={scaler.hpaSummary}
2514
- variant="inline"
2515
- header={
2516
- <>
2517
- <span className="font-medium text-theme-text-primary">
2518
- {displayKind(scaler.kind)} {scaler.name}
2519
- </span>
2520
- {scaler.managedBy ? (
2521
- <span className="text-theme-text-tertiary">
2522
- managed by KEDA {displayKind(scaler.managedBy.kind)}{" "}
2523
- <ResourceLink
2524
- name={scaler.managedBy.name}
2525
- kind={scaler.managedBy.kind}
2526
- namespace={scaler.managedBy.namespace ?? ""}
2527
- group={scaler.managedBy.group}
2528
- onNavigate={
2529
- onOpenResource && listedScaler(scaler.managedBy)
2530
- ? (ref) => onOpenResource(ref)
2531
- : undefined
2532
- }
2533
- />
2534
- </span>
2535
- ) : null}
2536
- </>
2537
- }
2538
- />
2539
- ))}
2540
- </div>
2541
- </div>
2542
- );
2543
- }
2544
-
2545
- function GitOpsStatusBody({
2546
- status,
2547
- }: {
2548
- status: NonNullable<EvidenceDataOf<"resource">["gitOpsDiagnosis"]>;
2549
- }) {
2550
- const fields = [
2551
- ["Sync", status.sync],
2552
- ["Health", status.health],
2553
- ["Operation", status.operationPhase],
2554
- ["Ready", status.ready],
2555
- ] as const;
2556
- return (
2557
- <div className="rounded-md border border-theme-border bg-theme-base/40 p-2.5">
2558
- <div className="mb-2 flex flex-wrap items-center gap-1.5">
2559
- <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
2560
- GitOps controller status
2561
- </span>
2562
- <Badge tone="note" size="sm">
2563
- {status.tool === "argocd" ? "Argo CD" : "Flux"}
2564
- </Badge>
2565
- {status.suspended ? (
2566
- <Badge severity="info" size="sm">
2567
- Suspended
2568
- </Badge>
2569
- ) : null}
2570
- </div>
2571
- <div className="flex flex-wrap gap-1.5">
2572
- {fields.map(([label, value]) =>
2573
- value ? (
2574
- <Badge
2575
- key={label}
2576
- severity={gitOpsValueSeverity(label, value)}
2577
- size="sm"
2578
- >
2579
- {label}: {value}
2580
- </Badge>
2581
- ) : null,
2582
- )}
2583
- {status.appliedRevision ? (
2584
- <Badge tone="structural" size="sm">
2585
- {status.appliedRevision}
2586
- </Badge>
2587
- ) : null}
2588
- </div>
2589
- </div>
2590
- );
2591
- }
2592
-
2593
- function gitOpsValueSeverity(label: string, value: string) {
2594
- const normalized = value.toLowerCase();
2595
- if (
2596
- normalized === "healthy" ||
2597
- normalized === "synced" ||
2598
- normalized === "succeeded" ||
2599
- (label === "Ready" && normalized.startsWith("true"))
2600
- ) {
2601
- return "success" as const;
2602
- }
2603
- if (
2604
- normalized === "degraded" ||
2605
- normalized === "missing" ||
2606
- normalized === "failed" ||
2607
- normalized === "error" ||
2608
- (label === "Ready" && normalized.startsWith("false"))
2609
- ) {
2610
- return "error" as const;
2611
- }
2612
- if (normalized === "outofsync") return "warning" as const;
2613
- if (normalized === "progressing" || normalized === "running")
2614
- return "info" as const;
2615
- return "neutral" as const;
2616
- }
2617
-
2618
- function ResourceFact({ label, value }: { label: string; value: unknown }) {
2619
- if (value === undefined || value === null || value === "") return null;
2620
- return (
2621
- <div className="flex justify-between gap-2 @min-[560px]/evidence:block">
2622
- <dt className="text-theme-text-tertiary">{label}</dt>
2623
- <dd className="font-mono font-medium text-theme-text-primary">
2624
- {String(value)}
2625
- </dd>
2626
- </div>
2627
- );
2628
- }
2629
-
2630
- function conditionStatusTone(condition: {
2631
- type: string;
2632
- status: string;
2633
- }): "healthy" | "degraded" | "unhealthy" | "unknown" {
2634
- switch (defaultConditionTone(condition)) {
2635
- case "ok":
2636
- return "healthy";
2637
- case "warning":
2638
- return "degraded";
2639
- case "fail":
2640
- return "unhealthy";
2641
- case "unknown":
2642
- return "unknown";
2643
- }
2644
- }
2645
-
2646
- function LogsBody({
2647
- data,
2648
- condensed = false,
2649
- }: {
2650
- data: EvidenceDataOf<"logs">;
2651
- condensed?: boolean;
2652
- }) {
2653
- const lines = data.logs?.lines ?? [];
2654
- const visibleLines = lines
2655
- .slice(-VISIBLE_LOG_EVIDENCE_LINES)
2656
- .map((line) => stripAnsi(line));
2657
- const omittedLines = lines.length - visibleLines.length;
2658
- return (
2659
- <div className="space-y-2">
2660
- <div className="flex flex-wrap items-center gap-1.5">
2661
- {condensed ? null : (
2662
- <>
2663
- <Badge tone="structural" size="sm">
2664
- {data.pod} / {data.container}
2665
- </Badge>
2666
- <Badge severity="neutral" size="sm">
2667
- {data.previous ? "previous instance" : "current instance"}
2668
- </Badge>
2669
- </>
2670
- )}
2671
- {data.logs?.fallback ? (
2672
- <Badge severity="neutral" size="sm">
2673
- Log tail · filter matched nothing
2674
- </Badge>
2675
- ) : null}
2676
- {data.logs ? (
2677
- <span className="text-xs text-theme-text-tertiary">
2678
- {data.logs.matchedLines} of {data.logs.totalLines} read lines
2679
- matched the filter
2680
- </span>
2681
- ) : null}
2682
- </div>
2683
- {condensed ? (
2684
- omittedLines > 0 ? (
2685
- <p className="text-xs text-theme-text-tertiary">
2686
- Showing the last {visibleLines.length} of {lines.length} lines.
2687
- </p>
2688
- ) : null
2689
- ) : visibleLines.length > 0 ? (
2690
- <TerminalBlock
2691
- label={
2692
- omittedLines > 0
2693
- ? `Selected log excerpt · last ${visibleLines.length} of ${lines.length} lines`
2694
- : "Selected log excerpt"
2695
- }
2696
- >
2697
- {visibleLines.join("\n")}
2698
- </TerminalBlock>
2699
- ) : (
2700
- <p className="text-xs italic text-theme-text-tertiary">
2701
- No lines were captured from this stream.
2702
- </p>
2703
- )}
2704
- {data.error ? (
2705
- <p className="text-xs leading-relaxed text-red-400">{data.error}</p>
2706
- ) : null}
2707
- {data.warnings.map((warning) => (
2708
- <p key={warning} className="text-xs text-warning-text">
2709
- {warning}
2710
- </p>
2711
- ))}
2712
- </div>
2713
- );
2714
- }
2715
-
2716
- function EventsBody({ data }: { data: EvidenceDataOf<"events"> }) {
2717
- return (
2718
- <div>
2719
- <p className="mb-2 text-xs text-theme-text-tertiary">{data.scope}</p>
2720
- <ol className="max-h-[28rem] space-y-0 overflow-y-auto pr-1">
2721
- {data.events.map((event, index) => (
2722
- <li
2723
- key={`${event.reason}-${event.lastTimestamp}-${index}`}
2724
- className="relative flex gap-3 pb-3 last:pb-0"
2725
- >
2726
- {index < data.events.length - 1 ? (
2727
- <span className="absolute bottom-0 left-[5px] top-3 w-px bg-theme-border" />
2728
- ) : null}
2729
- <span className="relative mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full border-2 border-amber-500 bg-theme-surface" />
2730
- <div className="min-w-0 flex-1">
2731
- <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
2732
- <span className="text-xs font-semibold text-theme-text-primary">
2733
- {event.reason}
2734
- {event.count > 1 ? (
2735
- <span className="ml-1.5 font-mono text-[11px] font-normal text-theme-text-tertiary">
2736
- ×{event.count}
2737
- </span>
2738
- ) : null}
2739
- </span>
2740
- <Tooltip
2741
- content={new Date(event.lastTimestamp).toLocaleString()}
2742
- delay={150}
2743
- position="left"
2744
- >
2745
- <time
2746
- dateTime={event.lastTimestamp}
2747
- className="text-xs text-theme-text-tertiary"
2748
- >
2749
- {formatRelativeAgeTime(event.lastTimestamp)}
2750
- </time>
2751
- </Tooltip>
2752
- </div>
2753
- <p className="mt-0.5 text-xs leading-relaxed text-theme-text-secondary">
2754
- {event.message}
2755
- </p>
2756
- </div>
2757
- </li>
2758
- ))}
2759
- </ol>
2760
- </div>
2761
- );
2762
- }
2763
-
2764
- function ChangesBody({ data }: { data: EvidenceDataOf<"changes"> }) {
2765
- const { onOpenResource } = useContext(EvidenceNavigationContext);
2766
- return (
2767
- <div className="space-y-2.5">
2768
- {data.changeContext?.evidence ? (
2769
- <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
2770
- {data.changeContext.evidence}
2771
- </p>
2772
- ) : null}
2773
- {data.changes.map((change, index) => (
2774
- <div
2775
- key={`${change.kind}-${change.namespace ?? ""}-${change.name}-${change.timestamp}-${index}`}
2776
- className="rounded-md border border-theme-border/70 bg-theme-base/30 p-2.5"
2777
- >
2778
- <div className="flex flex-wrap items-center gap-1.5">
2779
- <Badge tone="structural" size="sm">
2780
- {change.kind}
2781
- </Badge>
2782
- <span className="font-mono text-xs">
2783
- <ResourceLink
2784
- name={change.name}
2785
- kind={change.kind}
2786
- namespace={change.namespace ?? ""}
2787
- group={
2788
- change.apiVersion
2789
- ? apiVersionToGroup(change.apiVersion)
2790
- : undefined
2791
- }
2792
- label={`${change.namespace ? `${change.namespace}/` : ""}${change.name}`}
2793
- onNavigate={
2794
- change.apiVersion && onOpenResource
2795
- ? (ref) => onOpenResource(ref)
2796
- : undefined
2797
- }
2798
- />
2799
- </span>
2800
- <Badge tone="note" size="sm">
2801
- {change.changeType.replaceAll("_", " ")}
2802
- </Badge>
2803
- <Tooltip
2804
- content={new Date(change.timestamp).toLocaleString()}
2805
- delay={150}
2806
- position="left"
2807
- wrapperClassName="ml-auto"
2808
- >
2809
- <time
2810
- dateTime={change.timestamp}
2811
- className="text-xs text-theme-text-tertiary"
2812
- >
2813
- {formatRelativeAgeTime(change.timestamp)}
2814
- </time>
2815
- </Tooltip>
2816
- </div>
2817
- {change.summary ? (
2818
- <p className="mt-1.5 text-xs text-theme-text-secondary">
2819
- {change.summary}
2820
- </p>
2821
- ) : null}
2822
- {change.fields?.length ? (
2823
- <div className="mt-2">
2824
- <DiffViewer
2825
- diff={{
2826
- summary: `${change.fields.length} changed ${change.fields.length === 1 ? "field" : "fields"}`,
2827
- fields: change.fields.map((field) => ({
2828
- path: field.path,
2829
- oldValue: field.oldValue ?? null,
2830
- newValue: field.newValue ?? null,
2831
- })),
2832
- }}
2833
- />
2834
- </div>
2835
- ) : null}
2836
- </div>
2837
- ))}
2838
- </div>
2839
- );
2840
- }
2841
-
2842
- function DNSBody({ data }: { data: EvidenceDataOf<"dns"> }) {
2843
- const { onOpenResource } = useContext(EvidenceNavigationContext);
2844
- return (
2845
- <div className="space-y-2">
2846
- {(data.dns.signals ?? []).map((signal) => (
2847
- <p
2848
- key={signal}
2849
- className="text-xs leading-relaxed text-theme-text-secondary"
2850
- >
2851
- {signal}
2852
- </p>
2853
- ))}
2854
- {(data.dns.coreDNSFindings ?? []).map((finding) => (
2855
- <div
2856
- key={`${finding.kind}-${finding.namespace}-${finding.name}-${finding.reason}`}
2857
- className="rounded-md border border-theme-border bg-theme-base/40 p-2"
2858
- >
2859
- <div className="flex flex-wrap gap-1.5">
2860
- <Badge tone="structural" size="sm">
2861
- {finding.kind}
2862
- </Badge>
2863
- <span className="font-mono text-xs">
2864
- <ResourceLink
2865
- name={finding.name}
2866
- kind={finding.kind}
2867
- namespace={finding.namespace}
2868
- group=""
2869
- label={`${finding.namespace}/${finding.name}`}
2870
- onNavigate={
2871
- finding.kind.toLowerCase() === "configmap" && onOpenResource
2872
- ? (ref) => onOpenResource(ref)
2873
- : undefined
2874
- }
2875
- />
2876
- </span>
2877
- <Badge severity={severityBadge(finding.severity)} size="sm">
2878
- {finding.severity}
2879
- </Badge>
2880
- </div>
2881
- <p className="mt-1 text-xs text-theme-text-secondary">
2882
- {finding.reason}
2883
- {finding.message ? ` — ${finding.message}` : ""}
2884
- </p>
2885
- </div>
2886
- ))}
2887
- </div>
2888
- );
2889
- }
2890
-
2891
- function NetworkBody({ data }: { data: EvidenceDataOf<"network"> }) {
2892
- const { network } = data;
2893
- const stats = [
2894
- ["Tested", network.summary.tested],
2895
- ["Passed", network.summary.passed],
2896
- ["Failed", network.summary.failed],
2897
- ["Inferred", network.summary.derived ?? 0],
2898
- ["Skipped", network.summary.skipped],
2899
- ] as const;
2900
- return (
2901
- <div className="space-y-3">
2902
- <div className="grid grid-cols-3 gap-1.5 @min-[620px]/evidence:grid-cols-5">
2903
- {stats.map(([label, value]) => (
2904
- <div
2905
- key={label}
2906
- className="rounded-md border border-theme-border bg-theme-base/40 px-2 py-1.5 text-center"
2907
- >
2908
- <div className="font-mono text-sm font-semibold text-theme-text-primary">
2909
- {value}
2910
- </div>
2911
- <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
2912
- {label}
2913
- </div>
2914
- </div>
2915
- ))}
2916
- </div>
2917
- {network.diagnosis ? (
2918
- <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
2919
- <p className="text-xs font-medium leading-relaxed text-theme-text-primary">
2920
- {network.diagnosis.summary}
2921
- </p>
2922
- {network.diagnosis.nextAction ? (
2923
- <p className="mt-1 border-l-2 border-accent/50 pl-2 text-xs leading-relaxed text-theme-text-secondary">
2924
- Next check: {network.diagnosis.nextAction}
2925
- </p>
2926
- ) : null}
2927
- </div>
2928
- ) : (
2929
- <p className="text-xs leading-relaxed text-theme-text-secondary">
2930
- {network.summary.headline}
2931
- </p>
2932
- )}
2933
- {network.routes.length > 0 ? (
2934
- <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
2935
- {network.routes.map((route, index) => (
2936
- <li
2937
- key={`${route.route}-${route.target ?? ""}-${index}`}
2938
- 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"
2939
- >
2940
- <StatusDot
2941
- tone={networkOutcomeTone(route.outcome, route.benign)}
2942
- className="mt-1 shrink-0"
2943
- />
2944
- <span className="min-w-0 flex-1">
2945
- <span className="block truncate font-mono text-xs text-theme-text-primary">
2946
- {route.route}
2947
- {route.target ? ` → ${route.target}` : ""}
2948
- </span>
2949
- {route.evidence ? (
2950
- <span className="mt-0.5 block text-xs leading-relaxed text-theme-text-secondary">
2951
- {route.evidence}
2952
- </span>
2953
- ) : null}
2954
- </span>
2955
- <Badge
2956
- severity={networkOutcomeSeverity(route.outcome, route.benign)}
2957
- size="sm"
2958
- >
2959
- {route.benign
2960
- ? "intentional"
2961
- : route.outcome.replaceAll("_", " ")}
2962
- </Badge>
2963
- </li>
2964
- ))}
2965
- </ol>
2966
- ) : null}
2967
- </div>
2968
- );
2969
- }
2970
-
2971
- function networkOutcomeTone(
2972
- outcome: string,
2973
- benign?: boolean,
2974
- ): "healthy" | "degraded" | "unhealthy" | "unknown" {
2975
- if (benign) return "degraded";
2976
- const normalized = outcome.toLowerCase();
2977
- if (normalized.includes("verified") || normalized.includes("reached"))
2978
- return "healthy";
2979
- if (normalized.includes("fail") || normalized.includes("unreachable"))
2980
- return "unhealthy";
2981
- if (normalized.includes("skip") || normalized.includes("not"))
2982
- return "unknown";
2983
- return "degraded";
2984
- }
2985
-
2986
- function networkOutcomeSeverity(outcome: string, benign?: boolean) {
2987
- switch (networkOutcomeTone(outcome, benign)) {
2988
- case "healthy":
2989
- return "success" as const;
2990
- case "degraded":
2991
- return "warning" as const;
2992
- case "unhealthy":
2993
- return "error" as const;
2994
- case "unknown":
2995
- return "neutral" as const;
2996
- }
2997
- }
2998
-
2999
- function RelationshipsBody({
3000
- data,
3001
- }: {
3002
- data: EvidenceDataOf<"relationships">;
3003
- }) {
3004
- return (
3005
- <div className="space-y-2.5">
3006
- <div className="flex flex-wrap items-center gap-1.5">
3007
- <Badge tone="structural" size="sm">
3008
- {data.root.kind}
3009
- </Badge>
3010
- <span className="font-mono text-xs text-theme-text-secondary">
3011
- {data.root.namespace ? `${data.root.namespace}/` : ""}
3012
- {data.root.name}
3013
- </span>
3014
- <span className="text-xs text-theme-text-tertiary">
3015
- {data.nodes.length} resources · {data.edges.length} direct
3016
- relationships
3017
- </span>
3018
- </div>
3019
- <div className="max-h-44 overflow-y-auto pr-1">
3020
- <div className="flex flex-wrap gap-1.5">
3021
- {data.nodes.map((node) => (
3022
- <span
3023
- key={node.id}
3024
- className="inline-flex items-center gap-1 rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs"
3025
- >
3026
- <Badge tone="structural" size="sm">
3027
- {node.kind}
3028
- </Badge>
3029
- <span className="font-mono text-theme-text-secondary">
3030
- {node.name}
3031
- </span>
3032
- </span>
3033
- ))}
3034
- </div>
3035
- </div>
3036
- {data.edges.length > 0 ? (
3037
- <div className="grid max-h-52 gap-1 overflow-y-auto pr-1 @min-[680px]/evidence:grid-cols-2">
3038
- {data.edges.map((edge, index) => (
3039
- <div
3040
- key={
3041
- edge.id ?? `${edge.source}-${edge.target}-${edge.type}-${index}`
3042
- }
3043
- 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"
3044
- >
3045
- <span className="truncate">{edge.source}</span>
3046
- <span className="shrink-0 text-theme-text-tertiary">→</span>
3047
- <span className="truncate">{edge.target}</span>
3048
- <Badge tone="structural" size="sm">
3049
- {edge.label || edge.type}
3050
- </Badge>
3051
- </div>
3052
- ))}
3053
- </div>
3054
- ) : null}
3055
- </div>
3056
- );
3057
- }
3058
-
3059
- function TopologyBody({ data }: { data: EvidenceDataOf<"topology"> }) {
3060
- return (
3061
- <div className="space-y-2.5">
3062
- <div className="grid grid-cols-2 gap-2">
3063
- <TopologyStat label="Nodes" value={data.stats.nodes} />
3064
- <TopologyStat label="Relationships" value={data.stats.edges} />
3065
- </div>
3066
- {data.problems.map((problem) => (
3067
- <p
3068
- key={problem}
3069
- className="flex items-start gap-1.5 text-xs text-warning-text"
3070
- >
3071
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
3072
- {problem}
3073
- </p>
3074
- ))}
3075
- <div className="max-h-64 space-y-2 overflow-y-auto pr-1">
3076
- {data.namespaces.map((namespace) => (
3077
- <div key={namespace.namespace}>
3078
- <div className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
3079
- {namespace.namespace || "cluster-scoped"}
3080
- </div>
3081
- <ul className="mt-1 space-y-1 font-mono text-xs text-theme-text-secondary">
3082
- {namespace.chains.map((chain) => (
3083
- <li key={chain}>{chain}</li>
3084
- ))}
3085
- </ul>
3086
- </div>
3087
- ))}
3088
- </div>
3089
- {data.warnings.map((warning) => (
3090
- <p key={warning} className="text-xs text-warning-text">
3091
- {warning}
3092
- </p>
3093
- ))}
3094
- </div>
3095
- );
3096
- }
3097
-
3098
- function TopologyStat({ label, value }: { label: string; value: number }) {
3099
- return (
3100
- <div className="rounded-md border border-theme-border bg-theme-base/40 px-3 py-2">
3101
- <div className="font-mono text-lg font-semibold text-theme-text-primary">
3102
- {value}
3103
- </div>
3104
- <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
3105
- {label}
3106
- </div>
3107
- </div>
3108
- );
3109
- }
3110
-
3111
- const METRICS_AXIS_LABEL_MAX_CHARS = 72;
3112
-
3113
- function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
3114
- return series.dataPoints.filter((point) => typeof point.value === "number");
3115
- }
3116
-
3117
- function MetricsValueTable({
3118
- series,
3119
- labels,
3120
- unit,
3121
- withTime,
3122
- }: {
3123
- series: TimeSeries[];
3124
- /** Display name per series, derived from the complete result. */
3125
- labels: string[];
3126
- unit: string;
3127
- withTime: boolean;
3128
- }) {
3129
- return (
3130
- <table className="w-full text-xs">
3131
- <tbody>
3132
- {series.map((item, index) => {
3133
- const sample = finiteSamples(item).at(-1);
3134
- return (
3135
- <tr
3136
- key={`${labels[index]}-${index}`}
3137
- className="border-b border-theme-border/60 last:border-b-0"
3138
- >
3139
- <td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
3140
- {labels[index]}
3141
- </td>
3142
- {withTime ? (
3143
- <td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
3144
- {sample
3145
- ? new Date(sample.timestamp * 1000).toLocaleTimeString()
3146
- : ""}
3147
- </td>
3148
- ) : null}
3149
- <td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
3150
- {sample && typeof sample.value === "number"
3151
- ? formatMetricValue(sample.value, unit)
3152
- : "no value"}
3153
- </td>
3154
- </tr>
3155
- );
3156
- })}
3157
- </tbody>
3158
- </table>
3159
- );
3160
- }
3161
-
3162
- function MetricsBody({
3163
- data,
3164
- changeCoverage,
3165
- }: {
3166
- data: EvidenceDataOf<"metrics">;
3167
- changeCoverage?: MetricsChangeCoverage;
3168
- }) {
3169
- const annotations = changeCoverage?.markers;
3170
- const unit = data.unit ?? "";
3171
- const axisLabel = data.label ?? data.query;
3172
- const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
3173
- const axisText = axisTruncated
3174
- ? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
3175
- : axisLabel;
3176
- const domain = metricsDomain(data);
3177
- const windowText = domain
3178
- ? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
3179
- : undefined;
3180
- if (data.series.length === 0) {
3181
- return (
3182
- <div className="space-y-2">
3183
- <p className="text-xs text-theme-text-secondary">
3184
- No series matched this query
3185
- {data.mode === "range" ? " in the window" : ""}.
3186
- </p>
3187
- <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">
3188
- {data.query}
3189
- </pre>
3190
- {data.note ? (
3191
- <p className="text-xs text-theme-text-tertiary">{data.note}</p>
3192
- ) : null}
3193
- </div>
3194
- );
3195
- }
3196
- if (data.mode === "instant") {
3197
- return (
3198
- <div className="space-y-2">
3199
- <MetricsValueTable
3200
- series={data.series}
3201
- labels={seriesDisplayLabels(data.series)}
3202
- unit={unit}
3203
- withTime={false}
3204
- />
3205
- <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">
3206
- {data.query}
3207
- </pre>
3208
- {data.note ? (
3209
- <p className="text-xs text-theme-text-tertiary">{data.note}</p>
3210
- ) : null}
3211
- </div>
3212
- );
3213
- }
3214
- // Names come from the whole result so two series that differ only by a
3215
- // label the chart would hide stay distinguishable wherever they are listed.
3216
- const labels = seriesDisplayLabels(data.series);
3217
- const indexed = data.series.map((item, index) => ({
3218
- item,
3219
- label: labels[index],
3220
- finite: finiteSamples(item).length,
3221
- }));
3222
- // A series with one finite sample has no line to draw, and one with none
3223
- // (Prometheus serializes NaN and infinities as gaps) has nothing to show.
3224
- // Listing both keeps what was captured readable and states what was not.
3225
- const charted = indexed.filter((entry) => entry.finite >= 2);
3226
- const single = indexed.filter((entry) => entry.finite === 1);
3227
- const empty = indexed.filter((entry) => entry.finite === 0);
3228
- return (
3229
- <div className="space-y-2">
3230
- <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
3231
- <Tooltip
3232
- content={axisLabel}
3233
- delay={200}
3234
- position="top"
3235
- disabled={!axisTruncated}
3236
- >
3237
- <span
3238
- className="min-w-0 truncate font-mono text-theme-text-secondary"
3239
- data-testid="investigation-metrics-axis-label"
3240
- >
3241
- {axisText}
3242
- </span>
3243
- </Tooltip>
3244
- {unit ? (
3245
- <span className="text-theme-text-tertiary">({unit})</span>
3246
- ) : null}
3247
- {windowText ? (
3248
- <span className="ml-auto text-theme-text-tertiary">{windowText}</span>
3249
- ) : null}
3250
- </div>
3251
- {charted.length > 0 ? (
3252
- <div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
3253
- <AreaChart
3254
- series={charted.map((entry) => entry.item)}
3255
- seriesLabels={charted.map((entry) => entry.label)}
3256
- color={SERIES_COLORS[0]}
3257
- fillColor={seriesFill(0, SERIES_COLORS[0])}
3258
- unit={unit}
3259
- annotations={annotations}
3260
- domain={domain}
3261
- layout="auto"
3262
- />
3263
- {charted.length > 1 ? (
3264
- <SeriesLegend
3265
- series={charted.map((entry) => entry.item)}
3266
- seriesLabels={charted.map((entry) => entry.label)}
3267
- color={SERIES_COLORS[0]}
3268
- />
3269
- ) : null}
3270
- </div>
3271
- ) : null}
3272
- {single.length > 0 ? (
3273
- <div
3274
- className="space-y-1"
3275
- data-testid="investigation-metrics-sparse-series"
3276
- >
3277
- <p className="text-xs text-theme-text-tertiary">
3278
- {single.length === 1
3279
- ? "1 series has a single sample in this window, listed with its time:"
3280
- : `${single.length} series have a single sample in this window, listed with their times:`}
3281
- </p>
3282
- <MetricsValueTable
3283
- series={single.map((entry) => entry.item)}
3284
- labels={single.map((entry) => entry.label)}
3285
- unit={unit}
3286
- withTime
3287
- />
3288
- </div>
3289
- ) : null}
3290
- {empty.length > 0 ? (
3291
- <p
3292
- className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
3293
- data-testid="investigation-metrics-empty-series"
3294
- >
3295
- {empty.length === 1
3296
- ? "1 series had no usable samples in this window: "
3297
- : `${empty.length} series had no usable samples in this window: `}
3298
- <span className="font-mono">
3299
- {empty.map((entry) => entry.label).join("; ")}
3300
- </span>
3301
- </p>
3302
- ) : null}
3303
- {changeCoverage?.checked && charted.length > 0 ? (
3304
- <p className="text-xs text-theme-text-tertiary">
3305
- {annotations?.length === 1
3306
- ? "1 change recorded in this window is marked on the chart."
3307
- : annotations?.length
3308
- ? `${annotations.length} changes recorded in this window are marked on the chart.`
3309
- : // Only that the changes Radar read miss this window — not that
3310
- // the window was fully covered. The change lookup has its own
3311
- // window, which need not span the chart's.
3312
- "None of the changes Radar read fall in this window."}
3313
- </p>
3314
- ) : null}
3315
- {axisTruncated ? (
3316
- <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">
3317
- {data.query}
3318
- </pre>
3319
- ) : null}
3320
- {data.note ? (
3321
- <p className="text-xs text-theme-text-tertiary">{data.note}</p>
3322
- ) : null}
3323
- </div>
3324
- );
3325
- }
3326
-
3327
- // The rows the citations on a card name, in citation order: one row when the
3328
- // name picks exactly one entry, otherwise a line saying the entry is absent
3329
- // or which entries the name could mean.
3330
- function listingScopeNamespace(
3331
- source: InvestigationEvidenceSource,
3332
- ): string | undefined {
3333
- const namespace = investigationSourceArgs(source)?.namespace;
3334
- return typeof namespace === "string" && namespace ? namespace : undefined;
3335
- }
3336
-
3337
- export function namedInventoryRows(
3338
- resources: InvestigationResourceSummary[],
3339
- items: readonly { subject?: DiagnosisEvidenceSubject }[],
3340
- /** The namespace the listing call was scoped to; rows omit it then. */
3341
- scopeNamespace?: string,
3342
- ): {
3343
- key: string;
3344
- label: string;
3345
- matches: number;
3346
- row?: InvestigationResourceSummary;
3347
- }[] {
3348
- const seen = new Set<string>();
3349
- const out: {
3350
- key: string;
3351
- label: string;
3352
- matches: number;
3353
- row?: InvestigationResourceSummary;
3354
- }[] = [];
3355
- for (const item of items) {
3356
- const subject = item.subject;
3357
- if (!subject?.name) continue;
3358
- // Agents write namespace "" for a cluster-scoped kind; that states none.
3359
- const namespace = subject.namespace || undefined;
3360
- const key = `${namespace ?? ""}/${subject.name}`;
3361
- if (seen.has(key)) continue;
3362
- seen.add(key);
3363
- // A row without a namespace lives in the listing's scope, or in none
3364
- // (a cluster-scoped kind); neither satisfies a namespace the citation
3365
- // states unless it is the scope itself.
3366
- const matches = resources.filter(
3367
- (resource) =>
3368
- resource.name === subject.name &&
3369
- sameKind(resource.kind, subject.kind) &&
3370
- (namespace === undefined ||
3371
- (resource.namespace ?? scopeNamespace) === namespace),
3372
- );
3373
- out.push({
3374
- key,
3375
- label: namespace ? `${namespace}/${subject.name}` : subject.name,
3376
- matches: matches.length,
3377
- row: matches.length === 1 ? matches[0] : undefined,
3378
- });
3379
- }
3380
- return out;
3381
- }
3382
-
3383
- function InventoryRow({
3384
- resource,
3385
- divider = false,
3386
- }: {
3387
- resource: InvestigationResourceSummary;
3388
- divider?: boolean;
3389
- }) {
3390
- return (
3391
- <div
3392
- className={clsx(
3393
- "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
3394
- divider && "border-t border-theme-border/60",
3395
- )}
3396
- >
3397
- <StatusDot
3398
- tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
3399
- className="shrink-0"
3400
- />
3401
- <Badge tone="structural" size="sm">
3402
- {resource.kind}
3403
- </Badge>
3404
- <span className="min-w-0 flex-1">
3405
- <span className="block truncate font-mono text-xs text-theme-text-secondary">
3406
- {resource.namespace ? `${resource.namespace}/` : ""}
3407
- {resource.name}
3408
- </span>
3409
- {resource.issue ? (
3410
- <span className="block truncate text-xs text-warning-text">
3411
- {resource.issue}
3412
- </span>
3413
- ) : null}
3414
- </span>
3415
- {resource.ready || resource.status ? (
3416
- <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
3417
- {resource.ready || resource.status}
3418
- </span>
3419
- ) : null}
3420
- {(resource.summaryContext?.issueCount ?? 0) > 0 ? (
3421
- <Badge severity="warning" size="sm">
3422
- {resource.summaryContext?.issueCount} issues
3423
- </Badge>
3424
- ) : null}
3425
- </div>
3426
- );
3427
- }
3428
-
3429
- function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
3430
- return (
3431
- <div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
3432
- {data.resources.map((resource, index) => (
3433
- <InventoryRow
3434
- key={`${resource.kind}-${resource.namespace ?? ""}-${resource.name}`}
3435
- resource={resource}
3436
- divider={index > 0}
3437
- />
3438
- ))}
3439
- </div>
3440
- );
3441
- }
3442
-
3443
- const VISIBLE_ALERT_LABELS = 6;
3444
-
3445
- function alertStateSeverity(state: string | undefined) {
3446
- switch ((state ?? "").toLowerCase()) {
3447
- case "firing":
3448
- return "error" as const;
3449
- case "pending":
3450
- return "warning" as const;
3451
- case "inactive":
3452
- return "success" as const;
3453
- default:
3454
- return "neutral" as const;
3455
- }
3456
- }
3457
-
3458
- function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
3459
- const { rule, instances, annotations } = data;
3460
- const health = rule.health?.toLowerCase();
3461
- // Target instances lead; the rest stay visible in producer order.
3462
- const ordered = [...instances].sort(
3463
- (left, right) => Number(right.namesTarget) - Number(left.namesTarget),
3464
- );
3465
- const annotationEntries = Object.entries(annotations);
3466
- return (
3467
- <div className="space-y-2.5">
3468
- <div className="flex flex-wrap items-center gap-1.5">
3469
- {rule.state ? (
3470
- <Badge severity={alertStateSeverity(rule.state)} size="sm">
3471
- {rule.state}
3472
- </Badge>
3473
- ) : null}
3474
- {rule.labels.severity ? (
3475
- <Badge severity={severityBadge(rule.labels.severity)} size="sm">
3476
- severity {rule.labels.severity}
3477
- </Badge>
3478
- ) : null}
3479
- <Badge tone="structural" size="sm">
3480
- {rule.group}
3481
- </Badge>
3482
- {health && health !== "ok" ? (
3483
- <Badge severity={health === "err" ? "error" : "neutral"} size="sm">
3484
- health {health}
3485
- </Badge>
3486
- ) : null}
3487
- </div>
3488
- {ordered.length > 0 ? (
3489
- <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
3490
- {ordered.map((instance, index) => {
3491
- const labels = Object.entries(instance.labels).filter(
3492
- ([key]) => key !== "alertname" && key !== "severity",
3493
- );
3494
- const hidden = labels.length - VISIBLE_ALERT_LABELS;
3495
- return (
3496
- <li
3497
- key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
3498
- className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
3499
- >
3500
- <div className="flex flex-wrap items-center gap-1.5">
3501
- <Badge
3502
- severity={alertStateSeverity(instance.state)}
3503
- size="sm"
3504
- >
3505
- {instance.state}
3506
- </Badge>
3507
- {instance.namesTarget ? (
3508
- <Badge severity="info" size="sm">
3509
- names this resource
3510
- </Badge>
3511
- ) : null}
3512
- {instance.value ? (
3513
- <span className="font-mono text-xs text-theme-text-secondary">
3514
- value {instance.value}
3515
- </span>
3516
- ) : null}
3517
- {instance.activeAt ? (
3518
- <Tooltip
3519
- content={new Date(instance.activeAt).toLocaleString()}
3520
- delay={150}
3521
- position="left"
3522
- wrapperClassName="ml-auto"
3523
- >
3524
- <time
3525
- dateTime={instance.activeAt}
3526
- className="text-xs text-theme-text-tertiary"
3527
- >
3528
- active {formatRelativeAgeTime(instance.activeAt)}
3529
- </time>
3530
- </Tooltip>
3531
- ) : null}
3532
- </div>
3533
- {labels.length > 0 ? (
3534
- <div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
3535
- {labels
3536
- .slice(0, VISIBLE_ALERT_LABELS)
3537
- .map(([key, value]) => (
3538
- <span
3539
- key={key}
3540
- className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
3541
- >
3542
- {key}={value}
3543
- </span>
3544
- ))}
3545
- {hidden > 0 ? (
3546
- <span className="px-1 py-0.5 text-theme-text-tertiary">
3547
- +{hidden} more
3548
- </span>
3549
- ) : null}
3550
- </div>
3551
- ) : null}
3552
- </li>
3553
- );
3554
- })}
3555
- </ol>
3556
- ) : (
3557
- <p className="text-xs italic text-theme-text-tertiary">
3558
- No active instances were reported for this rule.
3559
- </p>
3560
- )}
3561
- {annotationEntries.length > 0 ? (
3562
- <dl className="space-y-1 text-xs">
3563
- {annotationEntries.map(([key, value]) => (
3564
- <div key={key} className="flex min-w-0 gap-2">
3565
- <dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
3566
- <dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3567
- {value}
3568
- </dd>
3569
- </div>
3570
- ))}
3571
- </dl>
3572
- ) : null}
3573
- {rule.query ? (
3574
- <TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
3575
- ) : null}
3576
- </div>
3577
- );
3578
- }
3579
-
3580
- function helmStatusSeverity(status: string) {
3581
- const normalized = status.toLowerCase();
3582
- if (normalized === "deployed") return "success" as const;
3583
- if (normalized.includes("failed")) return "error" as const;
3584
- if (normalized.startsWith("pending") || normalized === "uninstalling")
3585
- return "info" as const;
3586
- return "warning" as const;
3587
- }
3588
-
3589
- function HelmBody({ data }: { data: EvidenceDataOf<"helm"> }) {
3590
- const { onOpenResource } = useContext(EvidenceNavigationContext);
3591
- const { release } = data;
3592
- const operation = release.lastOperation;
3593
- return (
3594
- <div className="space-y-3">
3595
- <div className="flex flex-wrap items-center gap-1.5">
3596
- <Badge severity={helmStatusSeverity(release.status)} size="sm">
3597
- {release.status}
3598
- </Badge>
3599
- <Badge tone="structural" size="sm">
3600
- {release.chart}
3601
- {release.chartVersion ? ` ${release.chartVersion}` : ""}
3602
- </Badge>
3603
- <Badge tone="structural" size="sm">
3604
- revision {release.revision}
3605
- </Badge>
3606
- {release.appVersion ? (
3607
- <Badge tone="note" size="sm">
3608
- app {release.appVersion}
3609
- </Badge>
3610
- ) : null}
3611
- {release.resourceHealth ? (
3612
- <Badge
3613
- severity={
3614
- mapHealthToTone(release.resourceHealth) === "healthy"
3615
- ? "success"
3616
- : mapHealthToTone(release.resourceHealth) === "unhealthy"
3617
- ? "error"
3618
- : "warning"
3619
- }
3620
- size="sm"
3621
- >
3622
- resources {release.resourceHealth}
3623
- </Badge>
3624
- ) : null}
3625
- <Tooltip
3626
- content={new Date(release.updated).toLocaleString()}
3627
- delay={150}
3628
- position="left"
3629
- wrapperClassName="ml-auto"
3630
- >
3631
- <time
3632
- dateTime={release.updated}
3633
- className="text-xs text-theme-text-tertiary"
3634
- >
3635
- updated {formatRelativeAgeTime(release.updated)}
3636
- </time>
3637
- </Tooltip>
3638
- </div>
3639
- {release.healthIssue ? (
3640
- <p className="text-xs leading-relaxed text-warning-text">
3641
- {release.healthIssue}
3642
- </p>
3643
- ) : null}
3644
- {release.healthSummary &&
3645
- release.healthSummary !== release.healthIssue ? (
3646
- <p className="text-xs leading-relaxed text-theme-text-secondary">
3647
- {release.healthSummary}
3648
- </p>
3649
- ) : null}
3650
- {operation ? (
3651
- <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
3652
- <div className="flex flex-wrap items-center gap-1.5">
3653
- <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
3654
- Last operation
3655
- </span>
3656
- <Badge tone="note" size="sm">
3657
- {operation.kind.replaceAll("_", " ")}
3658
- </Badge>
3659
- <Badge
3660
- severity={
3661
- operation.status === "completed"
3662
- ? "success"
3663
- : operation.status === "failed"
3664
- ? "error"
3665
- : "warning"
3666
- }
3667
- size="sm"
3668
- >
3669
- {operation.status.replaceAll("_", " ")}
3670
- </Badge>
3671
- </div>
3672
- {operation.message ? (
3673
- <p className="mt-1 text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3674
- {operation.message}
3675
- </p>
3676
- ) : null}
3677
- </div>
3678
- ) : null}
3679
- {release.description ? (
3680
- <p className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]">
3681
- {release.description}
3682
- </p>
3683
- ) : null}
3684
- {release.storageNamespace &&
3685
- release.storageNamespace !== release.namespace ? (
3686
- <p className="text-xs text-theme-text-tertiary">
3687
- Release metadata stored in namespace {release.storageNamespace}
3688
- </p>
3689
- ) : null}
3690
- {release.managedByFluxHelmRelease ? (
3691
- <p className="text-xs text-theme-text-tertiary">
3692
- Managed by Flux HelmRelease {release.managedByFluxHelmRelease}
3693
- </p>
3694
- ) : null}
3695
- {release.resources.length > 0 ? (
3696
- <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
3697
- {release.resources.map((owned, index) => (
3698
- <div
3699
- key={`${owned.kind}-${owned.namespace}-${owned.name}`}
3700
- className={clsx(
3701
- "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
3702
- index > 0 && "border-t border-theme-border/60",
3703
- )}
3704
- >
3705
- <StatusDot
3706
- tone={mapHealthToTone(
3707
- owned.issue ? "unhealthy" : (owned.status ?? ""),
3708
- )}
3709
- className="shrink-0"
3710
- />
3711
- <Badge tone="structural" size="sm">
3712
- {owned.kind}
3713
- </Badge>
3714
- <span className="min-w-0 flex-1">
3715
- <span className="block truncate font-mono text-xs">
3716
- <ResourceLink
3717
- name={owned.name}
3718
- kind={owned.kind}
3719
- namespace={owned.namespace}
3720
- group={
3721
- owned.apiVersion
3722
- ? apiVersionToGroup(owned.apiVersion)
3723
- : undefined
3724
- }
3725
- label={`${owned.namespace ? `${owned.namespace}/` : ""}${owned.name}`}
3726
- onNavigate={
3727
- owned.apiVersion && onOpenResource
3728
- ? (ref) => onOpenResource(ref)
3729
- : undefined
3730
- }
3731
- />
3732
- </span>
3733
- {owned.issue ? (
3734
- <span className="block truncate text-xs text-warning-text">
3735
- {owned.issue}
3736
- </span>
3737
- ) : owned.summary || owned.message ? (
3738
- <span className="block truncate text-xs text-theme-text-tertiary">
3739
- {owned.summary || owned.message}
3740
- </span>
3741
- ) : null}
3742
- </span>
3743
- {owned.ready || owned.status ? (
3744
- <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
3745
- {owned.ready || owned.status}
3746
- </span>
3747
- ) : null}
3748
- </div>
3749
- ))}
3750
- </div>
3751
- ) : null}
3752
- </div>
3753
- );
3754
- }
3755
-
3756
- function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
3757
- const { subject, accessCheck } = data;
3758
- const subjectBadges = (
3759
- <>
3760
- <Badge tone="structural" size="sm">
3761
- {subject.kind}
3762
- </Badge>
3763
- <span className="font-mono text-xs text-theme-text-secondary">
3764
- {subject.namespace ? `${subject.namespace}/` : ""}
3765
- {subject.name}
3766
- </span>
3767
- </>
3768
- );
3769
- if (accessCheck) {
3770
- const facts = [
3771
- ["Verb", accessCheck.verb],
3772
- ["Resource", accessCheck.resource],
3773
- ["Subresource", accessCheck.subresource],
3774
- ["API group", accessCheck.group || "core"],
3775
- ["Namespace", accessCheck.namespace || "cluster-wide"],
3776
- ["Name", accessCheck.resourceName],
3777
- ] as const;
3778
- return (
3779
- <div className="space-y-2.5">
3780
- <div className="flex flex-wrap items-center gap-1.5">
3781
- {subjectBadges}
3782
- <Badge
3783
- severity={accessCheck.allowed ? "success" : "warning"}
3784
- size="sm"
3785
- >
3786
- {accessCheck.allowed
3787
- ? "allowed"
3788
- : accessCheck.denied
3789
- ? "denied"
3790
- : "not allowed"}
3791
- </Badge>
3792
- </div>
3793
- <dl className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
3794
- {facts.map(([label, value]) => (
3795
- <ResourceFact key={label} label={label} value={value} />
3796
- ))}
3797
- </dl>
3798
- {accessCheck.reason ? (
3799
- <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3800
- {accessCheck.reason}
3801
- </p>
3802
- ) : null}
3803
- {accessCheck.evaluationError ? (
3804
- <p className="text-xs leading-relaxed text-semantic-error">
3805
- {accessCheck.evaluationError}
3806
- </p>
3807
- ) : null}
3808
- </div>
3809
- );
3810
- }
3811
- const bindings = data.bindings ?? [];
3812
- const usedByPods = data.usedByPods ?? [];
3813
- return (
3814
- <div className="space-y-2.5">
3815
- <div className="flex flex-wrap items-center gap-1.5">
3816
- {subjectBadges}
3817
- <Badge tone="note" size="sm">
3818
- {data.flatRulesCount ?? 0}
3819
- {data.truncated ? "+" : ""} effective rules
3820
- </Badge>
3821
- {data.truncated ? (
3822
- <Badge severity="warning" size="sm">
3823
- rule list truncated
3824
- </Badge>
3825
- ) : null}
3826
- </div>
3827
- {bindings.length > 0 ? (
3828
- <div className="max-h-52 overflow-y-auto rounded-md border border-theme-border">
3829
- {bindings.map((binding, index) => (
3830
- <div
3831
- key={`${binding.bindingKind}-${binding.bindingNamespace ?? ""}-${binding.bindingName}`}
3832
- className={clsx(
3833
- "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs",
3834
- index > 0 && "border-t border-theme-border/60",
3835
- )}
3836
- >
3837
- <Badge tone="structural" size="sm">
3838
- {binding.bindingKind}
3839
- </Badge>
3840
- <span className="min-w-0 truncate font-mono text-theme-text-secondary">
3841
- {binding.bindingNamespace ? `${binding.bindingNamespace}/` : ""}
3842
- {binding.bindingName}
3843
- </span>
3844
- <span className="text-theme-text-tertiary">→</span>
3845
- <Badge tone="structural" size="sm">
3846
- {binding.roleKind}
3847
- </Badge>
3848
- <span className="min-w-0 truncate font-mono text-theme-text-primary">
3849
- {binding.roleNamespace ? `${binding.roleNamespace}/` : ""}
3850
- {binding.roleName}
3851
- </span>
3852
- <span className="ml-auto shrink-0 font-mono text-theme-text-tertiary">
3853
- {binding.rulesCount} rule{binding.rulesCount === 1 ? "" : "s"}
3854
- </span>
3855
- {binding.inheritedFromGroup ? (
3856
- <Badge tone="note" size="sm">
3857
- via {binding.inheritedFromGroup}
3858
- </Badge>
3859
- ) : null}
3860
- </div>
3861
- ))}
3862
- </div>
3863
- ) : (
3864
- <p className="text-xs italic text-theme-text-tertiary">
3865
- No RoleBinding or ClusterRoleBinding grants this subject anything.
3866
- </p>
3867
- )}
3868
- {usedByPods.length > 0 ? (
3869
- <p className="text-xs leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
3870
- <span className="text-theme-text-tertiary">Used by pods: </span>
3871
- <span className="font-mono">{usedByPods.join(", ")}</span>
3872
- {data.podsTotal && data.podsTotal > usedByPods.length
3873
- ? ` and ${data.podsTotal - usedByPods.length} more`
3874
- : ""}
3875
- </p>
3876
- ) : null}
3877
- </div>
3878
- );
3879
- }
3880
-
3881
- function RevisionHistory({
3882
- observations,
3883
- citedOrder,
3884
- caseItems = [],
3885
- reveal,
3886
- revealRequestId,
3887
- onViewSource,
3888
- }: {
3889
- observations: InvestigationEvidenceObservation[];
3890
- citedOrder?: number;
3891
- /** Agent items bound to a superseded observation of this group. */
3892
- caseItems?: InvestigationCaseItem[];
3893
- reveal: boolean;
3894
- revealRequestId?: number;
3895
- onViewSource: (
3896
- sourceId: string,
3897
- excerpt?: InvestigationSourceExcerpt,
3898
- ) => void;
3899
- }) {
3900
- const [open, setOpen] = useState(false);
3901
- const regionId = useId();
3902
- const { metricsMarkersBySource } = useContext(EvidenceNavigationContext);
3903
- const { elementRef, revealAfterToggle } =
3904
- useDisclosureReveal<HTMLDivElement>();
3905
- useLayoutEffect(() => {
3906
- if (reveal) setOpen(true);
3907
- }, [reveal, revealRequestId]);
3908
- return (
3909
- <div>
3910
- <button
3911
- type="button"
3912
- aria-expanded={open}
3913
- aria-controls={regionId}
3914
- onClick={() => {
3915
- setOpen(!open);
3916
- revealAfterToggle(!open);
3917
- }}
3918
- 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"
3919
- >
3920
- <CollapseChevron open={open} className="h-3.5 w-3.5" />
3921
- Previous observations · {observations.length}
3922
- </button>
3923
- <div id={regionId} ref={elementRef}>
3924
- <Collapse open={open}>
3925
- <ol className="space-y-3 pt-2">
3926
- {observations.map((observation) => {
3927
- const key = investigationCaseObservationKey(observation);
3928
- const rowItems = caseItems.filter(
3929
- (item) =>
3930
- item.observation &&
3931
- investigationCaseObservationKey(item.observation) === key,
3932
- );
3933
- const rowRoles = [...new Set(rowItems.map((item) => item.role))];
3934
- return (
3935
- <li
3936
- key={`${observation.source.id}-${observation.revision}`}
3937
- data-case-observation={rowItems.length > 0 ? key : undefined}
3938
- className="flex min-w-0 items-start gap-2 text-xs"
3939
- >
3940
- <span className="mt-0.5 shrink-0 text-theme-text-tertiary">
3941
- <span className="font-medium text-theme-text-secondary">
3942
- {observation.source.order === citedOrder
3943
- ? "Used for assessment"
3944
- : phaseLabel(observation.source.phase)}
3945
- </span>
3946
- </span>
3947
- <div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
3948
- {rowRoles.length > 0 ? (
3949
- <p className="flex flex-wrap items-center gap-1.5">
3950
- <span>{observation.summary || observation.title}</span>
3951
- {rowRoles.map((role) => (
3952
- <AgentRoleChip key={role} role={role} />
3953
- ))}
3954
- </p>
3955
- ) : (
3956
- <p>{observation.summary || observation.title}</p>
3957
- )}
3958
- {rowItems
3959
- .filter((item) => item.claim)
3960
- .map((item) => (
3961
- <AgentClaimNote
3962
- key={item.index}
3963
- claim={item.claim}
3964
- role={rowRoles.length > 1 ? item.role : undefined}
3965
- className="pt-1"
3966
- />
3967
- ))}
3968
- {evidenceHasDetails(
3969
- observation.data,
3970
- observation.summary,
3971
- ) && (
3972
- <EvidenceBody
3973
- data={observation.data}
3974
- cardSummary={observation.summary}
3975
- changeCoverage={metricsMarkersBySource?.get(
3976
- observation.source.id,
3977
- )}
3978
- />
3979
- )}
3980
- </div>
3981
- <SourceButton
3982
- ariaLabel={`View result for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
3983
- onClick={() =>
3984
- onViewSource(
3985
- observation.source.id,
3986
- evidenceSourceExcerpt(observation.data),
3987
- )
3988
- }
3989
- />
3990
- </li>
3991
- );
3992
- })}
3993
- </ol>
3994
- </Collapse>
3995
- </div>
3996
- </div>
3997
- );
3998
- }
3999
-
4000
- function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
4001
- // Changes get a tooltip rather than a sentence because the wording depends
4002
- // on whether the reported age was collected with the change.
4003
- if (data.type === "changes") {
4004
- return (
4005
- <Tooltip
4006
- content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
4007
- position="left"
4008
- className="pointer-events-none"
4009
- wrapperClassName="flex shrink-0"
4010
- >
4011
- <button
4012
- type="button"
4013
- aria-label="About change evidence"
4014
- 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"
4015
- >
4016
- <Info className="h-3.5 w-3.5" aria-hidden />
4017
- </button>
4018
- </Tooltip>
4019
- );
4020
- }
4021
- const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
4022
- if (!text) return null;
4023
- return (
4024
- <p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
4025
- <Info className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
4026
- {text}
4027
- </p>
4028
- );
4029
- }
4030
-
4031
- function EvidenceIcon({
4032
- observation,
4033
- prominence,
4034
- }: {
4035
- observation: InvestigationEvidenceObservation;
4036
- prominence: "primary" | "supporting" | "secondary";
4037
- }) {
4038
- const Icon = evidenceIcon(observation.data.type);
4039
- return (
4040
- <span
4041
- className={clsx(
4042
- "flex shrink-0 items-center justify-center text-theme-text-tertiary",
4043
- prominence === "primary" ? "h-7 w-7" : "h-6 w-6",
4044
- )}
4045
- >
4046
- <Icon
4047
- className={prominence === "primary" ? "h-4 w-4" : "h-3.5 w-3.5"}
4048
- aria-hidden
4049
- />
4050
- </span>
4051
- );
4052
- }
4053
-
4054
- function SourceButton({
4055
- ariaLabel,
4056
- buttonLabel = "View result",
4057
- compact = false,
4058
- onClick,
4059
- }: {
4060
- ariaLabel?: string;
4061
- buttonLabel?: string;
4062
- compact?: boolean;
4063
- onClick: () => void;
4064
- }) {
4065
- const tooltip = "Open the original tool result";
4066
- return (
4067
- <Tooltip
4068
- content={tooltip}
4069
- delay={350}
4070
- position="left"
4071
- wrapperClassName="flex shrink-0"
4072
- >
4073
- <button
4074
- type="button"
4075
- aria-label={ariaLabel ?? tooltip}
4076
- onClick={onClick}
4077
- className={clsx(
4078
- "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",
4079
- "gap-1 px-2 text-xs font-medium",
4080
- )}
4081
- >
4082
- <FileSearch className="h-3.5 w-3.5" aria-hidden />
4083
- <span
4084
- className={compact ? "hidden @min-[540px]/card:inline" : undefined}
4085
- >
4086
- {buttonLabel}
4087
- </span>
4088
- </button>
4089
- </Tooltip>
4090
- );
4091
- }
4092
-
4093
- function evidenceIcon(type: InvestigationEvidenceData["type"]) {
4094
- return EVIDENCE_KIND_TRAITS[type].icon;
4095
- }
4096
-
4097
- function severityBadge(value: string) {
4098
- const tone = value.toLowerCase();
4099
- if (tone === "error" || tone === "critical" || tone === "failed")
4100
- return "error" as const;
4101
- if (tone === "alert" || tone === "high") return "alert" as const;
4102
- if (tone === "warning" || tone === "medium") return "warning" as const;
4103
- if (tone === "info" || tone === "low") return "info" as const;
4104
- return "neutral" as const;
4105
- }
4106
-
4107
- function phaseLabel(
4108
- phase: InvestigationEvidenceObservation["source"]["phase"],
4109
- ): string {
4110
- switch (phase) {
4111
- case "initial":
4112
- return "Initial";
4113
- case "followup":
4114
- return "Follow-up";
4115
- case "verification":
4116
- return "Verification";
4117
- case "apply":
4118
- return "Apply";
4119
- }
4120
- }
4121
-
4122
- function toneBorder(
4123
- tone: InvestigationEvidenceObservation["tone"],
4124
- tier: InvestigationEvidenceTier,
4125
- prominence: "primary" | "supporting" | "secondary",
4126
- ): string {
4127
- if (prominence !== "primary") return "border-theme-border/70";
4128
- // A supporting adverse card is what the healthy-conflict banner points at,
4129
- // so it must be tellable from its neutral neighbours: a thin rule, lighter
4130
- // than the Key tier's.
4131
- if (tier === "supporting" && tone === "error")
4132
- return "border-l-2 border-l-semantic-error border-theme-border";
4133
- if (tier === "supporting" && (tone === "warning" || tone === "alert"))
4134
- return "border-l-2 border-l-semantic-warning border-theme-border";
4135
- if (tier !== "key") return "border-theme-border";
4136
- if (tone === "error")
4137
- return "border-l-[3px] border-l-red-500 border-theme-border";
4138
- if (tone === "alert")
4139
- return "border-l-[3px] border-l-orange-500 border-theme-border";
4140
- return "border-l-[3px] border-l-amber-500 border-theme-border";
4141
- }