@skyhook-io/radar-app 1.14.2 → 1.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. package/src/components/ui/Disclosure.tsx +0 -47
@@ -0,0 +1,660 @@
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("lists Helm releases with their status and chart", () => {
438
+ const projection = project([
439
+ tool(
440
+ "helm",
441
+ "list_helm_releases",
442
+ [
443
+ {
444
+ name: "podinfo",
445
+ namespace: "shop",
446
+ chart: "podinfo",
447
+ chartVersion: "6.15.0",
448
+ status: "deployed",
449
+ revision: 3,
450
+ updated: "2026-09-07T07:00:00Z",
451
+ },
452
+ {
453
+ name: "redis",
454
+ namespace: "shop",
455
+ chart: "redis",
456
+ chartVersion: "19.0.1",
457
+ status: "failed",
458
+ revision: 2,
459
+ updated: "2026-09-07T07:00:00Z",
460
+ healthIssue: "0/1 pods ready",
461
+ },
462
+ ],
463
+ { summary: JSON.stringify({ namespace: "shop" }) },
464
+ ),
465
+ ]);
466
+ const [group] = groupsOf(projection.groups, "inventory");
467
+ expect(group.latest.title).toBe("Helm releases in shop");
468
+ const data = group.latest.data;
469
+ if (data.type !== "inventory") throw new Error("expected inventory");
470
+ expect(data.resources.map((row) => row.status)).toEqual([
471
+ "deployed · podinfo 6.15.0",
472
+ "failed · redis 19.0.1",
473
+ ]);
474
+ expect(data.resources[1].issue).toBe("0/1 pods ready");
475
+ });
476
+
477
+ it("lists packages with their version and sources", () => {
478
+ const projection = project([
479
+ tool(
480
+ "pkgs",
481
+ "list_packages",
482
+ {
483
+ packages: [
484
+ {
485
+ chart: "podinfo",
486
+ namespace: "shop",
487
+ releaseName: "podinfo",
488
+ version: "6.15.0",
489
+ health: "healthy",
490
+ sources: ["H", "F"],
491
+ },
492
+ {
493
+ chart: "redis",
494
+ namespace: "shop",
495
+ releaseName: "redis",
496
+ version: "1.0.0",
497
+ health: "unhealthy",
498
+ sources: ["H"],
499
+ },
500
+ ],
501
+ sourceLegend: { H: "Helm", F: "Flux" },
502
+ },
503
+ { summary: JSON.stringify({ namespace: "shop" }) },
504
+ ),
505
+ ]);
506
+ const [group] = groupsOf(projection.groups, "inventory");
507
+ expect(group.latest.title).toBe("Packages in shop");
508
+ expect(group.latest.tone).toBe("warning");
509
+ const data = group.latest.data;
510
+ if (data.type !== "inventory") throw new Error("expected inventory");
511
+ expect(data.resources[0]).toMatchObject({
512
+ kind: "Package",
513
+ name: "podinfo",
514
+ status: "6.15.0 · Helm, Flux",
515
+ });
516
+ expect(data.resources[1].issue).toBe("unhealthy");
517
+ });
518
+
519
+ it("keeps a cluster-wide Helm listing and a package listing as separate cards", () => {
520
+ const projection = project([
521
+ tool(
522
+ "helm",
523
+ "list_helm_releases",
524
+ [
525
+ {
526
+ name: "podinfo",
527
+ namespace: "shop",
528
+ chart: "podinfo",
529
+ chartVersion: "6.15.0",
530
+ status: "deployed",
531
+ revision: 1,
532
+ updated: "2026-09-07T07:00:00Z",
533
+ },
534
+ ],
535
+ { summary: JSON.stringify({}) },
536
+ ),
537
+ tool(
538
+ "pkgs",
539
+ "list_packages",
540
+ {
541
+ packages: [
542
+ {
543
+ chart: "podinfo",
544
+ namespace: "shop",
545
+ releaseName: "podinfo",
546
+ version: "6.15.0",
547
+ health: "healthy",
548
+ sources: ["H"],
549
+ },
550
+ ],
551
+ sourceLegend: { H: "Helm" },
552
+ },
553
+ { summary: JSON.stringify({}) },
554
+ ),
555
+ ]);
556
+ expect(
557
+ groupsOf(projection.groups, "inventory").map(
558
+ (group) => group.latest.title,
559
+ ),
560
+ ).toEqual(["Helm releases", "Installed packages"]);
561
+ });
562
+
563
+ it("leads a listing with the rows its citations name, named by the kind the agent knows them as", () => {
564
+ const rows = [
565
+ { kind: "Package", name: "argo-cd", status: "v3" },
566
+ { kind: "Package", name: "flux", status: "v2" },
567
+ { kind: "Package", namespace: "shop", name: "podinfo", status: "6.15.0" },
568
+ ];
569
+ const named = namedInventoryRows(rows, [
570
+ {
571
+ subject: {
572
+ group: "helm.toolkit.fluxcd.io",
573
+ kind: "HelmRelease",
574
+ namespace: "flux-system",
575
+ name: "podinfo",
576
+ },
577
+ },
578
+ ]);
579
+ expect(named).toEqual([
580
+ expect.objectContaining({ matches: 1, row: rows[2] }),
581
+ ]);
582
+ const html = renderToStaticMarkup(
583
+ <InventoryBody
584
+ data={{ type: "inventory", resources: rows, scope: "cluster" }}
585
+ cited={[rows[2]]}
586
+ />,
587
+ );
588
+ const first = html.indexOf("podinfo");
589
+ const marker = html.indexOf('data-inventory-row="cited"');
590
+ expect(marker).toBeGreaterThan(-1);
591
+ expect(marker).toBeLessThan(first);
592
+ expect(first).toBeLessThan(html.indexOf("argo-cd"));
593
+ expect(html.match(/shop\/podinfo/g)).toHaveLength(1);
594
+ });
595
+
596
+ it("lists search hits with the field that matched, and renders it", () => {
597
+ const projection = project([
598
+ tool(
599
+ "search",
600
+ "search",
601
+ {
602
+ hits: [
603
+ {
604
+ kind: "ConfigMap",
605
+ namespace: "shop",
606
+ name: "nginx-config",
607
+ matched: [
608
+ {
609
+ token: "missing.conf",
610
+ site: "content:data.nginx.conf",
611
+ score: 3,
612
+ },
613
+ ],
614
+ snippets: [
615
+ {
616
+ token: "missing.conf",
617
+ path: "data.nginx.conf",
618
+ snippet: "include /etc/nginx/missing.conf;",
619
+ },
620
+ ],
621
+ },
622
+ ],
623
+ total: 1,
624
+ searched: 120,
625
+ },
626
+ { summary: JSON.stringify({ query: "missing.conf" }) },
627
+ ),
628
+ ]);
629
+ const [group] = groupsOf(projection.groups, "inventory");
630
+ expect(group.latest.title).toBe("Search results for “missing.conf”");
631
+ const data = group.latest.data;
632
+ if (data.type !== "inventory") throw new Error("expected inventory");
633
+ expect(data.resources[0].match).toBe(
634
+ "data.nginx.conf: include /etc/nginx/missing.conf;",
635
+ );
636
+ const html = renderToStaticMarkup(<InventoryBody data={data} />);
637
+ expect(html).toContain("include /etc/nginx/missing.conf;");
638
+ });
639
+ });
640
+
641
+ describe("metric discovery receipt", () => {
642
+ it("files what was searched and how much came back", () => {
643
+ const projection = project([
644
+ tool(
645
+ "disc",
646
+ "discover_metrics",
647
+ {
648
+ match: '{namespace="shop"}',
649
+ count: 42,
650
+ metrics: [],
651
+ truncated: false,
652
+ },
653
+ { summary: JSON.stringify({ match: '{namespace="shop"}' }) },
654
+ ),
655
+ ]);
656
+ const [group] = groupsOf(projection.groups, "receipt");
657
+ expect(group.latest.title).toBe('Metric names matching {namespace="shop"}');
658
+ expect(group.latest.summary).toBe("42 metrics · last hour");
659
+ });
660
+ });