@skyhook-io/radar-app 1.13.4 → 1.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
@@ -0,0 +1,439 @@
1
+ import { pluralToKind } from "@skyhook-io/k8s-ui";
2
+
3
+ import type {
4
+ DiagnosisEvidenceItem,
5
+ DiagnosisEvidenceRole,
6
+ DiagnosisEvidenceSubject,
7
+ DiagnosisRuledOut,
8
+ } from "../../api/diagnose";
9
+ import {
10
+ investigationEvidenceSubjectRef,
11
+ investigationSourceArgs,
12
+ isInvestigationEvidenceRef,
13
+ type InvestigationEvidenceGroup,
14
+ type InvestigationEvidenceObservation,
15
+ type InvestigationEvidenceProjection,
16
+ type InvestigationEvidenceSource,
17
+ } from "./investigationEvidence";
18
+
19
+ function nonEmptyString(value: unknown): value is string {
20
+ return typeof value === "string" && value !== "";
21
+ }
22
+
23
+ export type InvestigationCasePlacement = "card" | "revision" | "source";
24
+
25
+ /**
26
+ * One linked item of the agent's case, resolved against the captured
27
+ * evidence. `card` and `revision` items are pinned to exactly one observation;
28
+ * a `source` item could not be pinned and renders only beside its source in
29
+ * Assessment details.
30
+ */
31
+ export interface InvestigationCaseItem {
32
+ /** Position in Diagnosis.evidence; ruled-out entries point here. */
33
+ index: number;
34
+ role: DiagnosisEvidenceRole;
35
+ claim: string;
36
+ subject?: DiagnosisEvidenceSubject;
37
+ source: InvestigationEvidenceSource;
38
+ placement: InvestigationCasePlacement;
39
+ groupId?: string;
40
+ observation?: InvestigationEvidenceObservation;
41
+ }
42
+
43
+ export interface InvestigationCaseRuledOut {
44
+ hypothesis: string;
45
+ /** Always a card- or revision-placed item; unplaceable targets are not rendered. */
46
+ item: InvestigationCaseItem;
47
+ }
48
+
49
+ export interface InvestigationCaseResolution {
50
+ items: InvestigationCaseItem[];
51
+ ruledOut: InvestigationCaseRuledOut[];
52
+ }
53
+
54
+ /**
55
+ * Notes the agent put on cards in earlier assessments stay on those cards
56
+ * when a later turn takes over the pane's case, unless the later turn
57
+ * addressed the same card; a follow-up about one chart must not strip the
58
+ * initial assessment's reading from everything else. Ordering and the
59
+ * ruled-out list follow the live turn alone, and source-placed notes stay
60
+ * listed under their own assessment. `earlier` is newest first.
61
+ */
62
+ export function mergeInvestigationCases(
63
+ live: InvestigationCaseResolution | undefined,
64
+ earlier: readonly (InvestigationCaseResolution | undefined)[],
65
+ ): InvestigationCaseResolution | undefined {
66
+ const liveItems = live?.items ?? [];
67
+ const covered = new Set(
68
+ liveItems.flatMap((item) => (item.groupId ? [item.groupId] : [])),
69
+ );
70
+ const carried: InvestigationCaseItem[] = [];
71
+ for (const resolution of earlier) {
72
+ // The newest assessment that spoke about a group wins it outright, and it
73
+ // wins with everything it said: a card note and a pinned revision note are
74
+ // two readings of one group, not rivals. Coverage is therefore taken after
75
+ // the whole resolution, not as each of its items is carried.
76
+ const takenHere = new Set<string>();
77
+ for (const item of resolution?.items ?? []) {
78
+ if (item.placement === "source" || !item.groupId) continue;
79
+ if (covered.has(item.groupId)) continue;
80
+ takenHere.add(item.groupId);
81
+ carried.push(item);
82
+ }
83
+ for (const groupId of takenHere) covered.add(groupId);
84
+ }
85
+ if (carried.length === 0) return live;
86
+ return { items: [...liveItems, ...carried], ruledOut: live?.ruledOut ?? [] };
87
+ }
88
+
89
+ /**
90
+ * The subset of one assessment's items that survived into the case the pane
91
+ * actually renders. Matching is by value, not object identity: the merge is
92
+ * fed freshly resolved copies of every turn, so an identity test would report
93
+ * that an assessment's own note had vanished the moment any other turn was
94
+ * re-resolved.
95
+ */
96
+ export function investigationCaseItemsStillRendered(
97
+ assessmentItems: readonly InvestigationCaseItem[] | undefined,
98
+ renderedItems: readonly InvestigationCaseItem[] | undefined,
99
+ ): InvestigationCaseItem[] {
100
+ if (!assessmentItems?.length || !renderedItems?.length) return [];
101
+ const key = (item: InvestigationCaseItem) =>
102
+ [
103
+ item.index,
104
+ item.source.id,
105
+ item.placement,
106
+ item.groupId ?? "",
107
+ item.observation ? investigationCaseObservationKey(item.observation) : "",
108
+ ].join("\u0000");
109
+ const rendered = new Set(renderedItems.map(key));
110
+ return assessmentItems.filter((item) => rendered.has(key(item)));
111
+ }
112
+
113
+ /**
114
+ * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and the
115
+ * DiagnosisEvidenceRole union in api/diagnose.ts must list exactly these roles.
116
+ * A role the parser accepts but this set omits is bound server-side and then
117
+ * silently dropped here. Change all three together.
118
+ */
119
+ export const EVIDENCE_ROLES: ReadonlySet<string> =
120
+ new Set<DiagnosisEvidenceRole>([
121
+ "cause",
122
+ "symptom",
123
+ "context",
124
+ "benign",
125
+ "demoted",
126
+ "rules_out",
127
+ ]);
128
+
129
+ export function investigationCaseObservationKey(
130
+ observation: Pick<InvestigationEvidenceObservation, "source" | "revision">,
131
+ ): string {
132
+ return `${observation.source.id}#${observation.revision}`;
133
+ }
134
+
135
+ /**
136
+ * Resolves the agent's case for one assessment. Refs are re-validated against
137
+ * the assessment turn exactly like root-cause refs; an item whose ref does not
138
+ * resolve is dropped on its own. Placement follows the observation the
139
+ * (ref, subject) pair names: exactly one match pins the claim to that
140
+ * observation, anything else falls back to the source row.
141
+ */
142
+ export function resolveInvestigationCase(
143
+ projection: InvestigationEvidenceProjection,
144
+ diagnosis:
145
+ | { evidence?: DiagnosisEvidenceItem[]; ruledOut?: DiagnosisRuledOut[] }
146
+ | null
147
+ | undefined,
148
+ assessmentTurnIndex: number,
149
+ ): InvestigationCaseResolution {
150
+ const evidence = diagnosis?.evidence ?? [];
151
+ if (evidence.length === 0) return { items: [], ruledOut: [] };
152
+ const byRef = new Map<string, InvestigationEvidenceSource[]>();
153
+ for (const source of projection.evidenceRefSources) {
154
+ if (source.turnIndex !== assessmentTurnIndex || !source.evidenceRef)
155
+ continue;
156
+ const matches = byRef.get(source.evidenceRef) ?? [];
157
+ matches.push(source);
158
+ byRef.set(source.evidenceRef, matches);
159
+ }
160
+ const citableSourceIds = new Set(
161
+ projection.citableSources
162
+ .filter((source) => source.turnIndex === assessmentTurnIndex)
163
+ .map((source) => source.id),
164
+ );
165
+ const items: InvestigationCaseItem[] = [];
166
+ const byIndex = new Map<number, InvestigationCaseItem>();
167
+ evidence.forEach((entry, index) => {
168
+ if (
169
+ entry.status !== "linked" ||
170
+ !entry.ref ||
171
+ !isInvestigationEvidenceRef(entry.ref) ||
172
+ !entry.role ||
173
+ !EVIDENCE_ROLES.has(entry.role)
174
+ ) {
175
+ return;
176
+ }
177
+ const matches = byRef.get(entry.ref);
178
+ if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) return;
179
+ const source = matches[0];
180
+ const subject = validCaseSubject(entry.subject);
181
+ // A subject the agent supplied but got wrong is not the same as one it
182
+ // deliberately omitted. Omission asks Radar to place the note anywhere the
183
+ // cited call produced; a malformed subject asked for something specific
184
+ // that does not resolve, so the note stays beside its source rather than
185
+ // landing on whichever observation happens to be the only one.
186
+ const subjectUnusable = entry.subject !== undefined && !subject;
187
+ const item: InvestigationCaseItem = {
188
+ index,
189
+ role: entry.role,
190
+ claim: typeof entry.claim === "string" ? entry.claim.trim() : "",
191
+ ...(subject ? { subject } : {}),
192
+ source,
193
+ placement: "source",
194
+ };
195
+ const candidates = subjectUnusable
196
+ ? []
197
+ : projection.groups.flatMap((group) =>
198
+ group.observations
199
+ .filter(
200
+ (observation) =>
201
+ observation.source.id === source.id &&
202
+ (!subject ||
203
+ observationMatchesSubject(group, observation, subject)),
204
+ )
205
+ .map((observation) => ({ group, observation })),
206
+ );
207
+ if (candidates.length === 1) {
208
+ const { group, observation } = candidates[0];
209
+ item.groupId = group.id;
210
+ item.observation = observation;
211
+ // The claim stays bound to the exact observation the agent cited. Only
212
+ // the card's authoritative observation carries it on the card head; a
213
+ // superseded read keeps its own revision row even when it displays the
214
+ // same, because display equivalence ignores counts and times.
215
+ item.placement = observation === group.latest ? "card" : "revision";
216
+ }
217
+ items.push(item);
218
+ byIndex.set(index, item);
219
+ });
220
+ const ruledOut: InvestigationCaseRuledOut[] = [];
221
+ const seenRuledOut = new Set<string>();
222
+ for (const entry of diagnosis?.ruledOut ?? []) {
223
+ if (
224
+ typeof entry.hypothesis !== "string" ||
225
+ !entry.hypothesis.trim() ||
226
+ !Number.isInteger(entry.evidenceIndex)
227
+ ) {
228
+ continue;
229
+ }
230
+ const item = byIndex.get(entry.evidenceIndex);
231
+ if (!item || item.placement === "source") continue;
232
+ const hypothesis = entry.hypothesis.trim();
233
+ const dedupeKey = `${entry.evidenceIndex}\u0000${hypothesis}`;
234
+ if (seenRuledOut.has(dedupeKey)) continue;
235
+ seenRuledOut.add(dedupeKey);
236
+ ruledOut.push({ hypothesis, item });
237
+ }
238
+ return { items, ruledOut };
239
+ }
240
+
241
+ function validCaseSubject(
242
+ value: DiagnosisEvidenceSubject | undefined,
243
+ ): DiagnosisEvidenceSubject | undefined {
244
+ if (!value || !nonEmptyString(value.kind) || !nonEmptyString(value.name))
245
+ return undefined;
246
+ const optional = (field: unknown) =>
247
+ field === undefined || typeof field === "string";
248
+ if (
249
+ !optional(value.group) ||
250
+ !optional(value.namespace) ||
251
+ !optional(value.container) ||
252
+ !optional(value.observation) ||
253
+ (value.stream !== undefined &&
254
+ value.stream !== "current" &&
255
+ value.stream !== "previous")
256
+ ) {
257
+ return undefined;
258
+ }
259
+ return value;
260
+ }
261
+
262
+ interface ObservationSubjectIdentity {
263
+ kind: string;
264
+ /** Empty string is a known core group; undefined means the producer did not say. */
265
+ group?: string;
266
+ /** Empty string is known cluster scope; undefined means the producer did not say. */
267
+ namespace?: string;
268
+ name: string;
269
+ container?: string;
270
+ stream?: "current" | "previous";
271
+ }
272
+
273
+ /**
274
+ * What an observation is about, for placing an agent claim. Observations that
275
+ * state no resource of their own (events, changes without a subject, receipts)
276
+ * inherit the resource their producing call was asked about, so a claim can
277
+ * name "the events of Deployment api" inside a diagnose bundle.
278
+ *
279
+ * A stated resource identity knows its API group and scope: a core resource
280
+ * has group "" and a cluster-scoped one has namespace "". Only args-derived
281
+ * identities leave those undefined, and only then do they act as wildcards.
282
+ */
283
+ function observationSubjectIdentity(
284
+ observation: InvestigationEvidenceObservation,
285
+ ): ObservationSubjectIdentity | undefined {
286
+ const { data } = observation;
287
+ const stated = investigationEvidenceSubjectRef(data);
288
+ if (stated) {
289
+ // Producers that state a resource from its own object, or from the
290
+ // investigation target, know the group exactly; a subject ref copied from
291
+ // another producer's payload may not.
292
+ const groupKnown =
293
+ data.type === "resource" ||
294
+ data.type === "issue" ||
295
+ data.type === "logs" ||
296
+ data.type === "crash" ||
297
+ data.type === "startup" ||
298
+ data.type === "helm" ||
299
+ data.type === "permissions" ||
300
+ data.type === "metrics";
301
+ return {
302
+ kind: stated.kind,
303
+ group: stated.group ?? (groupKnown ? "" : undefined),
304
+ namespace: stated.namespace ?? "",
305
+ name: stated.name,
306
+ ...(data.type === "logs"
307
+ ? {
308
+ container: data.container,
309
+ stream: data.previous ? "previous" : "current",
310
+ }
311
+ : {}),
312
+ };
313
+ }
314
+ if (data.type === "changes" && data.subject?.kind) {
315
+ return {
316
+ kind: data.subject.kind,
317
+ namespace: data.subject.namespace,
318
+ name: data.subject.name,
319
+ };
320
+ }
321
+ const args = investigationSourceArgs(observation.source);
322
+ if (!args || !nonEmptyString(args.kind) || !nonEmptyString(args.name))
323
+ return undefined;
324
+ return {
325
+ kind: args.kind,
326
+ group: nonEmptyString(args.group) ? args.group : undefined,
327
+ namespace: nonEmptyString(args.namespace) ? args.namespace : undefined,
328
+ name: args.name,
329
+ };
330
+ }
331
+
332
+ function sameKind(left: string, right: string): boolean {
333
+ return pluralToKind(left).toLowerCase() === pluralToKind(right).toLowerCase();
334
+ }
335
+
336
+ /**
337
+ * A discriminator the agent omits is a wildcard, and so is one the producer
338
+ * did not state; every discriminator both sides supply must match. Uniqueness
339
+ * of the match, not completeness of the subject, is what places a claim.
340
+ */
341
+ function observationMatchesSubject(
342
+ group: InvestigationEvidenceGroup,
343
+ observation: InvestigationEvidenceObservation,
344
+ subject: DiagnosisEvidenceSubject,
345
+ ): boolean {
346
+ if (subject.observation !== undefined) {
347
+ // A diagnose bundle captures several vitals charts for one resource, so
348
+ // "metrics" alone cannot name one; "metrics:<category>" picks the chart
349
+ // whose identity ends in that category. An agent-run query is one chart,
350
+ // so a qualifier on it carries no meaning.
351
+ const [kind, qualifier] = subject.observation.toLowerCase().split(":", 2);
352
+ if (kind !== observation.data.type) return false;
353
+ if (
354
+ qualifier !== undefined &&
355
+ observation.data.type === "metrics" &&
356
+ observation.data.origin === "diagnose" &&
357
+ !group.identity.endsWith(`:${qualifier}`)
358
+ ) {
359
+ return false;
360
+ }
361
+ }
362
+ const identities = observationIdentities(observation);
363
+ // An observation that states no resource of its own (an agent-run query
364
+ // whose selectors do not name the target exactly, a topology summary) can
365
+ // only be named by its evidence kind; the ref and the uniqueness rule do
366
+ // the rest.
367
+ if (identities.length === 0) return subject.observation !== undefined;
368
+ return identities.some((identity) =>
369
+ identityMatchesSubject(identity, observation, subject),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * A log stream is identified by its pod, and also by the workload its
375
+ * producing call was asked about: an agent naming "the Deployment's
376
+ * container logs" means the stream a diagnose bundle read for that
377
+ * Deployment, and uniqueness still decides whether that names one.
378
+ */
379
+ function observationIdentities(
380
+ observation: InvestigationEvidenceObservation,
381
+ ): ObservationSubjectIdentity[] {
382
+ const stated = observationSubjectIdentity(observation);
383
+ const identities = stated ? [stated] : [];
384
+ if (observation.data.type === "logs" && stated) {
385
+ const args = investigationSourceArgs(observation.source);
386
+ if (args && nonEmptyString(args.kind) && nonEmptyString(args.name)) {
387
+ identities.push({
388
+ kind: args.kind,
389
+ group: nonEmptyString(args.group) ? args.group : undefined,
390
+ namespace: nonEmptyString(args.namespace) ? args.namespace : undefined,
391
+ name: args.name,
392
+ container: stated.container,
393
+ stream: stated.stream,
394
+ });
395
+ }
396
+ }
397
+ return identities;
398
+ }
399
+
400
+ function identityMatchesSubject(
401
+ identity: ObservationSubjectIdentity,
402
+ observation: InvestigationEvidenceObservation,
403
+ subject: DiagnosisEvidenceSubject,
404
+ ): boolean {
405
+ if (!sameKind(identity.kind, subject.kind) || identity.name !== subject.name)
406
+ return false;
407
+ if (
408
+ subject.group !== undefined &&
409
+ identity.group !== undefined &&
410
+ subject.group.toLowerCase() !== identity.group.toLowerCase()
411
+ ) {
412
+ return false;
413
+ }
414
+ if (
415
+ subject.namespace !== undefined &&
416
+ identity.namespace !== undefined &&
417
+ subject.namespace !== identity.namespace
418
+ ) {
419
+ return false;
420
+ }
421
+ // Container and stream are log-stream dimensions. Without an observation
422
+ // kind they say "a log stream"; with one stated for another kind (a
423
+ // container-scoped metrics query, say) they are descriptive only.
424
+ const streamDimensions =
425
+ observation.data.type === "logs" || subject.observation === undefined;
426
+ if (
427
+ streamDimensions &&
428
+ subject.container !== undefined &&
429
+ identity.container !== subject.container
430
+ )
431
+ return false;
432
+ if (
433
+ streamDimensions &&
434
+ subject.stream !== undefined &&
435
+ identity.stream !== subject.stream
436
+ )
437
+ return false;
438
+ return true;
439
+ }