@skyhook-io/radar-app 1.14.2 → 1.14.4

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 (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
@@ -0,0 +1,682 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { deployment, groupsOf, project, tool } from "../evidenceFixtures";
4
+ import { RankingBody } from "../bodies/ranking";
5
+ import { PostureBody } from "../bodies/posture";
6
+ import { InventoryBody, namedInventoryRows } from "../bodies/inventory";
7
+ import { severityBadge } from "../cardParts";
8
+
9
+ const targetBundle = {
10
+ resource: {
11
+ apiVersion: "apps/v1",
12
+ kind: "Deployment",
13
+ metadata: { namespace: "shop", name: "api" },
14
+ spec: { replicas: 1 },
15
+ status: { readyReplicas: 1 },
16
+ },
17
+ pods: 1,
18
+ podNames: ["api-7f6-abc"],
19
+ events: [],
20
+ };
21
+
22
+ describe("ranking card (top_resources)", () => {
23
+ it("marks the target's own pod and renders the rows", () => {
24
+ const projection = project([
25
+ tool("diag", "diagnose", targetBundle),
26
+ tool(
27
+ "top",
28
+ "top_resources",
29
+ {
30
+ kind: "pods",
31
+ sort: "memory",
32
+ metricsAvailable: true,
33
+ items: [
34
+ {
35
+ kind: "Pod",
36
+ namespace: "shop",
37
+ name: "worker-1",
38
+ cpuMilli: 120,
39
+ memoryMi: 900,
40
+ restarts: 0,
41
+ },
42
+ {
43
+ kind: "Pod",
44
+ namespace: "shop",
45
+ name: "api-7f6-abc",
46
+ owner: { group: "apps", kind: "Deployment", name: "api" },
47
+ cpuMilli: 40,
48
+ memoryMi: 512,
49
+ memoryLimitMi: 600,
50
+ restarts: 3,
51
+ },
52
+ ],
53
+ },
54
+ {
55
+ summary: JSON.stringify({
56
+ kind: "pods",
57
+ namespace: "shop",
58
+ sort: "memory",
59
+ }),
60
+ },
61
+ ),
62
+ ]);
63
+ const [group] = groupsOf(projection.groups, "ranking");
64
+ expect(group.latest.title).toBe("Top pods by memory");
65
+ expect(group.latest.relevance).toBe("producer-related");
66
+ const data = group.latest.data;
67
+ if (data.type !== "ranking") throw new Error("expected a ranking");
68
+ expect(data.rows.map((row) => [row.name, row.target])).toEqual([
69
+ ["worker-1", false],
70
+ ["api-7f6-abc", true],
71
+ ]);
72
+ const html = renderToStaticMarkup(<RankingBody data={data} />);
73
+ expect(data.rows[1].owner).toEqual({
74
+ group: "apps",
75
+ kind: "Deployment",
76
+ namespace: "shop",
77
+ name: "api",
78
+ });
79
+ expect(html).toContain('data-ranking-row="target"');
80
+ expect(html).toContain("512Mi");
81
+ expect(html).toContain("/600Mi");
82
+ expect(html).toContain("3 restarts");
83
+ });
84
+
85
+ it("says when a pod ranking of the workload's namespace holds none of its pods, and how many the tool skipped", () => {
86
+ const projection = project([
87
+ tool("diag", "diagnose", targetBundle),
88
+ tool(
89
+ "top",
90
+ "top_resources",
91
+ {
92
+ kind: "pods",
93
+ sort: "cpu",
94
+ metricsAvailable: true,
95
+ skippedNoMetrics: 3,
96
+ items: [
97
+ {
98
+ kind: "Pod",
99
+ namespace: "shop",
100
+ name: "worker-1",
101
+ cpuMilli: 120,
102
+ memoryMi: 900,
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ summary: JSON.stringify({
108
+ kind: "pods",
109
+ namespace: "shop",
110
+ sort: "cpu",
111
+ }),
112
+ },
113
+ ),
114
+ tool(
115
+ "top-other",
116
+ "top_resources",
117
+ {
118
+ kind: "pods",
119
+ sort: "cpu",
120
+ metricsAvailable: true,
121
+ items: [
122
+ {
123
+ kind: "Pod",
124
+ namespace: "other",
125
+ name: "worker-2",
126
+ cpuMilli: 120,
127
+ memoryMi: 900,
128
+ },
129
+ ],
130
+ },
131
+ {
132
+ summary: JSON.stringify({
133
+ kind: "pods",
134
+ namespace: "other",
135
+ sort: "cpu",
136
+ }),
137
+ },
138
+ ),
139
+ tool(
140
+ "top-nodes",
141
+ "top_resources",
142
+ {
143
+ kind: "nodes",
144
+ sort: "cpu",
145
+ metricsAvailable: true,
146
+ skippedNoMetrics: 1,
147
+ items: [
148
+ { kind: "Node", name: "worker-a", cpuMilli: 900, memoryMi: 4000 },
149
+ ],
150
+ },
151
+ { summary: JSON.stringify({ kind: "nodes", sort: "cpu" }) },
152
+ ),
153
+ ]);
154
+ const [own, other, nodes] = groupsOf(projection.groups, "ranking");
155
+ expect(own.latest.summary).toBe(
156
+ "1 ranked · in shop · this workload's pods aren't in these results · 3 pods omitted: no metrics",
157
+ );
158
+ expect(other.latest.summary).toBe("1 ranked · in other");
159
+ expect(nodes.latest.summary).toBe(
160
+ "1 ranked · cluster-wide · 1 node omitted: no metrics",
161
+ );
162
+ });
163
+
164
+ it("records a limit, not a card, when live metrics were unavailable", () => {
165
+ const projection = project([
166
+ tool("top", "top_resources", {
167
+ kind: "pods",
168
+ sort: "cpu",
169
+ metricsAvailable: false,
170
+ reason: "metrics.k8s.io is not registered",
171
+ }),
172
+ ]);
173
+ expect(groupsOf(projection.groups, "ranking")).toHaveLength(0);
174
+ expect(projection.limitations).toContainEqual(
175
+ expect.objectContaining({ source: "Live metrics" }),
176
+ );
177
+ });
178
+ });
179
+
180
+ describe("posture cards (audit, upgrade readiness)", () => {
181
+ it("keeps audit findings about the target, and marks them", () => {
182
+ const projection = project([
183
+ tool("diag", "diagnose", targetBundle),
184
+ tool(
185
+ "audit",
186
+ "get_cluster_audit",
187
+ {
188
+ summary: {
189
+ critical: 0,
190
+ high: 1,
191
+ medium: 2,
192
+ low: 0,
193
+ resources: 3,
194
+ categories: {},
195
+ },
196
+ findings: [
197
+ {
198
+ resource: "Deployment/shop/api",
199
+ check: "runAsRoot",
200
+ severity: "high",
201
+ category: "Security",
202
+ message: "Container runs as root.",
203
+ remediation: "Set runAsNonRoot: true.",
204
+ },
205
+ {
206
+ resource: "Deployment/other/web",
207
+ check: "noLimits",
208
+ severity: "medium",
209
+ category: "Efficiency",
210
+ message: "No limits.",
211
+ },
212
+ ],
213
+ totalCount: 2,
214
+ },
215
+ { summary: JSON.stringify({ namespace: "shop" }) },
216
+ ),
217
+ ]);
218
+ const [group] = groupsOf(projection.groups, "posture");
219
+ expect(group.latest.relevance).toBe("target");
220
+ const data = group.latest.data;
221
+ if (data.type !== "posture") throw new Error("expected posture");
222
+ expect(
223
+ data.findings.map((finding) => [finding.name, finding.target]),
224
+ ).toEqual([["api", true]]);
225
+ const html = renderToStaticMarkup(<PostureBody data={data} />);
226
+ expect(html).toContain('data-posture-finding="target"');
227
+ expect(html).toContain("runAsRoot");
228
+ expect(html).toContain("Set runAsNonRoot: true.");
229
+ });
230
+
231
+ it("keeps two expansions of one upgrade scan as two cards", () => {
232
+ const check = (id: string, kind: string) => ({
233
+ currentVersion: "1.36.1",
234
+ targetVersion: "1.37",
235
+ check: {
236
+ id,
237
+ title: id,
238
+ category: "api",
239
+ status: "fail",
240
+ findings: [
241
+ {
242
+ title: `${id} finding`,
243
+ level: "blocker",
244
+ resource: { kind, namespace: "shop", name: "api" },
245
+ },
246
+ ],
247
+ },
248
+ });
249
+ const projection = project([
250
+ tool("diag", "diagnose", targetBundle),
251
+ tool(
252
+ "u1",
253
+ "get_cluster_upgrade_readiness",
254
+ check("removed-apis", "Deployment"),
255
+ {
256
+ summary: JSON.stringify({
257
+ targetVersion: "1.37",
258
+ check: "removed-apis",
259
+ }),
260
+ },
261
+ ),
262
+ tool(
263
+ "u2",
264
+ "get_cluster_upgrade_readiness",
265
+ check("webhooks", "Deployment"),
266
+ {
267
+ summary: JSON.stringify({ targetVersion: "1.37", check: "webhooks" }),
268
+ },
269
+ ),
270
+ ]);
271
+ const cards = groupsOf(projection.groups, "posture");
272
+ expect(cards).toHaveLength(2);
273
+ expect(cards.map((card) => card.observations.length)).toEqual([1, 1]);
274
+ expect(severityBadge("blocker")).toBe("error");
275
+ expect(severityBadge("review")).toBe("info");
276
+ });
277
+
278
+ it("files a scan with nothing about the target as a receipt", () => {
279
+ const projection = project([
280
+ tool("diag", "diagnose", targetBundle),
281
+ tool(
282
+ "audit",
283
+ "get_cluster_audit",
284
+ {
285
+ summary: {
286
+ critical: 0,
287
+ high: 0,
288
+ medium: 1,
289
+ low: 0,
290
+ resources: 1,
291
+ categories: {},
292
+ },
293
+ findings: [
294
+ {
295
+ resource: "Deployment/other/web",
296
+ check: "noLimits",
297
+ severity: "medium",
298
+ message: "No limits.",
299
+ },
300
+ ],
301
+ totalCount: 1,
302
+ },
303
+ { summary: JSON.stringify({ namespace: "shop" }) },
304
+ ),
305
+ ]);
306
+ expect(groupsOf(projection.groups, "posture")).toHaveLength(0);
307
+ const receipt = groupsOf(projection.groups, "receipt").find((group) =>
308
+ group.latest.title.startsWith("No posture"),
309
+ );
310
+ expect(receipt?.latest.title).toBe("No posture findings on this resource");
311
+ expect(
312
+ receipt?.latest.data.type === "receipt" && receipt.latest.data.message,
313
+ ).toBe("1 finding elsewhere in the scan.");
314
+ });
315
+
316
+ it("reads an upgrade check's findings by the resource they name", () => {
317
+ const projection = project([
318
+ tool("diag", "diagnose", targetBundle),
319
+ tool(
320
+ "upgrade",
321
+ "get_cluster_upgrade_readiness",
322
+ {
323
+ currentVersion: "1.36.1",
324
+ targetVersion: "1.37",
325
+ check: {
326
+ id: "removed-apis",
327
+ title: "Removed APIs",
328
+ category: "api",
329
+ status: "fail",
330
+ findings: [
331
+ {
332
+ title: "Uses autoscaling/v2beta2",
333
+ level: "blocker",
334
+ resource: {
335
+ kind: "HorizontalPodAutoscaler",
336
+ namespace: "shop",
337
+ name: "api",
338
+ },
339
+ managedBy: {
340
+ kind: "Deployment",
341
+ namespace: "shop",
342
+ name: "api",
343
+ },
344
+ evidence: {
345
+ source: "live",
346
+ path: "apiVersion",
347
+ detail: "autoscaling/v2beta2",
348
+ },
349
+ impact: "The object stops being served after the upgrade.",
350
+ remediation: "Migrate to autoscaling/v2.",
351
+ },
352
+ ],
353
+ },
354
+ },
355
+ { summary: JSON.stringify({ target: "1.37", check: "removed-apis" }) },
356
+ ),
357
+ ]);
358
+ const [group] = groupsOf(projection.groups, "posture");
359
+ expect(group.latest.title).toBe("Upgrade findings for 1.37");
360
+ const data = group.latest.data;
361
+ if (data.type !== "posture") throw new Error("expected posture");
362
+ expect(data.findings[0].severity).toBe("blocker");
363
+ expect(data.findings[0].message).toContain("stops being served");
364
+ });
365
+ });
366
+
367
+ describe("posture scans that did not finish", () => {
368
+ it("records a capped audit and a paged upgrade check as limits, and says on the receipt when inputs were missing", () => {
369
+ const projection = project([
370
+ tool("diagnose", "diagnose", {
371
+ resource: deployment,
372
+ resourceContext: { tier: "basic" },
373
+ }),
374
+ tool(
375
+ "audit",
376
+ "get_cluster_audit",
377
+ {
378
+ findings: [
379
+ {
380
+ resource: "Deployment/other/web",
381
+ check: "noLimits",
382
+ severity: "medium",
383
+ message: "No limits",
384
+ },
385
+ ],
386
+ truncated: true,
387
+ missingInputs: ["networkpolicies"],
388
+ },
389
+ { summary: JSON.stringify({ namespace: "shop" }) },
390
+ ),
391
+ tool(
392
+ "upgrade",
393
+ "get_cluster_upgrade_readiness",
394
+ {
395
+ currentVersion: "1.35.7",
396
+ targetVersion: "1.36",
397
+ check: {
398
+ id: "removed-apis",
399
+ title: "Removed APIs",
400
+ category: "api",
401
+ status: "fail",
402
+ findings: [],
403
+ findingsTruncated: 5,
404
+ },
405
+ },
406
+ {
407
+ summary: JSON.stringify({
408
+ targetVersion: "1.36",
409
+ check: "removed-apis",
410
+ }),
411
+ },
412
+ ),
413
+ ]);
414
+ expect(projection.limitations.map((limit) => limit.message)).toEqual(
415
+ expect.arrayContaining([
416
+ expect.stringContaining("hit its cap"),
417
+ expect.stringContaining(
418
+ "Inputs missing from the scan: networkpolicies",
419
+ ),
420
+ expect.stringContaining("5 more findings past this page"),
421
+ ]),
422
+ );
423
+ const receipt = groupsOf(projection.groups, "receipt").find(
424
+ (group) =>
425
+ group.latest.data.type === "receipt" &&
426
+ group.latest.data.checked === "posture",
427
+ )!;
428
+ const data = receipt.latest.data;
429
+ if (data.type !== "receipt") throw new Error("expected receipt");
430
+ expect(data.message).toContain(
431
+ "Not every check ran: inputs missing for networkpolicies",
432
+ );
433
+ });
434
+ });
435
+
436
+ describe("listing cards (helm releases, packages, search)", () => {
437
+ it.each([null, []])("accepts an empty Helm result: %j", (payload) => {
438
+ const projection = project([
439
+ tool("helm", "list_helm_releases", payload, {
440
+ summary: JSON.stringify({ namespace: "shop" }),
441
+ }),
442
+ ]);
443
+ expect(projection.groups).toHaveLength(0);
444
+ expect(projection.limitations).toEqual([
445
+ expect.objectContaining({
446
+ source: "Resource inventory",
447
+ kind: "unknown",
448
+ }),
449
+ ]);
450
+ });
451
+
452
+ it.each([{}, "", 0])("rejects malformed Helm results: %j", (payload) => {
453
+ const projection = project([tool("helm", "list_helm_releases", payload)]);
454
+ expect(projection.limitations).toEqual([
455
+ expect.objectContaining({ source: "Helm releases", kind: "unknown" }),
456
+ ]);
457
+ });
458
+
459
+ it("lists Helm releases with their status and chart", () => {
460
+ const projection = project([
461
+ tool(
462
+ "helm",
463
+ "list_helm_releases",
464
+ [
465
+ {
466
+ name: "podinfo",
467
+ namespace: "shop",
468
+ chart: "podinfo",
469
+ chartVersion: "6.15.0",
470
+ status: "deployed",
471
+ revision: 3,
472
+ updated: "2026-09-07T07:00:00Z",
473
+ },
474
+ {
475
+ name: "redis",
476
+ namespace: "shop",
477
+ chart: "redis",
478
+ chartVersion: "19.0.1",
479
+ status: "failed",
480
+ revision: 2,
481
+ updated: "2026-09-07T07:00:00Z",
482
+ healthIssue: "0/1 pods ready",
483
+ },
484
+ ],
485
+ { summary: JSON.stringify({ namespace: "shop" }) },
486
+ ),
487
+ ]);
488
+ const [group] = groupsOf(projection.groups, "inventory");
489
+ expect(group.latest.title).toBe("Helm releases in shop");
490
+ const data = group.latest.data;
491
+ if (data.type !== "inventory") throw new Error("expected inventory");
492
+ expect(data.resources.map((row) => row.status)).toEqual([
493
+ "deployed · podinfo 6.15.0",
494
+ "failed · redis 19.0.1",
495
+ ]);
496
+ expect(data.resources[1].issue).toBe("0/1 pods ready");
497
+ });
498
+
499
+ it("lists packages with their version and sources", () => {
500
+ const projection = project([
501
+ tool(
502
+ "pkgs",
503
+ "list_packages",
504
+ {
505
+ packages: [
506
+ {
507
+ chart: "podinfo",
508
+ namespace: "shop",
509
+ releaseName: "podinfo",
510
+ version: "6.15.0",
511
+ health: "healthy",
512
+ sources: ["H", "F"],
513
+ },
514
+ {
515
+ chart: "redis",
516
+ namespace: "shop",
517
+ releaseName: "redis",
518
+ version: "1.0.0",
519
+ health: "unhealthy",
520
+ sources: ["H"],
521
+ },
522
+ ],
523
+ sourceLegend: { H: "Helm", F: "Flux" },
524
+ },
525
+ { summary: JSON.stringify({ namespace: "shop" }) },
526
+ ),
527
+ ]);
528
+ const [group] = groupsOf(projection.groups, "inventory");
529
+ expect(group.latest.title).toBe("Packages in shop");
530
+ expect(group.latest.tone).toBe("warning");
531
+ const data = group.latest.data;
532
+ if (data.type !== "inventory") throw new Error("expected inventory");
533
+ expect(data.resources[0]).toMatchObject({
534
+ kind: "Package",
535
+ name: "podinfo",
536
+ status: "6.15.0 · Helm, Flux",
537
+ });
538
+ expect(data.resources[1].issue).toBe("unhealthy");
539
+ });
540
+
541
+ it("keeps a cluster-wide Helm listing and a package listing as separate cards", () => {
542
+ const projection = project([
543
+ tool(
544
+ "helm",
545
+ "list_helm_releases",
546
+ [
547
+ {
548
+ name: "podinfo",
549
+ namespace: "shop",
550
+ chart: "podinfo",
551
+ chartVersion: "6.15.0",
552
+ status: "deployed",
553
+ revision: 1,
554
+ updated: "2026-09-07T07:00:00Z",
555
+ },
556
+ ],
557
+ { summary: JSON.stringify({}) },
558
+ ),
559
+ tool(
560
+ "pkgs",
561
+ "list_packages",
562
+ {
563
+ packages: [
564
+ {
565
+ chart: "podinfo",
566
+ namespace: "shop",
567
+ releaseName: "podinfo",
568
+ version: "6.15.0",
569
+ health: "healthy",
570
+ sources: ["H"],
571
+ },
572
+ ],
573
+ sourceLegend: { H: "Helm" },
574
+ },
575
+ { summary: JSON.stringify({}) },
576
+ ),
577
+ ]);
578
+ expect(
579
+ groupsOf(projection.groups, "inventory").map(
580
+ (group) => group.latest.title,
581
+ ),
582
+ ).toEqual(["Helm releases", "Installed packages"]);
583
+ });
584
+
585
+ it("leads a listing with the rows its citations name, named by the kind the agent knows them as", () => {
586
+ const rows = [
587
+ { kind: "Package", name: "argo-cd", status: "v3" },
588
+ { kind: "Package", name: "flux", status: "v2" },
589
+ { kind: "Package", namespace: "shop", name: "podinfo", status: "6.15.0" },
590
+ ];
591
+ const named = namedInventoryRows(rows, [
592
+ {
593
+ subject: {
594
+ group: "helm.toolkit.fluxcd.io",
595
+ kind: "HelmRelease",
596
+ namespace: "flux-system",
597
+ name: "podinfo",
598
+ },
599
+ },
600
+ ]);
601
+ expect(named).toEqual([
602
+ expect.objectContaining({ matches: 1, row: rows[2] }),
603
+ ]);
604
+ const html = renderToStaticMarkup(
605
+ <InventoryBody
606
+ data={{ type: "inventory", resources: rows, scope: "cluster" }}
607
+ cited={[rows[2]]}
608
+ />,
609
+ );
610
+ const first = html.indexOf("podinfo");
611
+ const marker = html.indexOf('data-inventory-row="cited"');
612
+ expect(marker).toBeGreaterThan(-1);
613
+ expect(marker).toBeLessThan(first);
614
+ expect(first).toBeLessThan(html.indexOf("argo-cd"));
615
+ expect(html.match(/shop\/podinfo/g)).toHaveLength(1);
616
+ });
617
+
618
+ it("lists search hits with the field that matched, and renders it", () => {
619
+ const projection = project([
620
+ tool(
621
+ "search",
622
+ "search",
623
+ {
624
+ hits: [
625
+ {
626
+ kind: "ConfigMap",
627
+ namespace: "shop",
628
+ name: "nginx-config",
629
+ matched: [
630
+ {
631
+ token: "missing.conf",
632
+ site: "content:data.nginx.conf",
633
+ score: 3,
634
+ },
635
+ ],
636
+ snippets: [
637
+ {
638
+ token: "missing.conf",
639
+ path: "data.nginx.conf",
640
+ snippet: "include /etc/nginx/missing.conf;",
641
+ },
642
+ ],
643
+ },
644
+ ],
645
+ total: 1,
646
+ searched: 120,
647
+ },
648
+ { summary: JSON.stringify({ query: "missing.conf" }) },
649
+ ),
650
+ ]);
651
+ const [group] = groupsOf(projection.groups, "inventory");
652
+ expect(group.latest.title).toBe("Search results for “missing.conf”");
653
+ const data = group.latest.data;
654
+ if (data.type !== "inventory") throw new Error("expected inventory");
655
+ expect(data.resources[0].match).toBe(
656
+ "data.nginx.conf: include /etc/nginx/missing.conf;",
657
+ );
658
+ const html = renderToStaticMarkup(<InventoryBody data={data} />);
659
+ expect(html).toContain("include /etc/nginx/missing.conf;");
660
+ });
661
+ });
662
+
663
+ describe("metric discovery receipt", () => {
664
+ it("files what was searched and how much came back", () => {
665
+ const projection = project([
666
+ tool(
667
+ "disc",
668
+ "discover_metrics",
669
+ {
670
+ match: '{namespace="shop"}',
671
+ count: 42,
672
+ metrics: [],
673
+ truncated: false,
674
+ },
675
+ { summary: JSON.stringify({ match: '{namespace="shop"}' }) },
676
+ ),
677
+ ]);
678
+ const [group] = groupsOf(projection.groups, "receipt");
679
+ expect(group.latest.title).toBe('Metric names matching {namespace="shop"}');
680
+ expect(group.latest.summary).toBe("42 metrics · last hour");
681
+ });
682
+ });
@@ -264,7 +264,7 @@ describe("diagnose metrics evidence", () => {
264
264
  expect(groups.map((group) => group.latest.title)).toEqual([
265
265
  "CPU usage · Deployment shop/api",
266
266
  "Memory working set · Deployment shop/api",
267
- "Restarts · Deployment shop/api",
267
+ "Restarts in trailing 1h · Deployment shop/api",
268
268
  ]);
269
269
  for (const group of groups) {
270
270
  expect(group.latest.relevance).toBe("target");
@@ -289,6 +289,12 @@ describe("diagnose metrics evidence", () => {
289
289
  expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
290
290
  }
291
291
  const [cpu, , restarts] = groups;
292
+ expect(restarts.latest.title).toContain("Restarts in trailing 1h");
293
+ expect(restarts.latest.data).toMatchObject({
294
+ type: "metrics",
295
+ label: "Restarts in trailing 1h",
296
+ unit: "count",
297
+ });
292
298
  expect(cpu.latest.summary).toBe("2 current pods · 60m window · 1m2s step");
293
299
  const cpuData = cpu.latest.data;
294
300
  if (cpuData.type !== "metrics") throw new Error("expected metrics");
@@ -562,7 +568,7 @@ describe("diagnose metrics evidence", () => {
562
568
  ]);
563
569
  const groups = groupsOf(projection.groups, "metrics");
564
570
  expect(groups.map((group) => group.latest.title)).toEqual([
565
- "Restarts · Deployment shop/api",
571
+ "Restarts in trailing 1h · Deployment shop/api",
566
572
  ]);
567
573
  expect(
568
574
  projection.limitations.some(