@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.
- package/package.json +2 -2
- package/src/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- 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({
|
|
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", "
|
|
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([
|