@skyhook-io/radar-app 1.13.3 → 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 (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -24,7 +24,12 @@ import {
24
24
  import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
25
25
  import type { Diagnosis } from "../../api/diagnose";
26
26
  import { AssessmentSources, ResultCard } from "./parts";
27
- import { investigationEvidenceCoverageLimited } from "./investigationState";
27
+ import {
28
+ investigationEvidenceConflictsWithHealthy,
29
+ investigationEvidenceCoverageLimited,
30
+ } from "./investigationState";
31
+ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
32
+ import { metricsChangeMarkers } from "./investigationMetrics";
28
33
 
29
34
  const onViewSource = vi.fn();
30
35
  const target = {
@@ -763,7 +768,7 @@ describe("InvestigationEvidencePane hierarchy and provenance", () => {
763
768
  const projection = project(
764
769
  tool(
765
770
  "metrics-cited",
766
- "query_prometheus",
771
+ "discover_metrics",
767
772
  { result: [1] },
768
773
  { evidenceRef: ref },
769
774
  ),
@@ -778,7 +783,7 @@ describe("InvestigationEvidencePane hierarchy and provenance", () => {
778
783
  const sources = renderToStaticMarkup(
779
784
  <AssessmentSources resolution={resolution} onViewSource={onViewSource} />,
780
785
  );
781
- expect(sources).toContain("Query Prometheus");
786
+ expect(sources).toContain("Discover Metrics");
782
787
  expect(sources).toContain("Sources used for this assessment");
783
788
  expect(html).not.toContain("Cited sources in Activity");
784
789
  expect(html).not.toContain("Agent-selected check");
@@ -1744,7 +1749,7 @@ describe("InvestigationEvidencePane honest result states", () => {
1744
1749
 
1745
1750
  const html = render(projection);
1746
1751
  expect(html).toContain("More evidence about this workload");
1747
- expect(html).toContain("No matching warning events");
1752
+ expect(html).toContain("No warning events");
1748
1753
  expect(html).not.toContain("What Radar did not find");
1749
1754
  expect(html).not.toContain(`${receipt!.id}-body`);
1750
1755
  });
@@ -1840,7 +1845,7 @@ describe("InvestigationEvidencePane honest result states", () => {
1840
1845
  expect(projection.coverage.checked).toBe(0);
1841
1846
  expect(projection.groups).toHaveLength(0);
1842
1847
  expect(html).not.toContain('id="investigation-checked-heading"');
1843
- expect(html).not.toContain("No matching warning events");
1848
+ expect(html).not.toContain("No warning events");
1844
1849
  expect(html).toContain("Evidence coverage is incomplete");
1845
1850
  });
1846
1851
 
@@ -2168,3 +2173,1439 @@ describe("InvestigationEvidencePane honest result states", () => {
2168
2173
  expect(html).toContain("CrashLoopBackOff");
2169
2174
  });
2170
2175
  });
2176
+
2177
+ describe("Track A evidence bodies and deep links", () => {
2178
+ const changeArgs = JSON.stringify({
2179
+ kind: "Deployment",
2180
+ namespace: "shop",
2181
+ name: "api",
2182
+ });
2183
+ const change = {
2184
+ kind: "Deployment",
2185
+ apiVersion: "apps/v1",
2186
+ namespace: "shop",
2187
+ name: "api",
2188
+ changeType: "update",
2189
+ timestamp: "2026-09-02T09:00:00Z",
2190
+ };
2191
+
2192
+ function renderWithNav(
2193
+ projection: InvestigationEvidenceProjection,
2194
+ nav: {
2195
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
2196
+ onOpenTimeline?: (scope: { namespace?: string; name: string }) => void;
2197
+ },
2198
+ ): string {
2199
+ return renderToStaticMarkup(
2200
+ <InvestigationEvidencePane
2201
+ projection={projection}
2202
+ collecting={false}
2203
+ animateGroupIds={new Set()}
2204
+ onViewSource={onViewSource}
2205
+ onViewActivity={() => {}}
2206
+ onOpenResource={nav.onOpenResource}
2207
+ onOpenTimeline={nav.onOpenTimeline}
2208
+ />,
2209
+ );
2210
+ }
2211
+
2212
+ it("renders each workload-logs stream as its own card from one source", () => {
2213
+ const projection = project(
2214
+ tool(
2215
+ "wl",
2216
+ "get_workload_logs",
2217
+ {
2218
+ workload: "deployments/shop/api",
2219
+ pods: 1,
2220
+ logs: [
2221
+ {
2222
+ pod: "api-68c7b766dc-fmphn",
2223
+ container: "api",
2224
+ logs: {
2225
+ lines: [
2226
+ "2026-09-07T08:00:21Z MongoServerError: Authentication failed.",
2227
+ ],
2228
+ totalLines: 50,
2229
+ matchedLines: 1,
2230
+ fallback: false,
2231
+ },
2232
+ },
2233
+ {
2234
+ pod: "api-68c7b766dc-fmphn",
2235
+ container: "istio-proxy",
2236
+ logs: {
2237
+ lines: ["2026-09-07T08:00:19Z info Envoy proxy is ready"],
2238
+ totalLines: 12,
2239
+ matchedLines: 0,
2240
+ fallback: true,
2241
+ },
2242
+ },
2243
+ ],
2244
+ },
2245
+ {
2246
+ summary: JSON.stringify({
2247
+ namespace: "shop",
2248
+ name: "api",
2249
+ kind: "deployment",
2250
+ }),
2251
+ },
2252
+ ),
2253
+ );
2254
+ const partition = partitionInvestigationEvidence(projection.groups);
2255
+ expect(partition.main.map((group) => group.latest.title)).toEqual([
2256
+ "Current logs · api-68c7b766dc-fmphn / api",
2257
+ ]);
2258
+ expect(partition.workload.map((group) => group.latest.title)).toEqual([
2259
+ "Current logs · api-68c7b766dc-fmphn / istio-proxy",
2260
+ ]);
2261
+ const html = render(projection, false, undefined, undefined, () => {});
2262
+ expect(html).toContain("MongoServerError: Authentication failed.");
2263
+ expect(html).toContain("Unfiltered log tail");
2264
+ expect(html).toContain(
2265
+ "Open current Pod shop/api-68c7b766dc-fmphn in Radar",
2266
+ );
2267
+ expect(
2268
+ html.match(
2269
+ new RegExp(investigationEvidenceSourceDomId("turn-0-step-wl"), "g"),
2270
+ ),
2271
+ ).toHaveLength(1);
2272
+ });
2273
+
2274
+ it("renders a firing alert rule with its target instance first and the rule text", () => {
2275
+ const projection = project(
2276
+ tool("diagnose", "diagnose", {
2277
+ resource: {
2278
+ apiVersion: "apps/v1",
2279
+ kind: "Deployment",
2280
+ metadata: { namespace: "shop", name: "api" },
2281
+ },
2282
+ resourceContext: { tier: "basic" },
2283
+ logsCurrent: [
2284
+ {
2285
+ pod: "api-68c7b766dc-fmphn",
2286
+ container: "api",
2287
+ logs: {
2288
+ lines: ["ready"],
2289
+ totalLines: 1,
2290
+ matchedLines: 0,
2291
+ fallback: true,
2292
+ },
2293
+ },
2294
+ ],
2295
+ }),
2296
+ tool(
2297
+ "rules",
2298
+ "get_prometheus_rules",
2299
+ {
2300
+ count: 1,
2301
+ rules: [
2302
+ {
2303
+ group: "kubernetes-apps",
2304
+ type: "alerting",
2305
+ name: "KubePodCrashLooping",
2306
+ query:
2307
+ 'max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}[5m]) >= 1',
2308
+ state: "firing",
2309
+ health: "ok",
2310
+ labels: { severity: "warning" },
2311
+ annotations: {
2312
+ summary: "Pod is crash looping.",
2313
+ description:
2314
+ "Pod {{ $labels.namespace }}/{{ $labels.pod }} is in waiting state (reason: CrashLoopBackOff).",
2315
+ },
2316
+ alerts: [
2317
+ {
2318
+ state: "firing",
2319
+ activeAt: "2026-09-07T07:58:00Z",
2320
+ value: "1e+00",
2321
+ labels: { namespace: "monitoring", pod: "other-abc" },
2322
+ },
2323
+ {
2324
+ state: "firing",
2325
+ activeAt: "2026-09-07T07:58:00Z",
2326
+ value: "1e+00",
2327
+ labels: {
2328
+ namespace: "shop",
2329
+ pod: "api-68c7b766dc-fmphn",
2330
+ container: "api",
2331
+ },
2332
+ },
2333
+ ],
2334
+ },
2335
+ ],
2336
+ },
2337
+ { summary: JSON.stringify({ state: "firing" }) },
2338
+ ),
2339
+ );
2340
+ const partition = partitionInvestigationEvidence(projection.groups);
2341
+ expect(
2342
+ partition.main
2343
+ .map((group) => group.latest.title)
2344
+ .filter((title) => title.startsWith("KubePodCrashLooping")),
2345
+ ).toEqual(["KubePodCrashLooping firing"]);
2346
+ const html = render(projection);
2347
+ expect(html).toContain("2 active instances, 1 naming Deployment shop/api");
2348
+ expect(html).toContain("names this resource");
2349
+ expect(html.indexOf("api-68c7b766dc-fmphn")).toBeLessThan(
2350
+ html.indexOf("other-abc"),
2351
+ );
2352
+ expect(html).toContain("Pod is crash looping.");
2353
+ expect(html).toContain("Rule expression");
2354
+ expect(html).toContain("kube_pod_container_status_waiting_reason");
2355
+ expect(html).toContain("a firing rule alone does not establish the cause");
2356
+ expect(html).not.toContain("Open current");
2357
+ });
2358
+
2359
+ it("renders a Helm release with its operation, health issue, and managed resources", () => {
2360
+ const onOpenResource = vi.fn();
2361
+ const projection = project(
2362
+ tool(
2363
+ "helm",
2364
+ "get_helm_release",
2365
+ {
2366
+ name: "shop",
2367
+ namespace: "shop",
2368
+ chart: "shop",
2369
+ chartVersion: "1.4.2",
2370
+ appVersion: "2.0.0",
2371
+ status: "pending-upgrade",
2372
+ revision: 8,
2373
+ updated: "2026-09-07T07:00:00Z",
2374
+ description: "Preparing upgrade",
2375
+ healthIssue: "Deployment shop/api has 0/1 ready replicas",
2376
+ lastOperation: {
2377
+ kind: "pending",
2378
+ status: "stuck_pending",
2379
+ source: "helm_status",
2380
+ confidence: "high",
2381
+ message: "Revision 8 has been pending-upgrade for 42m",
2382
+ },
2383
+ resources: [
2384
+ {
2385
+ kind: "Deployment",
2386
+ apiVersion: "apps/v1",
2387
+ name: "api",
2388
+ namespace: "shop",
2389
+ status: "Running",
2390
+ ready: "0/1",
2391
+ issue: "0/1 ready",
2392
+ },
2393
+ {
2394
+ kind: "Job",
2395
+ apiVersion: "batch/v1",
2396
+ name: "shop-migrate",
2397
+ namespace: "shop",
2398
+ status: "Complete",
2399
+ summary: "1/1 succeeded",
2400
+ },
2401
+ ],
2402
+ },
2403
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
2404
+ ),
2405
+ );
2406
+ const partition = partitionInvestigationEvidence(projection.groups);
2407
+ expect(partition.main.map((group) => group.latest.title)).toEqual([
2408
+ "Helm release shop/shop",
2409
+ ]);
2410
+ const html = render(
2411
+ projection,
2412
+ false,
2413
+ undefined,
2414
+ undefined,
2415
+ onOpenResource,
2416
+ );
2417
+ expect(html).toContain("pending-upgrade");
2418
+ expect(html).toContain("shop 1.4.2");
2419
+ expect(html).toContain("revision 8");
2420
+ expect(html).toContain("Last operation");
2421
+ expect(html).toContain("stuck pending");
2422
+ expect(html).toContain("Revision 8 has been pending-upgrade for 42m");
2423
+ expect(html).toContain("Deployment shop/api has 0/1 ready replicas");
2424
+ expect(html).toContain("0/1 ready");
2425
+ expect(html).toContain("1/1 succeeded");
2426
+ expect(html).toContain("Open current HelmRelease shop/shop in Radar");
2427
+ });
2428
+
2429
+ it("renders an access check verdict and a subject's bindings", () => {
2430
+ const projection = project(
2431
+ tool("diagnose", "diagnose", {
2432
+ resource: {
2433
+ apiVersion: "apps/v1",
2434
+ kind: "Deployment",
2435
+ metadata: { namespace: "shop", name: "api" },
2436
+ spec: { template: { spec: { serviceAccountName: "api-sa" } } },
2437
+ },
2438
+ resourceContext: { tier: "basic" },
2439
+ }),
2440
+ tool(
2441
+ "check",
2442
+ "get_subject_permissions",
2443
+ {
2444
+ subject: {
2445
+ kind: "ServiceAccount",
2446
+ namespace: "shop",
2447
+ name: "api-sa",
2448
+ },
2449
+ accessCheck: {
2450
+ verb: "get",
2451
+ resource: "secrets",
2452
+ namespace: "shop",
2453
+ resourceName: "db-credentials",
2454
+ allowed: false,
2455
+ reason: "",
2456
+ },
2457
+ },
2458
+ {
2459
+ summary: JSON.stringify({
2460
+ kind: "ServiceAccount",
2461
+ namespace: "shop",
2462
+ name: "api-sa",
2463
+ verb: "get",
2464
+ resource: "secrets",
2465
+ }),
2466
+ },
2467
+ ),
2468
+ tool(
2469
+ "subject",
2470
+ "get_subject_permissions",
2471
+ {
2472
+ subject: {
2473
+ kind: "ServiceAccount",
2474
+ namespace: "shop",
2475
+ name: "api-sa",
2476
+ },
2477
+ bindings: [
2478
+ {
2479
+ bindingKind: "RoleBinding",
2480
+ bindingNamespace: "shop",
2481
+ bindingName: "api-reader",
2482
+ roleKind: "Role",
2483
+ roleNamespace: "shop",
2484
+ roleName: "reader",
2485
+ rulesCount: 3,
2486
+ },
2487
+ {
2488
+ bindingKind: "ClusterRoleBinding",
2489
+ bindingName: "system:basic-user",
2490
+ roleKind: "ClusterRole",
2491
+ roleName: "system:basic-user",
2492
+ rulesCount: 1,
2493
+ inheritedFromGroup: "system:authenticated",
2494
+ },
2495
+ ],
2496
+ flatRules: [{ verbs: ["get"], resources: ["pods"] }],
2497
+ usedByPods: ["shop/api-68c7b766dc-fmphn"],
2498
+ },
2499
+ {
2500
+ summary: JSON.stringify({
2501
+ kind: "ServiceAccount",
2502
+ namespace: "shop",
2503
+ name: "api-sa",
2504
+ }),
2505
+ },
2506
+ ),
2507
+ );
2508
+ const partition = partitionInvestigationEvidence(projection.groups);
2509
+ expect(
2510
+ partition.main
2511
+ .map((group) => group.latest.title)
2512
+ .filter((title) => title.includes("api-sa")),
2513
+ ).toEqual(["Service Account shop/api-sa cannot get secrets"]);
2514
+ expect(
2515
+ partition.workload
2516
+ .map((group) => group.latest.title)
2517
+ .filter((title) => title.includes("api-sa")),
2518
+ ).toEqual(["Permissions of Service Account shop/api-sa"]);
2519
+ const html = render(projection, false, undefined, undefined, () => {});
2520
+ expect(html).toContain("not allowed");
2521
+ expect(html).toContain("db-credentials");
2522
+ expect(html).toContain("No RBAC rule allows it · namespace shop");
2523
+ expect(html).toContain("api-reader");
2524
+ expect(html).toContain("system:basic-user");
2525
+ expect(html).toContain("via system:authenticated");
2526
+ expect(html).toContain("3 rules");
2527
+ expect(html).toContain("1 effective rules");
2528
+ expect(html).toContain("shop/api-68c7b766dc-fmphn");
2529
+ expect(html).toContain("Open current Service Account shop/api-sa in Radar");
2530
+ expect(html).toContain("not what the workload has exercised");
2531
+ });
2532
+
2533
+ it("offers a Timeline link only for changes cards whose producer named a resource", () => {
2534
+ const onOpenTimeline = vi.fn();
2535
+ const projection = project(
2536
+ tool(
2537
+ "changes",
2538
+ "get_changes",
2539
+ { changes: [change] },
2540
+ { summary: changeArgs },
2541
+ ),
2542
+ tool(
2543
+ "changes-ns",
2544
+ "get_changes",
2545
+ { changes: [change] },
2546
+ { summary: JSON.stringify({ namespace: "shop" }) },
2547
+ ),
2548
+ );
2549
+ const withLink = renderWithNav(projection, { onOpenTimeline });
2550
+ expect(withLink.match(/Open the Timeline for shop\/api/g)).toHaveLength(1);
2551
+ expect(withLink).not.toContain("Open the Timeline for shop<");
2552
+ const withoutLink = renderWithNav(projection, {});
2553
+ expect(withoutLink).not.toContain("Open the Timeline");
2554
+ });
2555
+
2556
+ it("opens the relationship root's resource page from a relationships card", () => {
2557
+ const projection = project(
2558
+ tool(
2559
+ "neighborhood",
2560
+ "get_neighborhood",
2561
+ {
2562
+ root: {
2563
+ kind: "Deployment",
2564
+ group: "apps",
2565
+ namespace: "shop",
2566
+ name: "api",
2567
+ },
2568
+ subgraph: {
2569
+ nodes: [
2570
+ { id: "deployment/shop/api", kind: "Deployment", name: "api" },
2571
+ ],
2572
+ edges: [],
2573
+ },
2574
+ truncated: false,
2575
+ },
2576
+ {
2577
+ summary: JSON.stringify({
2578
+ kind: "Deployment",
2579
+ namespace: "shop",
2580
+ name: "api",
2581
+ }),
2582
+ },
2583
+ ),
2584
+ );
2585
+ const html = render(projection, false, undefined, undefined, () => {});
2586
+ expect(html).toContain("Relationships around Deployment api");
2587
+ expect(html).toContain("Open current Deployment shop/api in Radar");
2588
+ });
2589
+
2590
+ it("leads collapsed event cards with the event, keeping counts secondary", () => {
2591
+ const projection = project(
2592
+ tool(
2593
+ "events",
2594
+ "get_events",
2595
+ {
2596
+ events: [
2597
+ {
2598
+ reason: "BackOff",
2599
+ message: "Back-off restarting failed container",
2600
+ type: "Warning",
2601
+ count: 4,
2602
+ lastTimestamp: "2026-09-02T10:00:00Z",
2603
+ },
2604
+ ],
2605
+ },
2606
+ { summary: changeArgs },
2607
+ ),
2608
+ );
2609
+ const html = render(projection);
2610
+ const title = html.indexOf("Kubernetes events");
2611
+ const lead = html.indexOf("BackOff: Back-off restarting failed container");
2612
+ expect(lead).toBeGreaterThan(title);
2613
+ expect(html).not.toContain("1 event group");
2614
+ expect(html).toMatch(/BackOff<span[^>]*>×4<\/span>/);
2615
+ });
2616
+ });
2617
+
2618
+ describe("cited broader cards and coverage rows", () => {
2619
+ const helmPayload = {
2620
+ name: "prometheus",
2621
+ namespace: "opencost",
2622
+ chart: "prometheus",
2623
+ chartVersion: "25.8.0",
2624
+ status: "failed",
2625
+ revision: 1,
2626
+ updated: "2026-09-07T07:00:00Z",
2627
+ healthIssue: "6/12 pods Unschedulable",
2628
+ resources: [],
2629
+ };
2630
+ const permissionsPayload = {
2631
+ subject: { kind: "ServiceAccount", namespace: "other", name: "worker" },
2632
+ accessCheck: {
2633
+ verb: "get",
2634
+ resource: "secrets",
2635
+ namespace: "other",
2636
+ allowed: true,
2637
+ reason: 'RBAC: allowed by ClusterRoleBinding "worker"',
2638
+ },
2639
+ };
2640
+ const rulesPayload = {
2641
+ count: 1,
2642
+ rules: [
2643
+ {
2644
+ group: "general",
2645
+ type: "alerting",
2646
+ name: "TargetDown",
2647
+ query: "up == 0",
2648
+ state: "firing",
2649
+ health: "ok",
2650
+ labels: { severity: "warning" },
2651
+ alerts: [
2652
+ {
2653
+ state: "firing",
2654
+ labels: { namespace: "monitoring", job: "node-exporter" },
2655
+ },
2656
+ ],
2657
+ },
2658
+ ],
2659
+ };
2660
+
2661
+ it("admits a cited helm, permissions or alerts card and counts the rest as withheld", () => {
2662
+ const helmRef = evidenceRef("a", "b");
2663
+ const permRef = evidenceRef("a", "c");
2664
+ const rulesRef = evidenceRef("a", "d");
2665
+ const projection = project(
2666
+ tool("helm", "get_helm_release", helmPayload, {
2667
+ summary: JSON.stringify({ namespace: "opencost", name: "prometheus" }),
2668
+ evidenceRef: helmRef,
2669
+ }),
2670
+ tool("perm", "get_subject_permissions", permissionsPayload, {
2671
+ summary: JSON.stringify({
2672
+ kind: "ServiceAccount",
2673
+ namespace: "other",
2674
+ name: "worker",
2675
+ }),
2676
+ evidenceRef: permRef,
2677
+ }),
2678
+ tool("rules", "get_prometheus_rules", rulesPayload, {
2679
+ summary: JSON.stringify({}),
2680
+ evidenceRef: rulesRef,
2681
+ }),
2682
+ tool("cm", "get_resource", {
2683
+ apiVersion: "v1",
2684
+ kind: "ConfigMap",
2685
+ metadata: { namespace: "shop", name: "vars" },
2686
+ }),
2687
+ );
2688
+ const uncited = partitionInvestigationEvidence(projection.groups);
2689
+ expect(uncited.main).toHaveLength(0);
2690
+ expect(uncited.workload).toHaveLength(0);
2691
+ expect(uncited.hiddenBroader).toBe(4);
2692
+ expect(render(projection)).toContain(
2693
+ "4 results about other resources are not shown; they appear here when the assessment cites them.",
2694
+ );
2695
+
2696
+ const resolution = resolveInvestigationRootCauseEvidence(
2697
+ projection,
2698
+ { status: "linked", refs: [helmRef, permRef, rulesRef] },
2699
+ 0,
2700
+ );
2701
+ const cited = partitionInvestigationEvidence(projection.groups, resolution);
2702
+ expect(cited.main.map((group) => group.latest.title).sort()).toEqual([
2703
+ "Helm release opencost/prometheus",
2704
+ "Service Account other/worker can get secrets",
2705
+ "TargetDown firing",
2706
+ ]);
2707
+ expect(cited.hiddenBroader).toBe(1);
2708
+
2709
+ // A later turn's citations widen the selection; they never take one away.
2710
+ // Replacing the assessment's resolution with a follow-up's used to push
2711
+ // the assessment's own cited evidence back into the withheld count.
2712
+ // A later turn's citation widens the selection; it never takes one away,
2713
+ // and it carries the source that cited it so a broader card can actually
2714
+ // be promoted rather than selected-but-withheld.
2715
+ const withheld = projection.groups.find(
2716
+ (group) => !cited.collectionByGroup.has(group.id),
2717
+ );
2718
+ expect(withheld).toBeDefined();
2719
+ const widened = partitionInvestigationEvidence(
2720
+ projection.groups,
2721
+ resolution,
2722
+ undefined,
2723
+ [
2724
+ {
2725
+ groupId: withheld!.id,
2726
+ source: withheld!.latest.source,
2727
+ },
2728
+ ],
2729
+ );
2730
+ for (const group of cited.main) {
2731
+ expect(widened.main).toContain(group);
2732
+ }
2733
+ expect(widened.main).toContain(withheld);
2734
+ expect(widened.hiddenBroader).toBe(cited.hiddenBroader - 1);
2735
+ const html = render(projection, false, undefined, resolution);
2736
+ expect(html).toContain("6/12 pods Unschedulable");
2737
+ expect(html).toContain(
2738
+ "1 result about another resource is not shown; it appears here when the assessment cites it.",
2739
+ );
2740
+ });
2741
+
2742
+ it("files a complete, empty events query as a checked receipt, not a coverage gap", () => {
2743
+ const projection = project(
2744
+ tool(
2745
+ "events-empty",
2746
+ "get_events",
2747
+ { events: null },
2748
+ {
2749
+ summary: JSON.stringify({
2750
+ kind: "Pod",
2751
+ namespace: "shop",
2752
+ name: "api-a",
2753
+ }),
2754
+ },
2755
+ ),
2756
+ );
2757
+ expect(projection.limitations).toHaveLength(0);
2758
+ const receipts = projection.groups.filter(
2759
+ (group) => group.latest.data.type === "receipt",
2760
+ );
2761
+ expect(receipts.map((group) => group.latest.title)).toEqual([
2762
+ "No events in this window",
2763
+ ]);
2764
+ expect(receipts[0].latest.data).toMatchObject({
2765
+ type: "receipt",
2766
+ checked: "events",
2767
+ });
2768
+ const html = render(projection);
2769
+ expect(html).not.toContain("Evidence coverage is incomplete");
2770
+ expect(html).not.toContain("does not establish that no events occurred");
2771
+ });
2772
+
2773
+ it("renders a single-limitation coverage group once, with its source link", () => {
2774
+ const narrowed = {
2775
+ events: null,
2776
+ narrowHint: "Pass a name to narrow the query.",
2777
+ };
2778
+ const projection = project(
2779
+ tool("events-a", "get_events", narrowed, {
2780
+ summary: JSON.stringify({
2781
+ kind: "Pod",
2782
+ namespace: "shop",
2783
+ name: "api-a",
2784
+ }),
2785
+ }),
2786
+ tool("events-b", "get_events", narrowed, {
2787
+ summary: JSON.stringify({
2788
+ kind: "Pod",
2789
+ namespace: "shop",
2790
+ name: "api-b",
2791
+ }),
2792
+ }),
2793
+ tool("events-c", "get_events", narrowed, {
2794
+ summary: JSON.stringify({
2795
+ kind: "Pod",
2796
+ namespace: "shop",
2797
+ name: "api-c",
2798
+ }),
2799
+ }),
2800
+ );
2801
+ expect(projection.limitations).toHaveLength(1);
2802
+ expect(projection.limitations[0].sources).toHaveLength(3);
2803
+ const html = render(projection);
2804
+ const message =
2805
+ "Kubernetes events returned part of the matching results to keep this investigation fast. More may exist.";
2806
+ // Live region, strip summary, the group row's label and its text: never
2807
+ // a second identical detail row beneath it.
2808
+ expect(
2809
+ html.match(
2810
+ new RegExp(message.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
2811
+ ),
2812
+ ).toHaveLength(4);
2813
+ expect(html).toContain("View latest in Activity");
2814
+ expect(html).not.toContain('aria-controls=":r');
2815
+
2816
+ const twoRows = project(
2817
+ tool(
2818
+ "changes-err",
2819
+ "get_changes",
2820
+ { changes: [], sourcesErrored: ["timeline: permission denied"] },
2821
+ { summary: JSON.stringify({ namespace: "shop" }) },
2822
+ ),
2823
+ tool(
2824
+ "changes-hint",
2825
+ "get_changes",
2826
+ { changes: [], narrowHint: "narrow with since=" },
2827
+ { summary: JSON.stringify({ namespace: "shop" }) },
2828
+ ),
2829
+ );
2830
+ const grouped = groupEvidenceCoverage(twoRows.limitations);
2831
+ expect(
2832
+ grouped.find((group) => group.label === "Recent changes")?.limitations
2833
+ .length,
2834
+ ).toBeGreaterThan(1);
2835
+ expect(render(twoRows)).toContain('aria-expanded="false"');
2836
+ });
2837
+ });
2838
+
2839
+ describe("grouped startup cards", () => {
2840
+ it("opens to the pod names when several pods share one blocker", () => {
2841
+ const blocker = (name: string) => ({
2842
+ kind: "Pod",
2843
+ name,
2844
+ reason: "Unschedulable",
2845
+ severity: "critical",
2846
+ message: "1 node(s) no free host ports",
2847
+ });
2848
+ const projection = project(
2849
+ tool("diagnose", "diagnose", {
2850
+ resource: {
2851
+ apiVersion: "apps/v1",
2852
+ kind: "Deployment",
2853
+ metadata: { namespace: "shop", name: "api" },
2854
+ },
2855
+ resourceContext: { tier: "basic" },
2856
+ pods: 2,
2857
+ startupBlockers: [blocker("api-abc"), blocker("api-def")],
2858
+ }),
2859
+ );
2860
+ const html = render(projection);
2861
+ expect(html).toContain("2 pods · 1 node(s) no free host ports");
2862
+ expect(html).toContain(">api-abc<");
2863
+ expect(html).toContain(">api-def<");
2864
+ expect(html).toContain('aria-expanded="false"');
2865
+ });
2866
+ });
2867
+
2868
+ describe("InvestigationEvidencePane metrics cards", () => {
2869
+ const window = { start: "2026-09-06T07:00:00Z", end: "2026-09-06T09:00:00Z" };
2870
+ const targetSelectors = [
2871
+ {
2872
+ metric: "container_memory_working_set_bytes",
2873
+ matchers: [
2874
+ { label: "namespace", op: "=", value: "shop" },
2875
+ { label: "workload", op: "=", value: "api" },
2876
+ { label: "workload_type", op: "=", value: "deployment" },
2877
+ ],
2878
+ },
2879
+ ];
2880
+ function rangeResult(selectors: unknown[], query?: string) {
2881
+ return {
2882
+ query:
2883
+ query ??
2884
+ 'sum(container_memory_working_set_bytes{namespace="shop",workload="api",workload_type="deployment"})',
2885
+ type: "range",
2886
+ ...window,
2887
+ step: "60s",
2888
+ series: [
2889
+ {
2890
+ labels: {},
2891
+ dataPoints: [
2892
+ { timestamp: Date.parse(window.start) / 1000, value: 1 },
2893
+ { timestamp: Date.parse(window.end) / 1000, value: 2 },
2894
+ ],
2895
+ },
2896
+ ],
2897
+ selectors,
2898
+ };
2899
+ }
2900
+
2901
+ it("keeps an uncited target-scoped chart in the workload collection", () => {
2902
+ const projection = project(
2903
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
2904
+ );
2905
+ const partition = partitionInvestigationEvidence(projection.groups);
2906
+ expect(partition.main).toHaveLength(0);
2907
+ expect(partition.workload.map((group) => group.kind)).toEqual(["metrics"]);
2908
+ expect(partition.hiddenMetrics).toBe(0);
2909
+ const html = render(projection);
2910
+ expect(html).toContain("Memory working set");
2911
+ expect(html).toContain("container_memory_working_set_bytes · 1 series");
2912
+ expect(html).not.toContain("Prometheus metrics");
2913
+ expect(html).not.toContain("metric result");
2914
+ });
2915
+
2916
+ it("counts a withheld chart once, not in both lines", () => {
2917
+ // The pane heads two separate lines with these counts — "results about
2918
+ // other resources" and "broader metric results" — so a chart counted in
2919
+ // both announces one withheld result as two, and the numbers stop summing.
2920
+ const projection = project(
2921
+ tool("prom", "query_prometheus", rangeResult([])),
2922
+ );
2923
+ const partition = partitionInvestigationEvidence(projection.groups);
2924
+ expect(partition.hiddenMetrics).toBe(1);
2925
+ expect(partition.hiddenBroader).toBe(0);
2926
+ });
2927
+
2928
+ it("withholds an uncited broader chart and says so", () => {
2929
+ const projection = project(
2930
+ tool("prom", "query_prometheus", rangeResult([])),
2931
+ tool(
2932
+ "prom-2",
2933
+ "query_prometheus",
2934
+ rangeResult([], "sum(machine_memory_bytes)"),
2935
+ ),
2936
+ );
2937
+ const partition = partitionInvestigationEvidence(projection.groups);
2938
+ expect(partition.collectionByGroup.size).toBe(0);
2939
+ expect(partition.hiddenMetrics).toBe(2);
2940
+ const html = render(projection);
2941
+ expect(html).toContain(
2942
+ "2 broader metric results are not shown; they appear here when the assessment cites them.",
2943
+ );
2944
+ expect(html).not.toContain("Prometheus metrics");
2945
+ expect(html).not.toContain("container_memory_working_set_bytes");
2946
+ });
2947
+
2948
+ it("promotes a cited broader chart into main with its expression as the axis label", () => {
2949
+ const ref = evidenceRef("a", "b");
2950
+ const query =
2951
+ "sum(rate(container_cpu_usage_seconds_total[5m])) by (namespace)";
2952
+ const projection = project(
2953
+ tool("prom", "query_prometheus", rangeResult([], query), {
2954
+ evidenceRef: ref,
2955
+ }),
2956
+ );
2957
+ const resolution = resolveInvestigationRootCauseEvidence(
2958
+ projection,
2959
+ { status: "linked", refs: [ref] },
2960
+ 0,
2961
+ );
2962
+ expect(resolution.status).toBe("linked");
2963
+ const partition = partitionInvestigationEvidence(
2964
+ projection.groups,
2965
+ resolution,
2966
+ );
2967
+ expect(partition.main.map((group) => group.kind)).toEqual(["metrics"]);
2968
+ expect(partition.hiddenMetrics).toBe(0);
2969
+ const html = render(projection, false, undefined, resolution);
2970
+ expect(html).toContain("Prometheus metrics");
2971
+ expect(html).toContain("1 series · 2h window · 60s step");
2972
+ expect(html).not.toContain("metric result");
2973
+ });
2974
+
2975
+ it("lists single-sample series with their time instead of an empty chart, and names aggregated series", () => {
2976
+ const start = Date.parse(window.start) / 1000;
2977
+ const projection = project(
2978
+ tool("prom", "query_prometheus", {
2979
+ ...rangeResult(
2980
+ targetSelectors,
2981
+ "sum by (container) (rate(container_cpu_usage_seconds_total[5m]))",
2982
+ ),
2983
+ series: [
2984
+ {
2985
+ labels: { container: "api" },
2986
+ dataPoints: [
2987
+ { timestamp: start, value: 0.2 },
2988
+ { timestamp: start + 60, value: 0.4 },
2989
+ ],
2990
+ },
2991
+ {
2992
+ labels: { container: "envoy" },
2993
+ dataPoints: [
2994
+ { timestamp: start, value: 0.1 },
2995
+ { timestamp: start + 60, value: 0.1 },
2996
+ ],
2997
+ },
2998
+ {
2999
+ labels: { container: "init" },
3000
+ dataPoints: [{ timestamp: start + 120, value: 0.05 }],
3001
+ },
3002
+ ],
3003
+ }),
3004
+ tool("sparse", "query_prometheus", {
3005
+ ...rangeResult(targetSelectors, "up"),
3006
+ series: [
3007
+ {
3008
+ labels: { pod: "api-7f6-abc" },
3009
+ dataPoints: [{ timestamp: start + 30, value: 1 }],
3010
+ },
3011
+ ],
3012
+ }),
3013
+ );
3014
+ const html = render(projection);
3015
+ expect(html).toContain("container=api");
3016
+ expect(html).toContain("container=envoy");
3017
+ expect(html).not.toContain("series-0");
3018
+ expect(html).toContain(
3019
+ "1 series has a single sample in this window, listed with its time:",
3020
+ );
3021
+ expect(html).toContain("container=init");
3022
+ expect(
3023
+ html.match(/data-testid="investigation-metrics-sparse-series"/g),
3024
+ ).toHaveLength(2);
3025
+ expect(html).toContain("api-7f6-abc");
3026
+ });
3027
+
3028
+ it("keeps series that differ only by a hidden label distinguishable across chart and table, and names null-only series honestly", () => {
3029
+ const start = Date.parse(window.start) / 1000;
3030
+ const projection = project(
3031
+ tool("prom", "query_prometheus", {
3032
+ ...rangeResult(
3033
+ targetSelectors,
3034
+ 'rate(container_cpu_usage_seconds_total{namespace="shop",workload="api"}[5m])',
3035
+ ),
3036
+ series: [
3037
+ {
3038
+ labels: { pod: "api-7f6-abc", container: "api" },
3039
+ dataPoints: [
3040
+ { timestamp: start, value: 0.2 },
3041
+ { timestamp: start + 60, value: 0.4 },
3042
+ ],
3043
+ },
3044
+ {
3045
+ labels: { pod: "api-7f6-abc", container: "envoy" },
3046
+ dataPoints: [{ timestamp: start + 120, value: 0.05 }],
3047
+ },
3048
+ {
3049
+ labels: { pod: "api-7f6-abc", container: "init" },
3050
+ dataPoints: [
3051
+ { timestamp: start, value: null },
3052
+ { timestamp: start + 60, value: null },
3053
+ ],
3054
+ },
3055
+ ],
3056
+ }),
3057
+ );
3058
+ const html = render(projection);
3059
+ expect(html).toContain("pod=api-7f6-abc, container=envoy");
3060
+ expect(html).toContain(
3061
+ "1 series has a single sample in this window, listed with its time:",
3062
+ );
3063
+ expect(html).toContain("1 series had no usable samples in this window: ");
3064
+ expect(html).toContain("pod=api-7f6-abc, container=init");
3065
+ expect(html).not.toContain("2 series have a single sample");
3066
+ });
3067
+
3068
+ it("draws same-turn changes to the subject on the chart and links the subject", () => {
3069
+ const projection = project(
3070
+ tool("diag", "diagnose", {
3071
+ resource: {
3072
+ apiVersion: "apps/v1",
3073
+ kind: "Deployment",
3074
+ metadata: { namespace: "shop", name: "api" },
3075
+ },
3076
+ resourceContext: {
3077
+ tier: "diagnostic",
3078
+ uses: {
3079
+ configMaps: [
3080
+ { kind: "ConfigMap", namespace: "shop", name: "api-config" },
3081
+ ],
3082
+ },
3083
+ },
3084
+ recentChanges: [
3085
+ {
3086
+ kind: "ConfigMap",
3087
+ apiVersion: "v1",
3088
+ namespace: "shop",
3089
+ name: "api-config",
3090
+ changeType: "update",
3091
+ timestamp: "2026-09-06T07:30:00Z",
3092
+ },
3093
+ {
3094
+ kind: "Deployment",
3095
+ apiVersion: "apps/v1",
3096
+ namespace: "shop",
3097
+ name: "worker",
3098
+ changeType: "update",
3099
+ timestamp: "2026-09-06T08:00:00Z",
3100
+ },
3101
+ ],
3102
+ pods: 1,
3103
+ }),
3104
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
3105
+ );
3106
+ const onOpenResource = vi.fn();
3107
+ const html = render(
3108
+ projection,
3109
+ false,
3110
+ undefined,
3111
+ undefined,
3112
+ onOpenResource,
3113
+ );
3114
+ expect(html).toContain('data-chart-annotation="change"');
3115
+ expect(html.match(/data-chart-annotation="change"/g)).toHaveLength(1);
3116
+ expect(html).toContain("ConfigMap api-config");
3117
+ expect(html).not.toContain("Deployment worker");
3118
+ expect(html).toContain(
3119
+ "1 change recorded in this window is marked on the chart.",
3120
+ );
3121
+ expect(html).toContain("Open current Deployment shop/api in Radar");
3122
+ });
3123
+ });
3124
+
3125
+ describe("InvestigationEvidencePane diagnose vitals", () => {
3126
+ const window = {
3127
+ start: "2026-09-06T07:00:00Z",
3128
+ end: "2026-09-06T08:00:00Z",
3129
+ step: "1m2s",
3130
+ };
3131
+ const vitals = {
3132
+ window,
3133
+ pods: 1,
3134
+ series: [
3135
+ {
3136
+ category: "cpu",
3137
+ unit: "cores",
3138
+ query:
3139
+ "sum(rate(container_cpu_usage_seconds_total{container!='',namespace='shop',pod=~'^(api-abc)$'}[5m]))",
3140
+ series: [
3141
+ {
3142
+ labels: {},
3143
+ dataPoints: [
3144
+ { timestamp: Date.parse(window.start) / 1000, value: 0.12 },
3145
+ { timestamp: Date.parse(window.end) / 1000, value: 0.25 },
3146
+ ],
3147
+ },
3148
+ ],
3149
+ },
3150
+ ],
3151
+ };
3152
+
3153
+ it("says a window is clean only when changes were actually looked up", () => {
3154
+ const withChanges = render(
3155
+ project(
3156
+ tool("diag", "diagnose", {
3157
+ resource: {
3158
+ apiVersion: "apps/v1",
3159
+ kind: "Deployment",
3160
+ metadata: { namespace: "shop", name: "api" },
3161
+ },
3162
+ pods: 1,
3163
+ recentChanges: [],
3164
+ metrics: vitals,
3165
+ }),
3166
+ ),
3167
+ );
3168
+ expect(withChanges).toContain(
3169
+ "None of the changes Radar read fall in this window.",
3170
+ );
3171
+
3172
+ // The same chart with no change lookup in the turn must stay silent: an
3173
+ // empty marker list there means nobody looked, not that nothing happened.
3174
+ const withoutChanges = render(
3175
+ project(
3176
+ tool("prom", "query_prometheus", {
3177
+ query:
3178
+ 'sum(container_memory_working_set_bytes{namespace="shop",workload="api",workload_type="deployment"})',
3179
+ type: "range",
3180
+ ...window,
3181
+ step: "60s",
3182
+ series: [
3183
+ {
3184
+ labels: {},
3185
+ dataPoints: [
3186
+ { timestamp: Date.parse(window.start) / 1000, value: 1 },
3187
+ { timestamp: Date.parse(window.end) / 1000, value: 2 },
3188
+ ],
3189
+ },
3190
+ ],
3191
+ selectors: [
3192
+ {
3193
+ metric: "container_memory_working_set_bytes",
3194
+ matchers: [
3195
+ { label: "namespace", op: "=", value: "shop" },
3196
+ { label: "workload", op: "=", value: "api" },
3197
+ { label: "workload_type", op: "=", value: "deployment" },
3198
+ ],
3199
+ },
3200
+ ],
3201
+ }),
3202
+ ),
3203
+ );
3204
+ expect(withoutChanges).not.toContain(
3205
+ "None of the changes Radar read fall in this window.",
3206
+ );
3207
+ });
3208
+
3209
+ it("keeps an uncited diagnose chart in the workload collection and marks the same-turn change on it", () => {
3210
+ const projection = project(
3211
+ tool("diag", "diagnose", {
3212
+ resource: {
3213
+ apiVersion: "apps/v1",
3214
+ kind: "Deployment",
3215
+ metadata: { namespace: "shop", name: "api" },
3216
+ },
3217
+ pods: 1,
3218
+ recentChanges: [
3219
+ {
3220
+ kind: "Deployment",
3221
+ apiVersion: "apps/v1",
3222
+ namespace: "shop",
3223
+ name: "api",
3224
+ changeType: "update",
3225
+ timestamp: "2026-09-06T07:30:00Z",
3226
+ },
3227
+ {
3228
+ kind: "Deployment",
3229
+ apiVersion: "apps/v1",
3230
+ namespace: "shop",
3231
+ name: "worker",
3232
+ changeType: "update",
3233
+ timestamp: "2026-09-06T07:40:00Z",
3234
+ },
3235
+ ],
3236
+ metrics: vitals,
3237
+ }),
3238
+ );
3239
+ const partition = partitionInvestigationEvidence(projection.groups);
3240
+ expect(partition.main.map((group) => group.kind)).not.toContain("metrics");
3241
+ expect(partition.workload.map((group) => group.kind)).toContain("metrics");
3242
+ expect(partition.hiddenMetrics).toBe(0);
3243
+ // The vitals lead the collection; the absence receipts follow in their
3244
+ // own order, so a healthy workload does not open on four empty tiles.
3245
+ const kinds = partition.workload.map((group) => group.kind);
3246
+ const firstReceipt = kinds.indexOf("receipt");
3247
+ expect(firstReceipt).toBeGreaterThan(0);
3248
+ expect(kinds.slice(firstReceipt).every((kind) => kind === "receipt")).toBe(
3249
+ true,
3250
+ );
3251
+ expect(kinds.indexOf("metrics")).toBeLessThan(firstReceipt);
3252
+ expect(kinds.indexOf("resource")).toBeLessThan(kinds.indexOf("metrics"));
3253
+ expect(
3254
+ partition.workload
3255
+ .filter((group) => group.kind === "receipt")
3256
+ .map((group) => group.latest.title),
3257
+ ).toEqual(["Radar's diagnosis found no live issues", "No warning events"]);
3258
+ const onOpenResource = vi.fn();
3259
+ const html = render(
3260
+ projection,
3261
+ false,
3262
+ undefined,
3263
+ undefined,
3264
+ onOpenResource,
3265
+ );
3266
+ expect(html).toContain("CPU usage · shop/api");
3267
+ expect(html).toContain("1 current pod · 60m window · 1m2s step");
3268
+ expect(html).toContain("(cores)");
3269
+ expect(html.match(/data-chart-annotation="change"/g)).toHaveLength(1);
3270
+ expect(html).toContain("Deployment api");
3271
+ expect(html).not.toContain("Deployment worker");
3272
+ expect(html).toContain(
3273
+ "1 change recorded in this window is marked on the chart.",
3274
+ );
3275
+ expect(html).toContain("Open current Deployment shop/api in Radar");
3276
+ });
3277
+
3278
+ it("never marks the target's changes on a neighbour's chart", () => {
3279
+ const projection = project(
3280
+ tool("diag", "diagnose", {
3281
+ resource: {
3282
+ apiVersion: "apps/v1",
3283
+ kind: "Deployment",
3284
+ metadata: { namespace: "shop", name: "api" },
3285
+ },
3286
+ pods: 1,
3287
+ logsCurrent: [
3288
+ {
3289
+ pod: "api-abc",
3290
+ container: "api",
3291
+ logs: {
3292
+ lines: ["ERROR boom"],
3293
+ totalLines: 1,
3294
+ matchedLines: 1,
3295
+ fallback: false,
3296
+ },
3297
+ },
3298
+ ],
3299
+ recentChanges: [
3300
+ {
3301
+ kind: "Pod",
3302
+ apiVersion: "v1",
3303
+ namespace: "shop",
3304
+ name: "api-abc",
3305
+ changeType: "update",
3306
+ timestamp: "2026-09-06T07:30:00Z",
3307
+ },
3308
+ ],
3309
+ }),
3310
+ tool(
3311
+ "diag-worker",
3312
+ "diagnose",
3313
+ {
3314
+ resource: {
3315
+ apiVersion: "apps/v1",
3316
+ kind: "Deployment",
3317
+ metadata: { namespace: "shop", name: "worker" },
3318
+ },
3319
+ pods: 1,
3320
+ metrics: vitals,
3321
+ },
3322
+ { summary: JSON.stringify({ namespace: "shop", name: "worker" }) },
3323
+ ),
3324
+ );
3325
+ const chart = projection.groups.find(
3326
+ (group) =>
3327
+ group.kind === "metrics" && group.latest.relevance === "broader",
3328
+ );
3329
+ expect(chart).toBeDefined();
3330
+ expect(metricsChangeMarkers(projection.groups, chart!.latest)).toEqual([]);
3331
+ const targetChart = project(
3332
+ tool("diag", "diagnose", {
3333
+ resource: {
3334
+ apiVersion: "apps/v1",
3335
+ kind: "Deployment",
3336
+ metadata: { namespace: "shop", name: "api" },
3337
+ },
3338
+ pods: 1,
3339
+ recentChanges: [
3340
+ {
3341
+ kind: "Deployment",
3342
+ apiVersion: "apps/v1",
3343
+ namespace: "shop",
3344
+ name: "api",
3345
+ changeType: "update",
3346
+ timestamp: "2026-09-06T07:30:00Z",
3347
+ },
3348
+ ],
3349
+ metrics: vitals,
3350
+ }),
3351
+ );
3352
+ const target = targetChart.groups.find((group) => group.kind === "metrics");
3353
+ expect(
3354
+ metricsChangeMarkers(targetChart.groups, target!.latest),
3355
+ ).toHaveLength(1);
3356
+ });
3357
+
3358
+ it("shows a workload metrics limitation for a reported producer error", () => {
3359
+ const projection = project(
3360
+ tool("diag", "diagnose", {
3361
+ resource: {
3362
+ apiVersion: "apps/v1",
3363
+ kind: "Deployment",
3364
+ metadata: { namespace: "shop", name: "api" },
3365
+ },
3366
+ pods: 1,
3367
+ metrics: {
3368
+ window,
3369
+ pods: 1,
3370
+ series: [],
3371
+ error:
3372
+ "metrics omitted: 3s budget exceeded before all queries answered",
3373
+ },
3374
+ }),
3375
+ );
3376
+ const html = render(projection);
3377
+ expect(html).toContain("Workload metrics");
3378
+ expect(html).toContain(
3379
+ "metrics omitted: 3s budget exceeded before all queries answered",
3380
+ );
3381
+ expect(html).not.toContain("CPU usage");
3382
+ });
3383
+ });
3384
+
3385
+ describe("InvestigationEvidencePane scaled-by section", () => {
3386
+ const scaledDeployment = (scaledBy: unknown) =>
3387
+ tool("workload", "diagnose", {
3388
+ resource: {
3389
+ apiVersion: "apps/v1",
3390
+ kind: "Deployment",
3391
+ metadata: { namespace: "shop", name: "api" },
3392
+ },
3393
+ resourceContext: {
3394
+ tier: "basic",
3395
+ workloadSummary: { replicas: { desired: 5, ready: 5 } },
3396
+ scaledBy,
3397
+ },
3398
+ });
3399
+
3400
+ it("shows the HPA's own diagnosis on the workload card", () => {
3401
+ const html = render(
3402
+ project(
3403
+ scaledDeployment([
3404
+ {
3405
+ kind: "HorizontalPodAutoscaler",
3406
+ namespace: "shop",
3407
+ name: "api-hpa",
3408
+ hpaSummary: {
3409
+ state: "limited_max",
3410
+ summary: "Wants 8 replicas but is capped at maxReplicas=5",
3411
+ target: { kind: "Deployment", group: "apps", name: "api" },
3412
+ bounds: { min: 1, max: 5, current: 5, desired: 5 },
3413
+ reasons: [
3414
+ {
3415
+ id: "limited_max",
3416
+ message: "HPA is capped at maxReplicas=5",
3417
+ detail:
3418
+ "the desired replica count is more than the maximum replica count",
3419
+ conditionType: "ScalingLimited",
3420
+ conditionReason: "TooManyReplicas",
3421
+ },
3422
+ {
3423
+ id: "missing_current_metric",
3424
+ message: "HPA is missing current metric values",
3425
+ detail: "cpu",
3426
+ },
3427
+ ],
3428
+ },
3429
+ },
3430
+ ]),
3431
+ ),
3432
+ );
3433
+ expect(html).toContain("Scaled by");
3434
+ expect(html).toContain("api-hpa");
3435
+ expect(html).toContain("Maxed");
3436
+ expect(html).toContain("Wants 8 replicas but is capped at maxReplicas=5");
3437
+ expect(html).toContain("5/5 replicas · bounds 1-5");
3438
+ expect(html).not.toContain("HPA is capped at maxReplicas=5");
3439
+ expect(html).toContain(
3440
+ "Kubernetes ScalingLimited · TooManyReplicas: “the desired replica count is more than the maximum replica count”",
3441
+ );
3442
+ expect(html).toContain("HPA is missing current metric values");
3443
+ expect(html).toContain("· cpu");
3444
+ });
3445
+
3446
+ it("names the KEDA ScaledObject that owns the HPA and links it only when listed", () => {
3447
+ const kedaHPA = (listed: boolean) =>
3448
+ scaledDeployment([
3449
+ {
3450
+ kind: "HorizontalPodAutoscaler",
3451
+ namespace: "shop",
3452
+ name: "keda-hpa-api",
3453
+ managedBy: {
3454
+ kind: "ScaledObject",
3455
+ group: "keda.sh",
3456
+ namespace: "shop",
3457
+ name: "api",
3458
+ },
3459
+ hpaSummary: {
3460
+ state: "limited_min",
3461
+ summary: "HPA is holding at minReplicas=2",
3462
+ bounds: { min: 2, max: 10, current: 2, desired: 2 },
3463
+ reasons: [
3464
+ {
3465
+ id: "limited_min",
3466
+ message: "HPA is held at minReplicas=2",
3467
+ detail:
3468
+ "the desired replica count is less than the minimum replica count",
3469
+ conditionType: "ScalingLimited",
3470
+ conditionReason: "TooFewReplicas",
3471
+ },
3472
+ ],
3473
+ },
3474
+ },
3475
+ ...(listed
3476
+ ? [
3477
+ {
3478
+ kind: "ScaledObject",
3479
+ group: "keda.sh",
3480
+ namespace: "shop",
3481
+ name: "api",
3482
+ },
3483
+ ]
3484
+ : []),
3485
+ ]);
3486
+ const open = () => {};
3487
+ const linked = render(
3488
+ project(kedaHPA(true)),
3489
+ false,
3490
+ undefined,
3491
+ undefined,
3492
+ open,
3493
+ );
3494
+ expect(linked).toContain("managed by KEDA ScaledObject");
3495
+ expect(linked).toContain("HPA is holding at minReplicas=2");
3496
+ expect(linked).not.toContain("HPA is held at minReplicas=2");
3497
+ expect(linked).toContain(
3498
+ "Kubernetes ScalingLimited · TooFewReplicas: “the desired replica count is less than the minimum replica count”",
3499
+ );
3500
+ expect(linked).toMatch(/<button[^>]*>api<\/button>/);
3501
+ const unlisted = render(
3502
+ project(kedaHPA(false)),
3503
+ false,
3504
+ undefined,
3505
+ undefined,
3506
+ open,
3507
+ );
3508
+ expect(unlisted).toContain("managed by KEDA ScaledObject");
3509
+ expect(unlisted).not.toMatch(/<button[^>]*>api<\/button>/);
3510
+ });
3511
+
3512
+ it("stays quiet for scalers Radar did not diagnose", () => {
3513
+ const html = render(
3514
+ project(
3515
+ scaledDeployment([
3516
+ {
3517
+ kind: "ScaledObject",
3518
+ group: "keda.sh",
3519
+ namespace: "shop",
3520
+ name: "api-scaler",
3521
+ },
3522
+ {
3523
+ kind: "HorizontalPodAutoscaler",
3524
+ namespace: "shop",
3525
+ name: "api-hpa",
3526
+ },
3527
+ ]),
3528
+ ),
3529
+ );
3530
+ expect(html).not.toContain("Scaled by");
3531
+ });
3532
+ });
3533
+
3534
+ describe("supporting adverse cards are visibly marked", () => {
3535
+ const ref = evidenceRef("a", "b");
3536
+ const bundle = {
3537
+ resource: {
3538
+ apiVersion: "apps/v1",
3539
+ kind: "Deployment",
3540
+ metadata: { namespace: "shop", name: "api" },
3541
+ status: { readyReplicas: 1, replicas: 1 },
3542
+ },
3543
+ pods: 1,
3544
+ logsCurrent: [
3545
+ {
3546
+ pod: "api-abc",
3547
+ container: "api",
3548
+ logs: {
3549
+ lines: ["ERROR failed to resolve backend service"],
3550
+ totalLines: 1,
3551
+ matchedLines: 1,
3552
+ fallback: false,
3553
+ },
3554
+ },
3555
+ {
3556
+ pod: "api-abc",
3557
+ container: "proxy",
3558
+ logs: {
3559
+ lines: ["proxy ready"],
3560
+ totalLines: 1,
3561
+ matchedLines: 0,
3562
+ fallback: true,
3563
+ },
3564
+ },
3565
+ ],
3566
+ };
3567
+
3568
+ it("draws a thin warning rule on a supporting warning card and none on neutral or context cards", () => {
3569
+ const projection = project(
3570
+ tool("diag", "diagnose", bundle, { evidenceRef: ref }),
3571
+ );
3572
+ const logs = projection.groups.filter((group) => group.kind === "logs");
3573
+ const [errorLogs, proxyLogs] = logs;
3574
+ expect(errorLogs.latest.tier).toBe("supporting");
3575
+ expect(errorLogs.latest.tone).toBe("warning");
3576
+ expect(proxyLogs.latest.tier).toBe("context");
3577
+ expect(proxyLogs.latest.tone).toBe("neutral");
3578
+ const html = render(projection);
3579
+ const card = (id: string) =>
3580
+ html.slice(html.indexOf(`id="${id}"`), html.indexOf(`id="${id}"`) + 900);
3581
+ expect(card(errorLogs.id)).toContain(
3582
+ "border-l-2 border-l-semantic-warning",
3583
+ );
3584
+ expect(card(errorLogs.id)).not.toContain("border-l-[3px]");
3585
+ expect(card(proxyLogs.id)).not.toContain("border-l-");
3586
+ const resource = projection.groups.find(
3587
+ (group) => group.kind === "resource",
3588
+ )!;
3589
+ expect(resource.latest.tier).toBe("context");
3590
+ expect(card(resource.id)).not.toContain("border-l-");
3591
+ });
3592
+
3593
+ it("marks every card that triggers the healthy-conflict banner", () => {
3594
+ const projection = project(
3595
+ tool("diag", "diagnose", bundle, { evidenceRef: ref }),
3596
+ );
3597
+ expect(investigationEvidenceConflictsWithHealthy(projection)).toBe(true);
3598
+ const html = render(projection);
3599
+ const triggering = projection.groups.filter((group) =>
3600
+ investigationEvidenceConflictsWithHealthy({ groups: [group] }),
3601
+ );
3602
+ expect(triggering.length).toBeGreaterThan(0);
3603
+ for (const group of triggering) {
3604
+ const start = html.indexOf(`id="${group.id}"`);
3605
+ expect(start).toBeGreaterThan(-1);
3606
+ expect(html.slice(start, start + 900)).toMatch(
3607
+ /border-l-(2 border-l-semantic-(warning|error)|\[3px\])/,
3608
+ );
3609
+ }
3610
+ });
3611
+ });