@skyhook-io/radar-app 1.14.0 → 1.14.2

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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -0,0 +1,771 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+
4
+ import {
5
+ InvestigationEvidencePane,
6
+ namedInventoryRows,
7
+ } from "./InvestigationEvidencePane";
8
+ import { resolveInvestigationCase } from "./investigationCase";
9
+ import {
10
+ projectInvestigationEvidence,
11
+ type InvestigationEvidenceTimelineItem,
12
+ } from "./investigationEvidence";
13
+ import type { DiagnosisEvidenceItem } from "../../api/diagnose";
14
+
15
+ const target = {
16
+ kind: "Deployment",
17
+ group: "apps",
18
+ namespace: "shop",
19
+ name: "api",
20
+ };
21
+
22
+ function evidenceRef(scope: string, nonce: string): string {
23
+ return `ev_${scope.repeat(26)}_${nonce.repeat(26)}`;
24
+ }
25
+
26
+ function tool(
27
+ id: string,
28
+ name: string,
29
+ result: unknown,
30
+ patch: Partial<
31
+ Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }>
32
+ > = {},
33
+ ): Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }> {
34
+ return {
35
+ kind: "tool",
36
+ id,
37
+ tool: name,
38
+ status: "done",
39
+ summary: JSON.stringify({ namespace: "shop", name: "api" }),
40
+ result: JSON.stringify(result),
41
+ isError: false,
42
+ radarEvidence: true,
43
+ ...patch,
44
+ };
45
+ }
46
+
47
+ const crashIssue = {
48
+ id: "issue-api-crash",
49
+ severity: "critical",
50
+ source: "problem",
51
+ category: "crashloop",
52
+ category_group: "runtime",
53
+ grouping_scope: "workload",
54
+ kind: "Deployment",
55
+ group: "apps",
56
+ namespace: "shop",
57
+ name: "api",
58
+ reason: "CrashLoopBackOff",
59
+ message: "The API container keeps restarting.",
60
+ };
61
+
62
+ describe("InvestigationEvidencePane under a story", () => {
63
+ const ref = evidenceRef("a", "b");
64
+ const projection = projectInvestigationEvidence(
65
+ [
66
+ {
67
+ timeline: [
68
+ tool(
69
+ "issues",
70
+ "issues",
71
+ { issues: [crashIssue], total: 1, total_matched: 1 },
72
+ {
73
+ evidenceRef: ref,
74
+ },
75
+ ),
76
+ ],
77
+ },
78
+ ],
79
+ target,
80
+ );
81
+ const evidence: DiagnosisEvidenceItem[] = [
82
+ { status: "linked", ref, role: "cause", claim: "" },
83
+ { status: "unlinked" },
84
+ ];
85
+ const investigationCase = resolveInvestigationCase(
86
+ projection,
87
+ { evidence },
88
+ 0,
89
+ );
90
+ const render = (report: string) =>
91
+ renderToStaticMarkup(
92
+ <InvestigationEvidencePane
93
+ projection={projection}
94
+ investigationCase={investigationCase}
95
+ story={{ report, evidence }}
96
+ collecting={false}
97
+ animateGroupIds={new Set()}
98
+ onViewSource={() => {}}
99
+ onViewActivity={() => {}}
100
+ afterEvidence={<div data-next-steps>steps</div>}
101
+ />,
102
+ );
103
+
104
+ it("places the cited card in the story, marks it in the inventory, and puts next steps before it", () => {
105
+ const html = render(
106
+ "The container keeps dying.\n\n[[radar:evidence=0]]\n\nThat is the whole story.",
107
+ );
108
+ expect(html).toContain("data-story");
109
+ expect(html).toContain('id="story-');
110
+ expect(html).toContain("Captured results");
111
+ expect(html).toContain("1 shown above");
112
+ expect(html).toContain("In the analysis");
113
+ expect(html).not.toContain(">Evidence<");
114
+ // Next steps sit between the story and the inventory.
115
+ const story = html.indexOf("data-story");
116
+ const steps = html.indexOf("data-next-steps");
117
+ const inventory = html.indexOf("Captured results");
118
+ expect(story).toBeGreaterThan(-1);
119
+ expect(steps).toBeGreaterThan(story);
120
+ expect(inventory).toBeGreaterThan(steps);
121
+ // The inventory is collapsed once the story placed something.
122
+ expect(html).toMatch(/aria-controls="investigation-captured-results"[^>]*/);
123
+ expect(html).toMatch(
124
+ /aria-expanded="false"[^>]*aria-controls="investigation-captured-results"/,
125
+ );
126
+ });
127
+
128
+ it("shows lost support at the sentence and opens the inventory when nothing was placed", () => {
129
+ const html = render(
130
+ "Nothing placed, but see [[radar:evidence=1]] and [[radar:evidence=7]].",
131
+ );
132
+ expect(html).toContain('data-story-lost-support="unlinked"');
133
+ expect(html).toContain('data-story-lost-support="invalid"');
134
+ });
135
+
136
+ it("links a bound call that produced no card to its raw result in Activity", () => {
137
+ const searchRef = evidenceRef("e", "f");
138
+ const withSearch = projectInvestigationEvidence(
139
+ [
140
+ {
141
+ timeline: [
142
+ tool(
143
+ "issues",
144
+ "issues",
145
+ { issues: [crashIssue], total: 1, total_matched: 1 },
146
+ {
147
+ evidenceRef: ref,
148
+ },
149
+ ),
150
+ tool(
151
+ "search",
152
+ "search",
153
+ { results: [], total: 0 },
154
+ {
155
+ evidenceRef: searchRef,
156
+ summary: JSON.stringify({ query: "metrics-server" }),
157
+ },
158
+ ),
159
+ ],
160
+ },
161
+ ],
162
+ target,
163
+ );
164
+ const items: DiagnosisEvidenceItem[] = [
165
+ {
166
+ status: "linked",
167
+ ref: searchRef,
168
+ role: "rules_out",
169
+ claim: "Nothing named metrics-server exists.",
170
+ },
171
+ ];
172
+ const html = renderToStaticMarkup(
173
+ <InvestigationEvidencePane
174
+ projection={withSearch}
175
+ investigationCase={resolveInvestigationCase(
176
+ withSearch,
177
+ { evidence: items },
178
+ 0,
179
+ )}
180
+ story={{
181
+ report: "No metrics-server anywhere.\n\n[[radar:evidence=0]]",
182
+ evidence: items,
183
+ }}
184
+ collecting={false}
185
+ animateGroupIds={new Set()}
186
+ onViewSource={() => {}}
187
+ onViewActivity={() => {}}
188
+ />,
189
+ );
190
+ expect(html).toContain('data-story-lost-support="nocard"');
191
+ expect(html).toContain("Result in Activity");
192
+ expect(html).toContain("None shown above");
193
+ expect(html).toMatch(
194
+ /aria-expanded="true"[^>]*aria-controls="investigation-captured-results"/,
195
+ );
196
+ });
197
+
198
+ it("places a subject-less citation of a fan-out call on that call's primary card", () => {
199
+ const bundleRef = evidenceRef("c", "d");
200
+ const bundle = projectInvestigationEvidence(
201
+ [
202
+ {
203
+ timeline: [
204
+ tool(
205
+ "diag",
206
+ "diagnose",
207
+ {
208
+ resource: {
209
+ apiVersion: "apps/v1",
210
+ kind: "Deployment",
211
+ metadata: { name: "api", namespace: "shop" },
212
+ status: { replicas: 1, readyReplicas: 0 },
213
+ },
214
+ issues: [crashIssue],
215
+ events: [],
216
+ },
217
+ { evidenceRef: bundleRef },
218
+ ),
219
+ ],
220
+ },
221
+ ],
222
+ target,
223
+ );
224
+ const items: DiagnosisEvidenceItem[] = [
225
+ { status: "linked", ref: bundleRef, role: "context", claim: "" },
226
+ ];
227
+ const bundleCase = resolveInvestigationCase(bundle, { evidence: items }, 0);
228
+ // The resolver itself leaves the item at its source: several cards match.
229
+ expect(bundleCase.items[0]?.placement).toBe("source");
230
+ const html = renderToStaticMarkup(
231
+ <InvestigationEvidencePane
232
+ projection={bundle}
233
+ investigationCase={bundleCase}
234
+ story={{
235
+ report: "Radar's bundle says so.\n\n[[radar:evidence=0]]",
236
+ evidence: items,
237
+ }}
238
+ collecting={false}
239
+ animateGroupIds={new Set()}
240
+ onViewSource={() => {}}
241
+ onViewActivity={() => {}}
242
+ />,
243
+ );
244
+ expect(html).toContain('data-story-placement="0"');
245
+ expect(html).not.toContain("data-story-lost-support");
246
+ expect(html).toContain("1 shown above");
247
+ });
248
+
249
+ it("renders a cited earlier read as that read, labelled, when a newer one exists", () => {
250
+ const early = evidenceRef("a", "b");
251
+ const later = evidenceRef("c", "d");
252
+ const twoReads = projectInvestigationEvidence(
253
+ [
254
+ {
255
+ timeline: [
256
+ tool(
257
+ "issues-early",
258
+ "issues",
259
+ { issues: [crashIssue], total: 1, total_matched: 1 },
260
+ {
261
+ evidenceRef: early,
262
+ },
263
+ ),
264
+ ],
265
+ },
266
+ {
267
+ timeline: [
268
+ tool(
269
+ "issues-later",
270
+ "issues",
271
+ {
272
+ issues: [
273
+ {
274
+ ...crashIssue,
275
+ message:
276
+ "The API container keeps restarting, 40 times now.",
277
+ },
278
+ ],
279
+ total: 1,
280
+ total_matched: 1,
281
+ },
282
+ { evidenceRef: later },
283
+ ),
284
+ ],
285
+ },
286
+ ],
287
+ target,
288
+ );
289
+ const items: DiagnosisEvidenceItem[] = [
290
+ { status: "linked", ref: early, role: "cause", claim: "" },
291
+ ];
292
+ // A revised assessment in turn 1 citing the read from turn 0.
293
+ const html = renderToStaticMarkup(
294
+ <InvestigationEvidencePane
295
+ projection={twoReads}
296
+ investigationCase={resolveInvestigationCase(
297
+ twoReads,
298
+ { evidence: items },
299
+ 1,
300
+ )}
301
+ story={{
302
+ report: "It was crashing then.\n\n[[radar:evidence=0]]",
303
+ evidence: items,
304
+ }}
305
+ collecting={false}
306
+ animateGroupIds={new Set()}
307
+ onViewSource={() => {}}
308
+ onViewActivity={() => {}}
309
+ />,
310
+ );
311
+ expect(html).toContain('data-story-placement="0"');
312
+ expect(html).toContain("Captured in turn 1");
313
+ expect(html).toContain("a newer read");
314
+ });
315
+
316
+ it("binds a subject-less earlier citation to that call's own read, never a later one", () => {
317
+ const early = evidenceRef("a", "b");
318
+ const later = evidenceRef("c", "d");
319
+ const bundleResult = (ready: number) => ({
320
+ resource: {
321
+ apiVersion: "apps/v1",
322
+ kind: "Deployment",
323
+ metadata: { name: "api", namespace: "shop" },
324
+ status: { replicas: 1, readyReplicas: ready },
325
+ },
326
+ issues: [crashIssue],
327
+ events: [],
328
+ });
329
+ const twoBundles = projectInvestigationEvidence(
330
+ [
331
+ {
332
+ timeline: [
333
+ tool("diag-early", "diagnose", bundleResult(0), {
334
+ evidenceRef: early,
335
+ }),
336
+ ],
337
+ },
338
+ {
339
+ timeline: [
340
+ tool("diag-later", "diagnose", bundleResult(1), {
341
+ evidenceRef: later,
342
+ }),
343
+ ],
344
+ },
345
+ ],
346
+ target,
347
+ );
348
+ const items: DiagnosisEvidenceItem[] = [
349
+ { status: "linked", ref: early, role: "cause", claim: "" },
350
+ ];
351
+ const html = renderToStaticMarkup(
352
+ <InvestigationEvidencePane
353
+ projection={twoBundles}
354
+ investigationCase={resolveInvestigationCase(
355
+ twoBundles,
356
+ { evidence: items },
357
+ 1,
358
+ )}
359
+ story={{
360
+ report: "Then it was down.\n\n[[radar:evidence=0]]",
361
+ evidence: items,
362
+ }}
363
+ collecting={false}
364
+ animateGroupIds={new Set()}
365
+ onViewSource={() => {}}
366
+ onViewActivity={() => {}}
367
+ />,
368
+ );
369
+ const placed = html.slice(html.indexOf('data-story-placement="0"'));
370
+ expect(placed).toContain('data-evidence-source="turn-0-step-diag-early"');
371
+ expect(placed).toContain("Captured in turn 1");
372
+ });
373
+
374
+ it("keeps the previous layout without a story", () => {
375
+ const html = renderToStaticMarkup(
376
+ <InvestigationEvidencePane
377
+ projection={projection}
378
+ investigationCase={investigationCase}
379
+ collecting={false}
380
+ animateGroupIds={new Set()}
381
+ onViewSource={() => {}}
382
+ onViewActivity={() => {}}
383
+ afterEvidence={<div data-next-steps>steps</div>}
384
+ />,
385
+ );
386
+ expect(html).toContain(">Evidence<");
387
+ expect(html).not.toContain("Captured results");
388
+ expect(html.indexOf("data-next-steps")).toBeGreaterThan(
389
+ html.indexOf("More evidence about this resource"),
390
+ );
391
+ });
392
+ });
393
+
394
+ describe("story log card", () => {
395
+ const ref = evidenceRef("c", "d");
396
+ const projection = projectInvestigationEvidence(
397
+ [
398
+ {
399
+ timeline: [
400
+ tool(
401
+ "wl",
402
+ "get_workload_logs",
403
+ {
404
+ workload: "deployments/shop/api",
405
+ pods: 1,
406
+ logs: [
407
+ {
408
+ pod: "api-68c7b766dc-fmphn",
409
+ container: "api",
410
+ logs: {
411
+ lines: [
412
+ "2026-09-07T08:00:19Z boot",
413
+ "2026-09-07T08:00:20Z connecting",
414
+ "2026-09-07T08:00:21Z MongoServerError: Authentication failed.",
415
+ ],
416
+ totalLines: 50,
417
+ matchedLines: 0,
418
+ fallback: true,
419
+ },
420
+ },
421
+ ],
422
+ },
423
+ {
424
+ evidenceRef: ref,
425
+ summary: JSON.stringify({
426
+ namespace: "shop",
427
+ name: "api",
428
+ kind: "deployment",
429
+ }),
430
+ },
431
+ ),
432
+ ],
433
+ },
434
+ ],
435
+ target,
436
+ );
437
+ const evidence: DiagnosisEvidenceItem[] = [
438
+ {
439
+ status: "linked",
440
+ ref,
441
+ role: "cause",
442
+ claim: "The log names the failure.",
443
+ },
444
+ ];
445
+ const investigationCase = resolveInvestigationCase(
446
+ projection,
447
+ { evidence },
448
+ 0,
449
+ );
450
+ const html = renderToStaticMarkup(
451
+ <InvestigationEvidencePane
452
+ projection={projection}
453
+ investigationCase={investigationCase}
454
+ story={{
455
+ report: "The API cannot log in.\n\n[[radar:evidence=0]]\n",
456
+ evidence,
457
+ }}
458
+ collecting={false}
459
+ animateGroupIds={new Set()}
460
+ onViewSource={() => {}}
461
+ onViewActivity={() => {}}
462
+ />,
463
+ );
464
+ const story = html.slice(
465
+ html.indexOf('aria-label="Analysis"'),
466
+ html.indexOf("investigation-captured-results"),
467
+ );
468
+
469
+ it("shows the cited lines inline and keeps retrieval bookkeeping out of the story", () => {
470
+ expect(story).toContain("MongoServerError: Authentication failed.");
471
+ expect(story).toContain("Earlier lines");
472
+ expect(story).not.toContain("matched the filter");
473
+ expect(story).not.toContain("filter matched nothing");
474
+ expect(html).toContain("0 of 50 read lines matched the filter");
475
+ });
476
+ });
477
+
478
+ describe("story card gap clause", () => {
479
+ it("puts the clause on a compact card's row and under a full card without a label", () => {
480
+ const ref = evidenceRef("q", "r");
481
+ const projection = projectInvestigationEvidence(
482
+ [
483
+ {
484
+ timeline: [
485
+ tool(
486
+ "issues",
487
+ "issues",
488
+ { issues: [crashIssue], total: 1, total_matched: 1 },
489
+ {
490
+ evidenceRef: ref,
491
+ },
492
+ ),
493
+ ],
494
+ },
495
+ ],
496
+ target,
497
+ );
498
+ const evidence: DiagnosisEvidenceItem[] = [
499
+ {
500
+ status: "linked",
501
+ ref,
502
+ role: "cause",
503
+ claim: "",
504
+ gap: "this pod only, not the other replicas",
505
+ },
506
+ ];
507
+ const render = (report: string) =>
508
+ renderToStaticMarkup(
509
+ <InvestigationEvidencePane
510
+ projection={projection}
511
+ investigationCase={resolveInvestigationCase(
512
+ projection,
513
+ { evidence },
514
+ 0,
515
+ )}
516
+ story={{ report, evidence }}
517
+ collecting={false}
518
+ animateGroupIds={new Set()}
519
+ onViewSource={() => {}}
520
+ onViewActivity={() => {}}
521
+ />,
522
+ );
523
+ const compact = render("It crashes.\n\n[[radar:evidence=0|compact]]");
524
+ expect(compact).toContain("data-agent-gap-hint");
525
+ expect(compact).toContain("this pod only, not the other replicas");
526
+ expect(compact).not.toContain("not shown");
527
+ const full = render("It crashes.\n\n[[radar:evidence=0]]");
528
+ // The record under Captured results keeps its labelled form.
529
+ const story = full.slice(0, full.indexOf("Captured results"));
530
+ expect(story).toMatch(
531
+ /data-agent-gap[^>]*>this pod only, not the other replicas</,
532
+ );
533
+ expect(story).not.toContain("Not shown:");
534
+ });
535
+ });
536
+
537
+ describe("story card defaults", () => {
538
+ it("shows the row a citation names on a listing card, inline", () => {
539
+ const listRef = evidenceRef("k", "l");
540
+ const projection = projectInvestigationEvidence(
541
+ [
542
+ {
543
+ timeline: [
544
+ tool(
545
+ "cms",
546
+ "list_resources",
547
+ [
548
+ { kind: "ConfigMap", name: "kube-root-ca.crt" },
549
+ { kind: "ConfigMap", name: "nginx-config" },
550
+ ],
551
+ {
552
+ evidenceRef: listRef,
553
+ summary: JSON.stringify({
554
+ kind: "configmaps",
555
+ namespace: "shop",
556
+ }),
557
+ },
558
+ ),
559
+ ],
560
+ },
561
+ ],
562
+ target,
563
+ );
564
+ const evidence: DiagnosisEvidenceItem[] = [
565
+ {
566
+ status: "linked",
567
+ ref: listRef,
568
+ role: "context",
569
+ claim: "",
570
+ subject: {
571
+ kind: "ConfigMap",
572
+ namespace: "shop",
573
+ name: "nginx-config",
574
+ observation: "resource",
575
+ },
576
+ },
577
+ ];
578
+ const html = renderToStaticMarkup(
579
+ <InvestigationEvidencePane
580
+ projection={projection}
581
+ investigationCase={resolveInvestigationCase(
582
+ projection,
583
+ { evidence },
584
+ 0,
585
+ )}
586
+ story={{
587
+ report: "The config exists.\n\n[[radar:evidence=0]]",
588
+ evidence,
589
+ }}
590
+ collecting={false}
591
+ animateGroupIds={new Set()}
592
+ onViewSource={() => {}}
593
+ onViewActivity={() => {}}
594
+ />,
595
+ );
596
+ const start = html.indexOf("data-story-inventory-rows");
597
+ expect(start).toBeGreaterThan(-1);
598
+ // The inline block ends where the folded body (every row) begins.
599
+ const block = html.slice(start, html.indexOf('-body"', start));
600
+ expect(block).toContain("nginx-config");
601
+ expect(block).not.toContain("kube-root-ca.crt");
602
+ });
603
+
604
+ it("says when a cited entry is absent from the listing, or which entries a name could mean", () => {
605
+ expect(
606
+ namedInventoryRows(
607
+ [
608
+ { kind: "ConfigMap", namespace: "a", name: "config" },
609
+ { kind: "ConfigMap", namespace: "b", name: "config" },
610
+ ],
611
+ [
612
+ { subject: { kind: "ConfigMap", name: "config" } },
613
+ { subject: { kind: "ConfigMap", namespace: "b", name: "config" } },
614
+ { subject: { kind: "ConfigMap", name: "missing" } },
615
+ { subject: { kind: "ConfigMap", name: "missing" } },
616
+ ],
617
+ ),
618
+ ).toEqual([
619
+ { key: "/config", label: "config", matches: 2, row: undefined },
620
+ {
621
+ key: "b/config",
622
+ label: "b/config",
623
+ matches: 1,
624
+ row: { kind: "ConfigMap", namespace: "b", name: "config" },
625
+ },
626
+ { key: "/missing", label: "missing", matches: 0, row: undefined },
627
+ ]);
628
+ // Rows of a namespace-scoped listing omit their namespace: the scope is
629
+ // theirs, and only that namespace satisfies a namespaced citation.
630
+ const scoped = [{ kind: "ConfigMap", name: "config" }];
631
+ expect(
632
+ namedInventoryRows(
633
+ scoped,
634
+ [{ subject: { kind: "ConfigMap", namespace: "shop", name: "config" } }],
635
+ "shop",
636
+ )[0].matches,
637
+ ).toBe(1);
638
+ expect(
639
+ namedInventoryRows(
640
+ scoped,
641
+ [
642
+ {
643
+ subject: { kind: "ConfigMap", namespace: "other", name: "config" },
644
+ },
645
+ ],
646
+ "shop",
647
+ )[0].matches,
648
+ ).toBe(0);
649
+ expect(
650
+ namedInventoryRows(
651
+ [{ kind: "Namespace", name: "prod" }],
652
+ [{ subject: { kind: "Namespace", namespace: "other", name: "prod" } }],
653
+ )[0].matches,
654
+ ).toBe(0);
655
+ // A mixed listing holding a Service and a Deployment of one name shows
656
+ // the row of the kind the citation states.
657
+ expect(
658
+ namedInventoryRows(
659
+ [
660
+ { kind: "Service", namespace: "shop", name: "api" },
661
+ { kind: "Deployment", namespace: "shop", name: "api" },
662
+ ],
663
+ [{ subject: { kind: "Deployment", namespace: "shop", name: "api" } }],
664
+ )[0].row?.kind,
665
+ ).toBe("Deployment");
666
+ expect(
667
+ namedInventoryRows(
668
+ [{ kind: "APIService", name: "v1.apps" }],
669
+ [{ subject: { kind: "APIService", namespace: "", name: "v1.apps" } }],
670
+ )[0],
671
+ ).toEqual({
672
+ key: "/v1.apps",
673
+ label: "v1.apps",
674
+ matches: 1,
675
+ row: { kind: "APIService", name: "v1.apps" },
676
+ });
677
+ });
678
+
679
+ it("shows the chart inline on a metrics card placed as the symptom", () => {
680
+ const promRef = evidenceRef("m", "n");
681
+ const projection = projectInvestigationEvidence(
682
+ [
683
+ {
684
+ timeline: [
685
+ tool(
686
+ "diag",
687
+ "diagnose",
688
+ {
689
+ resource: {
690
+ apiVersion: "apps/v1",
691
+ kind: "Deployment",
692
+ metadata: { namespace: "shop", name: "api" },
693
+ spec: { replicas: 1 },
694
+ status: { replicas: 1, readyReplicas: 1 },
695
+ },
696
+ pods: 1,
697
+ podNames: ["api-1"],
698
+ events: [],
699
+ },
700
+ { evidenceRef: evidenceRef("o", "p") },
701
+ ),
702
+ tool(
703
+ "prom",
704
+ "query_prometheus",
705
+ {
706
+ query:
707
+ 'sum(container_memory_working_set_bytes{namespace="shop",pod=~"^(api-1)$"})',
708
+ type: "range",
709
+ start: "2026-09-06T07:17:23Z",
710
+ end: "2026-09-06T09:17:23Z",
711
+ step: "25s",
712
+ resultType: "matrix",
713
+ seriesCount: 1,
714
+ series: [
715
+ {
716
+ labels: { pod: "api-1" },
717
+ dataPoints: [
718
+ { timestamp: 1_788_679_043, value: 169_377_792 },
719
+ { timestamp: 1_788_679_068, value: 171_401_216 },
720
+ ],
721
+ },
722
+ ],
723
+ selectors: [
724
+ {
725
+ metric: "container_memory_working_set_bytes",
726
+ matchers: [
727
+ { label: "namespace", op: "=", value: "shop" },
728
+ { label: "pod", op: "=~", value: "^(api-1)$" },
729
+ ],
730
+ },
731
+ ],
732
+ },
733
+ { evidenceRef: promRef, summary: "{}" },
734
+ ),
735
+ ],
736
+ },
737
+ ],
738
+ target,
739
+ );
740
+ const evidence: DiagnosisEvidenceItem[] = [
741
+ {
742
+ status: "linked",
743
+ ref: promRef,
744
+ role: "symptom",
745
+ claim: "",
746
+ subject: {
747
+ kind: "Deployment",
748
+ namespace: "shop",
749
+ name: "api",
750
+ observation: "metrics",
751
+ },
752
+ },
753
+ ];
754
+ const html = renderToStaticMarkup(
755
+ <InvestigationEvidencePane
756
+ projection={projection}
757
+ investigationCase={resolveInvestigationCase(
758
+ projection,
759
+ { evidence },
760
+ 0,
761
+ )}
762
+ story={{ report: "Memory climbs.\n\n[[radar:evidence=0]]", evidence }}
763
+ collecting={false}
764
+ animateGroupIds={new Set()}
765
+ onViewSource={() => {}}
766
+ onViewActivity={() => {}}
767
+ />,
768
+ );
769
+ expect(html).toContain("data-story-metrics");
770
+ });
771
+ });