@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.
- package/package.json +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- 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 {
|
|
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
|
-
"
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|