@skyhook-io/radar-app 1.13.4 → 1.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
@@ -24,8 +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";
28
31
  import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
32
+ import { metricsChangeMarkers } from "./investigationMetrics";
29
33
 
30
34
  const onViewSource = vi.fn();
31
35
  const target = {
@@ -764,7 +768,7 @@ describe("InvestigationEvidencePane hierarchy and provenance", () => {
764
768
  const projection = project(
765
769
  tool(
766
770
  "metrics-cited",
767
- "query_prometheus",
771
+ "discover_metrics",
768
772
  { result: [1] },
769
773
  { evidenceRef: ref },
770
774
  ),
@@ -779,7 +783,7 @@ describe("InvestigationEvidencePane hierarchy and provenance", () => {
779
783
  const sources = renderToStaticMarkup(
780
784
  <AssessmentSources resolution={resolution} onViewSource={onViewSource} />,
781
785
  );
782
- expect(sources).toContain("Query Prometheus");
786
+ expect(sources).toContain("Discover Metrics");
783
787
  expect(sources).toContain("Sources used for this assessment");
784
788
  expect(html).not.toContain("Cited sources in Activity");
785
789
  expect(html).not.toContain("Agent-selected check");
@@ -1745,7 +1749,7 @@ describe("InvestigationEvidencePane honest result states", () => {
1745
1749
 
1746
1750
  const html = render(projection);
1747
1751
  expect(html).toContain("More evidence about this workload");
1748
- expect(html).toContain("No matching warning events");
1752
+ expect(html).toContain("No warning events");
1749
1753
  expect(html).not.toContain("What Radar did not find");
1750
1754
  expect(html).not.toContain(`${receipt!.id}-body`);
1751
1755
  });
@@ -1841,7 +1845,7 @@ describe("InvestigationEvidencePane honest result states", () => {
1841
1845
  expect(projection.coverage.checked).toBe(0);
1842
1846
  expect(projection.groups).toHaveLength(0);
1843
1847
  expect(html).not.toContain('id="investigation-checked-heading"');
1844
- expect(html).not.toContain("No matching warning events");
1848
+ expect(html).not.toContain("No warning events");
1845
1849
  expect(html).toContain("Evidence coverage is incomplete");
1846
1850
  });
1847
1851
 
@@ -2701,6 +2705,33 @@ describe("cited broader cards and coverage rows", () => {
2701
2705
  "TargetDown firing",
2702
2706
  ]);
2703
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);
2704
2735
  const html = render(projection, false, undefined, resolution);
2705
2736
  expect(html).toContain("6/12 pods Unschedulable");
2706
2737
  expect(html).toContain(
@@ -2728,7 +2759,7 @@ describe("cited broader cards and coverage rows", () => {
2728
2759
  (group) => group.latest.data.type === "receipt",
2729
2760
  );
2730
2761
  expect(receipts.map((group) => group.latest.title)).toEqual([
2731
- "No events matched",
2762
+ "No events in this window",
2732
2763
  ]);
2733
2764
  expect(receipts[0].latest.data).toMatchObject({
2734
2765
  type: "receipt",
@@ -2771,7 +2802,7 @@ describe("cited broader cards and coverage rows", () => {
2771
2802
  expect(projection.limitations[0].sources).toHaveLength(3);
2772
2803
  const html = render(projection);
2773
2804
  const message =
2774
- "Kubernetes events was narrowed to keep this investigation bounded. Additional matching evidence may exist.";
2805
+ "Kubernetes events returned part of the matching results to keep this investigation fast. More may exist.";
2775
2806
  // Live region, strip summary, the group row's label and its text: never
2776
2807
  // a second identical detail row beneath it.
2777
2808
  expect(
@@ -2833,3 +2864,748 @@ describe("grouped startup cards", () => {
2833
2864
  expect(html).toContain('aria-expanded="false"');
2834
2865
  });
2835
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
+ });