@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.
- package/package.json +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- 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/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- 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 +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- 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 +6 -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 +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- 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/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- 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 {
|
|
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
|
-
"
|
|
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("
|
|
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
|
|
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
|
|
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
|
+
});
|