@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
@@ -0,0 +1,312 @@
1
+ import type { InvestigationTimelineScope } from "./navigation";
2
+ import { clsx } from "clsx";
3
+ import { EVIDENCE_KIND_TRAITS } from "../investigationEvidenceKinds";
4
+ import {
5
+ FileClock,
6
+ FileSearch,
7
+ Info,
8
+ SquareArrowOutUpRight,
9
+ } from "lucide-react";
10
+ import { defaultConditionTone, displayKind } from "@skyhook-io/k8s-ui";
11
+ import type {
12
+ InvestigationEvidenceData,
13
+ InvestigationEvidenceGroup,
14
+ InvestigationEvidenceObservation,
15
+ InvestigationEvidenceSource,
16
+ InvestigationEvidenceTier,
17
+ } from ".";
18
+ import type { DiagnosisResourceRef } from "../diagnoseEvidenceTypes";
19
+ import { Tooltip } from "../../ui/Tooltip";
20
+
21
+ export function OpenResourceButton({
22
+ resourceRef,
23
+ onOpenResource,
24
+ compact = false,
25
+ }: {
26
+ resourceRef?: DiagnosisResourceRef;
27
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
28
+ compact?: boolean;
29
+ }) {
30
+ if (!resourceRef || !onOpenResource) return null;
31
+ const identity = `${resourceRef.namespace ? `${resourceRef.namespace}/` : ""}${resourceRef.name}`;
32
+ const label = `Open current ${displayKind(resourceRef.kind)} ${identity} in Radar`;
33
+ return (
34
+ <Tooltip
35
+ content={label}
36
+ delay={350}
37
+ position="left"
38
+ wrapperClassName="flex shrink-0"
39
+ >
40
+ <button
41
+ type="button"
42
+ aria-label={label}
43
+ onClick={() => onOpenResource(resourceRef)}
44
+ className={clsx(
45
+ "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",
46
+ "gap-1 px-2 text-xs font-medium",
47
+ )}
48
+ >
49
+ {compact ? null : (
50
+ <span>Open current {displayKind(resourceRef.kind)}</span>
51
+ )}
52
+ <SquareArrowOutUpRight className="h-3.5 w-3.5" aria-hidden />
53
+ </button>
54
+ </Tooltip>
55
+ );
56
+ }
57
+
58
+ export function OpenTimelineButton({
59
+ scope,
60
+ onOpenTimeline,
61
+ }: {
62
+ scope: InvestigationTimelineScope;
63
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
64
+ }) {
65
+ if (!onOpenTimeline) return null;
66
+ const identity = `${scope.namespace ? `${scope.namespace}/` : ""}${scope.name}`;
67
+ const label = `Open the Timeline for ${identity}`;
68
+ return (
69
+ <Tooltip
70
+ content={label}
71
+ delay={350}
72
+ position="left"
73
+ wrapperClassName="flex shrink-0"
74
+ >
75
+ <button
76
+ type="button"
77
+ aria-label={label}
78
+ onClick={() => onOpenTimeline(scope)}
79
+ 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"
80
+ >
81
+ <FileClock className="h-3.5 w-3.5" aria-hidden />
82
+ <span className="hidden @min-[540px]/card:inline">Timeline</span>
83
+ </button>
84
+ </Tooltip>
85
+ );
86
+ }
87
+
88
+ export function uniquePrimarySources(
89
+ group: InvestigationEvidenceGroup,
90
+ ): InvestigationEvidenceSource[] {
91
+ // One tool call can contribute repeated revisions to the same semantic group.
92
+ // It still owns one navigation destination, and DOM ids must remain unique.
93
+ const sources = new Map<string, InvestigationEvidenceSource>();
94
+ for (const observation of group.observations) {
95
+ const { source } = observation;
96
+ if (source.primaryGroupId === group.id && !sources.has(source.id)) {
97
+ sources.set(source.id, source);
98
+ }
99
+ }
100
+ return [...sources.values()];
101
+ }
102
+
103
+ export type EvidenceDataOf<T extends InvestigationEvidenceData["type"]> =
104
+ Extract<InvestigationEvidenceData, { type: T }>;
105
+
106
+ export function gitOpsValueSeverity(label: string, value: string) {
107
+ const normalized = value.toLowerCase();
108
+ if (
109
+ normalized === "healthy" ||
110
+ normalized === "synced" ||
111
+ normalized === "succeeded" ||
112
+ (label === "Ready" && normalized.startsWith("true"))
113
+ ) {
114
+ return "success" as const;
115
+ }
116
+ if (
117
+ normalized === "degraded" ||
118
+ normalized === "missing" ||
119
+ normalized === "failed" ||
120
+ normalized === "error" ||
121
+ (label === "Ready" && normalized.startsWith("false"))
122
+ ) {
123
+ return "error" as const;
124
+ }
125
+ if (normalized === "outofsync") return "warning" as const;
126
+ if (normalized === "progressing" || normalized === "running")
127
+ return "info" as const;
128
+ return "neutral" as const;
129
+ }
130
+
131
+ export function ResourceFact({
132
+ label,
133
+ value,
134
+ }: {
135
+ label: string;
136
+ value: unknown;
137
+ }) {
138
+ if (value === undefined || value === null || value === "") return null;
139
+ return (
140
+ <div className="flex justify-between gap-2 @min-[560px]/evidence:block">
141
+ <dt className="text-theme-text-tertiary">{label}</dt>
142
+ <dd className="font-mono font-medium text-theme-text-primary">
143
+ {String(value)}
144
+ </dd>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ export function conditionStatusTone(condition: {
150
+ type: string;
151
+ status: string;
152
+ }): "healthy" | "degraded" | "unhealthy" | "unknown" {
153
+ switch (defaultConditionTone(condition)) {
154
+ case "ok":
155
+ return "healthy";
156
+ case "warning":
157
+ return "degraded";
158
+ case "fail":
159
+ return "unhealthy";
160
+ case "unknown":
161
+ return "unknown";
162
+ }
163
+ }
164
+
165
+ export function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
166
+ // Changes get a tooltip rather than a sentence because the wording depends
167
+ // on whether the reported age was collected with the change.
168
+ if (data.type === "changes") {
169
+ return (
170
+ <Tooltip
171
+ content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
172
+ position="left"
173
+ className="pointer-events-none"
174
+ wrapperClassName="flex shrink-0"
175
+ >
176
+ <button
177
+ type="button"
178
+ aria-label="About change evidence"
179
+ 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"
180
+ >
181
+ <Info className="h-3.5 w-3.5" aria-hidden />
182
+ </button>
183
+ </Tooltip>
184
+ );
185
+ }
186
+ const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
187
+ if (!text) return null;
188
+ return (
189
+ <p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
190
+ <Info className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
191
+ {text}
192
+ </p>
193
+ );
194
+ }
195
+
196
+ export function EvidenceIcon({
197
+ observation,
198
+ prominence,
199
+ }: {
200
+ observation: InvestigationEvidenceObservation;
201
+ prominence: "primary" | "supporting" | "secondary";
202
+ }) {
203
+ const Icon = evidenceIcon(observation.data.type);
204
+ return (
205
+ <span
206
+ className={clsx(
207
+ "flex shrink-0 items-center justify-center text-theme-text-tertiary",
208
+ prominence === "primary" ? "h-7 w-7" : "h-6 w-6",
209
+ )}
210
+ >
211
+ <Icon
212
+ className={prominence === "primary" ? "h-4 w-4" : "h-3.5 w-3.5"}
213
+ aria-hidden
214
+ />
215
+ </span>
216
+ );
217
+ }
218
+
219
+ export function SourceButton({
220
+ ariaLabel,
221
+ buttonLabel = "View result",
222
+ compact = false,
223
+ onClick,
224
+ }: {
225
+ ariaLabel?: string;
226
+ buttonLabel?: string;
227
+ compact?: boolean;
228
+ onClick: () => void;
229
+ }) {
230
+ const tooltip = "Open the original tool result";
231
+ return (
232
+ <Tooltip
233
+ content={tooltip}
234
+ delay={350}
235
+ position="left"
236
+ wrapperClassName="flex shrink-0"
237
+ >
238
+ <button
239
+ type="button"
240
+ aria-label={ariaLabel ?? tooltip}
241
+ onClick={onClick}
242
+ className={clsx(
243
+ "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",
244
+ "gap-1 px-2 text-xs font-medium",
245
+ )}
246
+ >
247
+ <FileSearch className="h-3.5 w-3.5" aria-hidden />
248
+ <span
249
+ className={compact ? "hidden @min-[540px]/card:inline" : undefined}
250
+ >
251
+ {buttonLabel}
252
+ </span>
253
+ </button>
254
+ </Tooltip>
255
+ );
256
+ }
257
+
258
+ function evidenceIcon(type: InvestigationEvidenceData["type"]) {
259
+ return EVIDENCE_KIND_TRAITS[type].icon;
260
+ }
261
+
262
+ export function severityBadge(value: string) {
263
+ const tone = value.toLowerCase();
264
+ if (
265
+ tone === "error" ||
266
+ tone === "critical" ||
267
+ tone === "failed" ||
268
+ tone === "blocker"
269
+ )
270
+ return "error" as const;
271
+ if (tone === "alert" || tone === "high") return "alert" as const;
272
+ if (tone === "warning" || tone === "medium") return "warning" as const;
273
+ if (tone === "info" || tone === "low" || tone === "review")
274
+ return "info" as const;
275
+ return "neutral" as const;
276
+ }
277
+
278
+ export function phaseLabel(
279
+ phase: InvestigationEvidenceObservation["source"]["phase"],
280
+ ): string {
281
+ switch (phase) {
282
+ case "initial":
283
+ return "Initial";
284
+ case "followup":
285
+ return "Follow-up";
286
+ case "verification":
287
+ return "Verification";
288
+ case "apply":
289
+ return "Apply";
290
+ }
291
+ }
292
+
293
+ export function toneBorder(
294
+ tone: InvestigationEvidenceObservation["tone"],
295
+ tier: InvestigationEvidenceTier,
296
+ prominence: "primary" | "supporting" | "secondary",
297
+ ): string {
298
+ if (prominence !== "primary") return "border-theme-border/70";
299
+ // A supporting adverse card is what the healthy-conflict banner points at,
300
+ // so it must be tellable from its neutral neighbours: a thin rule, lighter
301
+ // than the Key tier's.
302
+ if (tier === "supporting" && tone === "error")
303
+ return "border-l-2 border-l-semantic-error border-theme-border";
304
+ if (tier === "supporting" && (tone === "warning" || tone === "alert"))
305
+ return "border-l-2 border-l-semantic-warning border-theme-border";
306
+ if (tier !== "key") return "border-theme-border";
307
+ if (tone === "error")
308
+ return "border-l-[3px] border-l-red-500 border-theme-border";
309
+ if (tone === "alert")
310
+ return "border-l-[3px] border-l-orange-500 border-theme-border";
311
+ return "border-l-[3px] border-l-amber-500 border-theme-border";
312
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { storyExcerpt } from "./excerpt";
3
+ import type { InvestigationEvidenceObservation } from "./types";
4
+
5
+ function observation(
6
+ data: InvestigationEvidenceObservation["data"],
7
+ ): InvestigationEvidenceObservation {
8
+ return {
9
+ source: { id: "s1", turnIndex: 0 },
10
+ revision: 1,
11
+ title: "t",
12
+ data,
13
+ } as unknown as InvestigationEvidenceObservation;
14
+ }
15
+
16
+ describe("storyExcerpt", () => {
17
+ it("keeps a log card's newest two selected lines even when compact, the rest behind the fold", () => {
18
+ const logs = observation({
19
+ type: "logs",
20
+ pod: "api-1",
21
+ container: "api",
22
+ previous: false,
23
+ warnings: [],
24
+ logs: { lines: ["a", "b", "c", "d"], totalLines: 4, matchedLines: 4 },
25
+ } as never);
26
+ expect(storyExcerpt(logs, [], { compact: true })).toEqual({
27
+ kind: "lines",
28
+ head: ["c", "d"],
29
+ rest: ["a", "b"],
30
+ });
31
+ });
32
+
33
+ it("shows a chart only when the card is the cause or the symptom, and never compact", () => {
34
+ const metrics = observation({ type: "metrics", origin: "query" } as never);
35
+ expect(
36
+ storyExcerpt(metrics, [{ role: "context" }], { compact: false }),
37
+ ).toBeUndefined();
38
+ expect(
39
+ storyExcerpt(metrics, [{ role: "symptom" }], { compact: false }),
40
+ ).toEqual({ kind: "chart" });
41
+ expect(
42
+ storyExcerpt(metrics, [{ role: "cause" }], { compact: true }),
43
+ ).toBeUndefined();
44
+ });
45
+
46
+ it("picks the workload's own rows from a ranking and the findings on it from a posture card", () => {
47
+ const ranking = observation({
48
+ type: "ranking",
49
+ kind: "pods",
50
+ sort: "memory",
51
+ scope: "namespace shop",
52
+ rows: [
53
+ { kind: "Pod", name: "other", cpu: "1m", memory: "1Mi", target: false },
54
+ { kind: "Pod", name: "api-1", cpu: "2m", memory: "2Mi", target: true },
55
+ ],
56
+ } as never);
57
+ expect(storyExcerpt(ranking, [], { compact: false })).toMatchObject({
58
+ kind: "ranking",
59
+ rows: [{ name: "api-1" }],
60
+ });
61
+ expect(
62
+ storyExcerpt(ranking, [{ subject: { kind: "Pod", name: "other" } }], {
63
+ compact: false,
64
+ }),
65
+ ).toMatchObject({
66
+ kind: "ranking",
67
+ rows: [{ name: "other" }, { name: "api-1" }],
68
+ });
69
+ const posture = observation({
70
+ type: "posture",
71
+ source: "audit",
72
+ scope: "namespace shop",
73
+ findings: [
74
+ {
75
+ kind: "Deployment",
76
+ name: "api",
77
+ check: "runAsRoot",
78
+ severity: "high",
79
+ message: "m",
80
+ target: true,
81
+ },
82
+ ],
83
+ } as never);
84
+ expect(storyExcerpt(posture, [], { compact: false })).toMatchObject({
85
+ kind: "posture",
86
+ findings: [{ check: "runAsRoot" }],
87
+ });
88
+ });
89
+
90
+ it("shows the rows a citation names on a listing, and nothing for an unnamed one", () => {
91
+ const listing = observation({
92
+ type: "inventory",
93
+ scope: "namespace shop",
94
+ resources: [{ kind: "ConfigMap", namespace: "shop", name: "cfg" }],
95
+ } as never);
96
+ expect(storyExcerpt(listing, [], { compact: false })).toBeUndefined();
97
+ expect(
98
+ storyExcerpt(
99
+ listing,
100
+ [{ subject: { kind: "ConfigMap", namespace: "shop", name: "cfg" } }],
101
+ { compact: false },
102
+ ),
103
+ ).toMatchObject({ kind: "rows", entries: [{ matches: 1 }] });
104
+ });
105
+ });
@@ -0,0 +1,92 @@
1
+ import { sameKind } from "./observations";
2
+ import type {
3
+ DiagnosisEvidenceRole,
4
+ DiagnosisEvidenceSubject,
5
+ } from "../../../api/diagnose";
6
+ import { namedInventoryRows } from "./bodies/inventory";
7
+ import { stripAnsi } from "@skyhook-io/k8s-ui";
8
+ import type {
9
+ InvestigationEvidenceObservation,
10
+ InvestigationPostureFinding,
11
+ InvestigationRankingRow,
12
+ InvestigationResourceSummary,
13
+ } from "./types";
14
+
15
+ /** How many selected log lines a card carries; the rest wait in the record. */
16
+ export const VISIBLE_LOG_EVIDENCE_LINES = 12;
17
+
18
+ /**
19
+ * What a card placed in the story shows inline, under its header, before the
20
+ * fold. The citation decides it: the lines Radar selected, the row a subject
21
+ * names, the workload's own row in a ranking, the findings on it, or the
22
+ * chart when the card is the cause or the symptom. Nothing here is the
23
+ * agent's text; every excerpt is a slice of Radar's record.
24
+ */
25
+ export type StoryExcerpt =
26
+ | { kind: "lines"; head: string[]; rest: string[] }
27
+ | { kind: "rows"; entries: ReturnType<typeof namedInventoryRows> }
28
+ | { kind: "ranking"; rows: InvestigationRankingRow[] }
29
+ | { kind: "posture"; findings: InvestigationPostureFinding[] }
30
+ | { kind: "chart" };
31
+
32
+ export interface StoryExcerptItem {
33
+ role?: DiagnosisEvidenceRole;
34
+ subject?: DiagnosisEvidenceSubject;
35
+ }
36
+
37
+ export function storyExcerpt(
38
+ observation: InvestigationEvidenceObservation,
39
+ items: readonly StoryExcerptItem[],
40
+ options: { compact: boolean; scopeNamespace?: string },
41
+ ): StoryExcerpt | undefined {
42
+ const data = observation.data;
43
+ // The lines are the card: a compact log card keeps them.
44
+ if (data.type === "logs") {
45
+ const lines = (data.logs?.lines ?? [])
46
+ .slice(-VISIBLE_LOG_EVIDENCE_LINES)
47
+ .map((line) => stripAnsi(line));
48
+ if (lines.length === 0) return undefined;
49
+ return { kind: "lines", head: lines.slice(-2), rest: lines.slice(0, -2) };
50
+ }
51
+ if (options.compact) return undefined;
52
+ const named = (row: { kind: string; namespace?: string; name: string }) =>
53
+ items.some(
54
+ (item) =>
55
+ item.subject?.name === row.name &&
56
+ sameKind(item.subject.kind, row.kind) &&
57
+ (!item.subject.namespace ||
58
+ row.namespace === undefined ||
59
+ item.subject.namespace === row.namespace),
60
+ );
61
+ switch (data.type) {
62
+ case "metrics":
63
+ // A chart placed as the cause or the symptom is the card.
64
+ return items.some(
65
+ (item) => item.role === "cause" || item.role === "symptom",
66
+ )
67
+ ? { kind: "chart" }
68
+ : undefined;
69
+ case "inventory": {
70
+ const entries = namedInventoryRows(
71
+ data.resources as InvestigationResourceSummary[],
72
+ items,
73
+ options.scopeNamespace,
74
+ );
75
+ return entries.length > 0 ? { kind: "rows", entries } : undefined;
76
+ }
77
+ case "ranking": {
78
+ const rows = data.rows
79
+ .filter((row) => row.target || named(row))
80
+ .slice(0, 3);
81
+ return rows.length > 0 ? { kind: "ranking", rows } : undefined;
82
+ }
83
+ case "posture": {
84
+ const findings = data.findings
85
+ .filter((finding) => finding.target || named(finding))
86
+ .slice(0, 3);
87
+ return findings.length > 0 ? { kind: "posture", findings } : undefined;
88
+ }
89
+ default:
90
+ return undefined;
91
+ }
92
+ }
@@ -446,12 +446,7 @@ describe("root-cause evidence resolution", () => {
446
446
  it("keeps an unadapted successful Radar check linkable to Activity", () => {
447
447
  const ref = evidenceRef("a", "b");
448
448
  const projection = project([
449
- tool(
450
- "metrics-1",
451
- "discover_metrics",
452
- { result: [1] },
453
- { evidenceRef: ref },
454
- ),
449
+ tool("metrics-1", "get_dashboard", { result: [1] }, { evidenceRef: ref }),
455
450
  ]);
456
451
 
457
452
  expect(projection.groups).toHaveLength(0);
@@ -501,7 +496,10 @@ describe("root-cause evidence resolution", () => {
501
496
  { status: "linked", refs: [priorRef] },
502
497
  1,
503
498
  ),
504
- ).toMatchObject({ status: "linked", links: [{ source: { stepId: "old" } }] });
499
+ ).toMatchObject({
500
+ status: "linked",
501
+ links: [{ source: { stepId: "old" } }],
502
+ });
505
503
  // A result read AFTER the assessment turn is not: the assessment could not
506
504
  // have seen it.
507
505
  expect(
@@ -15,7 +15,7 @@ export {
15
15
  isInvestigationEvidenceRef,
16
16
  resolveInvestigationRootCauseEvidence,
17
17
  } from "./identity";
18
- export { evidenceSemanticSnapshot } from "./observations";
18
+ export { evidenceSemanticSnapshot, sameKind } from "./observations";
19
19
  export type {
20
20
  InvestigationAccessCheck,
21
21
  InvestigationAlertInstance,
@@ -0,0 +1,26 @@
1
+ import { createContext } from "react";
2
+ import type { MetricsChangeCoverage } from "../investigationMetrics";
3
+ import type { DiagnosisResourceRef } from "../diagnoseEvidenceTypes";
4
+ import type { InvestigationCaseItem } from "../investigationCase";
5
+
6
+ /** The Timeline scope a changes card can open: one resource name in one namespace. */
7
+ export interface InvestigationTimelineScope {
8
+ namespace?: string;
9
+ name: string;
10
+ }
11
+
12
+ export const EvidenceNavigationContext = createContext<{
13
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
14
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
15
+ revealSourceId?: string;
16
+ revealRequestId?: number;
17
+ expandedGroupIds?: ReadonlySet<string>;
18
+ onGroupOpenChange?: (id: string, open: boolean) => void;
19
+ citedOrderByGroup?: ReadonlyMap<string, number>;
20
+ /** Change markers for each metrics observation, keyed by its source id. */
21
+ metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
22
+ /** Card- and revision-placed agent items, keyed by group id. */
23
+ caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
24
+ /** The hypothesis each `rules_out` item excludes, keyed by item. */
25
+ excludedByItem?: ReadonlyMap<string, string>;
26
+ }>({});
@@ -17,6 +17,7 @@ import {
17
17
  } from "../diagnoseEvidenceTypes";
18
18
  import { investigationResourceEvidenceSummary } from "../investigationResourceEvidenceModel";
19
19
  import { kindToPluralWithGroup } from "../../../utils/navigation";
20
+ import { pluralToKind } from "@skyhook-io/k8s-ui";
20
21
  import { stableHash } from "./identity";
21
22
  import { nonEmptyString, parseJSON, record, stringArray } from "./parse";
22
23
  import {
@@ -185,12 +186,22 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
185
186
  get_helm_release: "Helm release",
186
187
  get_subject_permissions: "Permissions",
187
188
  query_prometheus: "Prometheus query",
189
+ top_resources: "Resource ranking",
190
+ get_cluster_audit: "Posture findings",
191
+ get_cluster_upgrade_readiness: "Upgrade readiness",
192
+ list_helm_releases: "Helm releases",
193
+ list_packages: "Installed packages",
194
+ search: "Search results",
195
+ discover_metrics: "Metric discovery",
188
196
  };
189
197
  export function investigationResultLabel(
190
198
  source: InvestigationEvidenceSource,
191
199
  ): string {
192
200
  return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
193
201
  }
202
+ export function sameKind(left: string, right: string): boolean {
203
+ return pluralToKind(left).toLowerCase() === pluralToKind(right).toLowerCase();
204
+ }
194
205
  export function resourceMatchesTarget(
195
206
  target: InvestigationEvidenceTarget,
196
207
  resource: {
@@ -1774,7 +1774,7 @@ describe("strict evidence adapters", () => {
1774
1774
 
1775
1775
  it("keeps unknown tools in Activity and limits invalid known contracts", () => {
1776
1776
  const result = project([
1777
- tool("other", "discover_metrics", { data: "ignored" }),
1777
+ tool("other", "get_dashboard", { data: "ignored" }),
1778
1778
  tool("bad-events", "get_events", { events: [{ nope: true }] }),
1779
1779
  ]);
1780
1780
  expect(result.sources.map((source) => source.stepId)).toEqual([