@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
|
@@ -3,13 +3,17 @@ import {
|
|
|
3
3
|
defaultConditionTone,
|
|
4
4
|
displayKind,
|
|
5
5
|
englishPlural,
|
|
6
|
+
hpaStateLabel,
|
|
7
|
+
hpaStateLevel,
|
|
6
8
|
kindToPlural,
|
|
7
9
|
stripAnsi,
|
|
10
|
+
type HPADiagnosisState,
|
|
8
11
|
type Issue,
|
|
9
12
|
type IssueRecentChange,
|
|
10
13
|
type Topology,
|
|
11
14
|
} from "@skyhook-io/k8s-ui";
|
|
12
15
|
import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
|
|
16
|
+
import type { TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
|
|
13
17
|
import { apiVersionToGroup } from "../../utils/navigation";
|
|
14
18
|
|
|
15
19
|
import {
|
|
@@ -28,6 +32,7 @@ import {
|
|
|
28
32
|
} from "./diagnoseEvidenceTypes";
|
|
29
33
|
import type { RootCauseEvidence } from "../../api/diagnose";
|
|
30
34
|
import { investigationResourceEvidenceSummary } from "./investigationResourceEvidenceModel";
|
|
35
|
+
import { metricsUnitForExpression } from "./investigationMetrics";
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* The projection deliberately consumes only the small, structural portion of
|
|
@@ -92,7 +97,8 @@ export type InvestigationEvidenceKind =
|
|
|
92
97
|
| "receipt"
|
|
93
98
|
| "alerts"
|
|
94
99
|
| "helm"
|
|
95
|
-
| "permissions"
|
|
100
|
+
| "permissions"
|
|
101
|
+
| "metrics";
|
|
96
102
|
|
|
97
103
|
type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
|
|
98
104
|
|
|
@@ -425,7 +431,13 @@ export type InvestigationEvidenceData =
|
|
|
425
431
|
checked:
|
|
426
432
|
"issues" | "events" | "changes" | "inventory" | "logs" | "alerts";
|
|
427
433
|
scope: string;
|
|
428
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Only when there is something to add. The card already shows the title
|
|
436
|
+
* and the scope, so a line that restates either costs the reader a read
|
|
437
|
+
* and returns nothing; this carries the reason the answer is what it is,
|
|
438
|
+
* or the limit of what it proves.
|
|
439
|
+
*/
|
|
440
|
+
message?: string;
|
|
429
441
|
}
|
|
430
442
|
| {
|
|
431
443
|
type: "alerts";
|
|
@@ -445,7 +457,40 @@ export type InvestigationEvidenceData =
|
|
|
445
457
|
truncated?: boolean;
|
|
446
458
|
usedByPods?: string[];
|
|
447
459
|
podsTotal?: number;
|
|
448
|
-
}
|
|
460
|
+
}
|
|
461
|
+
| InvestigationMetricsEvidence;
|
|
462
|
+
/** One PromQL vector selector as the producer tokenized it. */
|
|
463
|
+
export interface InvestigationMetricsSelector {
|
|
464
|
+
metric: string;
|
|
465
|
+
matchers: Array<{ label: string; op: string; value: string }>;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A Prometheus result captured during the investigation. `origin` says which
|
|
470
|
+
* producer ran the query; the shape is shared so every metrics card renders
|
|
471
|
+
* the same way. `subject` is set only when the query's selectors are all
|
|
472
|
+
* scoped to the investigation target.
|
|
473
|
+
*/
|
|
474
|
+
export interface InvestigationMetricsEvidence {
|
|
475
|
+
type: "metrics";
|
|
476
|
+
origin: "query" | "diagnose";
|
|
477
|
+
query: string;
|
|
478
|
+
mode: "range" | "instant";
|
|
479
|
+
start?: string;
|
|
480
|
+
end?: string;
|
|
481
|
+
step?: string;
|
|
482
|
+
unit?: string;
|
|
483
|
+
label?: string;
|
|
484
|
+
series: TimeSeries[];
|
|
485
|
+
truncated: boolean;
|
|
486
|
+
summary?: unknown;
|
|
487
|
+
note?: string;
|
|
488
|
+
selectors?: InvestigationMetricsSelector[];
|
|
489
|
+
selectorsUnknown?: boolean;
|
|
490
|
+
subject?: DiagnosisResourceRef;
|
|
491
|
+
pods?: number;
|
|
492
|
+
partial?: boolean;
|
|
493
|
+
}
|
|
449
494
|
|
|
450
495
|
export interface InvestigationEvidenceObservation {
|
|
451
496
|
source: InvestigationEvidenceSource;
|
|
@@ -635,6 +680,8 @@ export function investigationEvidenceSubjectRef(
|
|
|
635
680
|
namespace: data.subject.namespace,
|
|
636
681
|
name: data.subject.name,
|
|
637
682
|
};
|
|
683
|
+
case "metrics":
|
|
684
|
+
return data.subject;
|
|
638
685
|
default:
|
|
639
686
|
return undefined;
|
|
640
687
|
}
|
|
@@ -671,6 +718,16 @@ export function investigationEvidenceSourceDomId(sourceId: string): string {
|
|
|
671
718
|
|
|
672
719
|
const investigationEvidenceRefRe = /^ev_[a-z2-7]{26,128}_[a-z2-7]{26,128}$/;
|
|
673
720
|
|
|
721
|
+
export function isInvestigationEvidenceRef(value: string): boolean {
|
|
722
|
+
return investigationEvidenceRefRe.test(value);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function investigationSourceArgs(
|
|
726
|
+
source: InvestigationEvidenceSource,
|
|
727
|
+
): Record<string, unknown> | undefined {
|
|
728
|
+
return record(source.args ? parseJSON(source.args) : undefined);
|
|
729
|
+
}
|
|
730
|
+
|
|
674
731
|
export function resolveInvestigationRootCauseEvidence(
|
|
675
732
|
projection: InvestigationEvidenceProjection,
|
|
676
733
|
evidence: RootCauseEvidence | undefined,
|
|
@@ -1270,6 +1327,7 @@ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
1270
1327
|
get_prometheus_rules: "Alert rules",
|
|
1271
1328
|
get_helm_release: "Helm release",
|
|
1272
1329
|
get_subject_permissions: "Permissions",
|
|
1330
|
+
query_prometheus: "Prometheus query",
|
|
1273
1331
|
};
|
|
1274
1332
|
|
|
1275
1333
|
function investigationResultLabel(source: InvestigationEvidenceSource): string {
|
|
@@ -1363,6 +1421,13 @@ class ProjectionBuilder {
|
|
|
1363
1421
|
Set<InvestigationSemanticDomain>
|
|
1364
1422
|
>();
|
|
1365
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* Pods a target-scoped diagnose bundle listed as the workload's own, by
|
|
1426
|
+
* controller ownership. Membership for an agent's pod-level Prometheus
|
|
1427
|
+
* query is proved against this set, never inferred from a name.
|
|
1428
|
+
*/
|
|
1429
|
+
readonly establishedTargetPods = new Set<string>();
|
|
1430
|
+
|
|
1366
1431
|
private readonly groupByIdentity = new Map<
|
|
1367
1432
|
string,
|
|
1368
1433
|
InvestigationEvidenceGroup
|
|
@@ -1585,7 +1650,7 @@ function addNarrowHint(
|
|
|
1585
1650
|
source,
|
|
1586
1651
|
label,
|
|
1587
1652
|
label +
|
|
1588
|
-
"
|
|
1653
|
+
" returned part of the matching results to keep this investigation fast. More may exist.",
|
|
1589
1654
|
"truncated",
|
|
1590
1655
|
);
|
|
1591
1656
|
}
|
|
@@ -1608,7 +1673,7 @@ function addResourceContextLimitations(
|
|
|
1608
1673
|
builder.limit(
|
|
1609
1674
|
source,
|
|
1610
1675
|
omitted.field,
|
|
1611
|
-
`
|
|
1676
|
+
`Radar left out part of the resource context (${omitted.reason.replaceAll("_", " ")}).`,
|
|
1612
1677
|
omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
|
|
1613
1678
|
);
|
|
1614
1679
|
}
|
|
@@ -1617,7 +1682,7 @@ function addResourceContextLimitations(
|
|
|
1617
1682
|
builder.limit(
|
|
1618
1683
|
source,
|
|
1619
1684
|
"Relationships",
|
|
1620
|
-
`
|
|
1685
|
+
`Radar returned ${shown} of ${context.referencedBy.total} referenced-by relationships.`,
|
|
1621
1686
|
"truncated",
|
|
1622
1687
|
);
|
|
1623
1688
|
}
|
|
@@ -1625,7 +1690,7 @@ function addResourceContextLimitations(
|
|
|
1625
1690
|
builder.limit(
|
|
1626
1691
|
source,
|
|
1627
1692
|
"Application references",
|
|
1628
|
-
"
|
|
1693
|
+
"Radar returned part of the stale Secret references.",
|
|
1629
1694
|
"truncated",
|
|
1630
1695
|
);
|
|
1631
1696
|
}
|
|
@@ -1640,7 +1705,7 @@ function addIssueLimitations(
|
|
|
1640
1705
|
builder.limit(
|
|
1641
1706
|
source,
|
|
1642
1707
|
`Radar Issue ${value.id}`,
|
|
1643
|
-
"
|
|
1708
|
+
"Radar returned part of the affected-resource list.",
|
|
1644
1709
|
"truncated",
|
|
1645
1710
|
);
|
|
1646
1711
|
}
|
|
@@ -1661,11 +1726,16 @@ function resourceObservationSummary(
|
|
|
1661
1726
|
if (gitOps?.sync) return `Sync ${gitOps.sync}`;
|
|
1662
1727
|
if (gitOps?.suspended) return "Reconciliation suspended";
|
|
1663
1728
|
const replicas = context?.workloadSummary?.replicas;
|
|
1729
|
+
const adverseScaler = adverseScalerStates(context)[0];
|
|
1664
1730
|
if (replicas?.desired !== undefined) {
|
|
1665
1731
|
const desired = replicas.desired;
|
|
1666
1732
|
const ready = replicas.ready ?? 0;
|
|
1667
|
-
|
|
1733
|
+
const readiness = `${ready}/${desired} replicas ready`;
|
|
1734
|
+
return adverseScaler
|
|
1735
|
+
? `${readiness} · HPA: ${hpaStateLabel(adverseScaler)}`
|
|
1736
|
+
: readiness;
|
|
1668
1737
|
}
|
|
1738
|
+
if (adverseScaler) return `HPA: ${hpaStateLabel(adverseScaler)}`;
|
|
1669
1739
|
if (context?.statusSummary?.phase) return context.statusSummary.phase;
|
|
1670
1740
|
if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
|
|
1671
1741
|
return (
|
|
@@ -1675,6 +1745,22 @@ function resourceObservationSummary(
|
|
|
1675
1745
|
);
|
|
1676
1746
|
}
|
|
1677
1747
|
|
|
1748
|
+
// A scaler that cannot act (no metrics, cannot read the target) or is pinned
|
|
1749
|
+
// at its ceiling is a captured Radar fact about the workload, so it lifts the
|
|
1750
|
+
// card the way an adverse condition does. Ordinary scale-ups and scale-downs
|
|
1751
|
+
// are the autoscaler working and stay as detail.
|
|
1752
|
+
function adverseScalerStates(
|
|
1753
|
+
context: InvestigationResourceContext | undefined,
|
|
1754
|
+
): HPADiagnosisState[] {
|
|
1755
|
+
return (context?.scaledBy ?? []).flatMap((scaler) => {
|
|
1756
|
+
const state = scaler.hpaSummary?.state;
|
|
1757
|
+
if (!state) return [];
|
|
1758
|
+
return hpaStateLevel(state) === "unhealthy" || state === "limited_max"
|
|
1759
|
+
? [state]
|
|
1760
|
+
: [];
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1678
1764
|
function addResourceObservation(
|
|
1679
1765
|
builder: ProjectionBuilder,
|
|
1680
1766
|
source: InvestigationEvidenceSource,
|
|
@@ -1712,7 +1798,11 @@ function addResourceObservation(
|
|
|
1712
1798
|
gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
|
|
1713
1799
|
)),
|
|
1714
1800
|
);
|
|
1715
|
-
const hasAdverseState =
|
|
1801
|
+
const hasAdverseState =
|
|
1802
|
+
replicaShortfall ||
|
|
1803
|
+
adverseCondition ||
|
|
1804
|
+
gitOpsAdverse ||
|
|
1805
|
+
adverseScalerStates(context).length > 0;
|
|
1716
1806
|
const intendedTier: InvestigationEvidenceTier =
|
|
1717
1807
|
critical && !hasDetailedCriticalIssue
|
|
1718
1808
|
? "key"
|
|
@@ -1833,9 +1923,8 @@ function addEvents(
|
|
|
1833
1923
|
complete = true,
|
|
1834
1924
|
emptyIsAuthoritative = false,
|
|
1835
1925
|
relevance: InvestigationEvidenceRelevance = "broader",
|
|
1836
|
-
emptyReceipt: { title: string; message
|
|
1837
|
-
title: "No
|
|
1838
|
-
message: "The warning-event query completed and returned no groups.",
|
|
1926
|
+
emptyReceipt: { title: string; message?: string } = {
|
|
1927
|
+
title: "No warning events",
|
|
1839
1928
|
},
|
|
1840
1929
|
): void {
|
|
1841
1930
|
const scope = scopeFromArgs(source);
|
|
@@ -1933,13 +2022,12 @@ function addChanges(
|
|
|
1933
2022
|
tier: evidenceTierForRelevance("checked", relevance),
|
|
1934
2023
|
relevance,
|
|
1935
2024
|
tone: "neutral",
|
|
1936
|
-
title: "No
|
|
2025
|
+
title: "No recorded changes in this window",
|
|
1937
2026
|
summary: scope,
|
|
1938
2027
|
data: {
|
|
1939
2028
|
type: "receipt",
|
|
1940
2029
|
checked: "changes",
|
|
1941
2030
|
scope,
|
|
1942
|
-
message: "The requested change window returned no tracked changes.",
|
|
1943
2031
|
},
|
|
1944
2032
|
});
|
|
1945
2033
|
return;
|
|
@@ -2272,6 +2360,7 @@ function adaptDiagnose(
|
|
|
2272
2360
|
});
|
|
2273
2361
|
const relatedRelevance: InvestigationEvidenceRelevance =
|
|
2274
2362
|
bundleRelevance === "target" ? "producer-related" : "broader";
|
|
2363
|
+
|
|
2275
2364
|
const bundledRowRelevance = (
|
|
2276
2365
|
kind: string,
|
|
2277
2366
|
name: string,
|
|
@@ -2381,14 +2470,12 @@ function adaptDiagnose(
|
|
|
2381
2470
|
tier: evidenceTierForRelevance("checked", bundleRelevance),
|
|
2382
2471
|
relevance: bundleRelevance,
|
|
2383
2472
|
tone: "neutral",
|
|
2384
|
-
title: "
|
|
2473
|
+
title: "Radar's diagnosis found no live issues",
|
|
2385
2474
|
summary: scope,
|
|
2386
2475
|
data: {
|
|
2387
2476
|
type: "receipt",
|
|
2388
2477
|
checked: "issues",
|
|
2389
2478
|
scope,
|
|
2390
|
-
message:
|
|
2391
|
-
"Radar's workload diagnosis completed without a classified live issue for this resource.",
|
|
2392
2479
|
},
|
|
2393
2480
|
});
|
|
2394
2481
|
}
|
|
@@ -2407,9 +2494,14 @@ function adaptDiagnose(
|
|
|
2407
2494
|
if (Array.isArray(blockersRaw)) {
|
|
2408
2495
|
for (const { blocker, pods, foldedInto } of blockerGroups) {
|
|
2409
2496
|
if (foldedInto) continue;
|
|
2497
|
+
// One blocker keeps one identity however many pods share it. Keying a
|
|
2498
|
+
// merged card differently from a single-pod one made a re-diagnose that
|
|
2499
|
+
// crossed one pod look like a new fact, so the same reason appeared
|
|
2500
|
+
// twice; a Pod blocker is the same finding whether it holds one pod or
|
|
2501
|
+
// nine.
|
|
2410
2502
|
const grouped = blocker.kind === "Pod" && pods.length > 1;
|
|
2411
2503
|
builder.observe(
|
|
2412
|
-
|
|
2504
|
+
blocker.kind === "Pod"
|
|
2413
2505
|
? `startup:Pod:${blocker.reason}:${fnv1a32(blocker.message).toString(36)}`
|
|
2414
2506
|
: `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
|
|
2415
2507
|
"startup",
|
|
@@ -2428,7 +2520,11 @@ function adaptDiagnose(
|
|
|
2428
2520
|
data: {
|
|
2429
2521
|
type: "startup",
|
|
2430
2522
|
blocker,
|
|
2431
|
-
|
|
2523
|
+
// Always carry the pods, whether one or nine: they are what puts
|
|
2524
|
+
// this workload's pods into the set a later Prometheus query is
|
|
2525
|
+
// proved against, and a merged card that dropped them quietly
|
|
2526
|
+
// weakened attribution elsewhere.
|
|
2527
|
+
pods,
|
|
2432
2528
|
subject: grouped
|
|
2433
2529
|
? undefined
|
|
2434
2530
|
: (() => {
|
|
@@ -2502,7 +2598,7 @@ function adaptDiagnose(
|
|
|
2502
2598
|
builder.limit(
|
|
2503
2599
|
source,
|
|
2504
2600
|
"Crash evidence",
|
|
2505
|
-
"
|
|
2601
|
+
"Radar returned part of the crash-cause candidates.",
|
|
2506
2602
|
"truncated",
|
|
2507
2603
|
);
|
|
2508
2604
|
}
|
|
@@ -2553,7 +2649,7 @@ function adaptDiagnose(
|
|
|
2553
2649
|
tier: evidenceTierForRelevance("checked", logRelevance),
|
|
2554
2650
|
relevance: logRelevance,
|
|
2555
2651
|
tone: "neutral",
|
|
2556
|
-
title: "No previous
|
|
2652
|
+
title: "No previous logs expected",
|
|
2557
2653
|
summary: `${item.pod} / ${item.container}`,
|
|
2558
2654
|
data: {
|
|
2559
2655
|
type: "receipt",
|
|
@@ -2785,6 +2881,21 @@ function adaptDiagnose(
|
|
|
2785
2881
|
if (nonEmptyString(value.recentChangesError)) {
|
|
2786
2882
|
builder.limit(source, "Recent changes", value.recentChangesError, "error");
|
|
2787
2883
|
}
|
|
2884
|
+
|
|
2885
|
+
addDiagnoseMetrics(
|
|
2886
|
+
builder,
|
|
2887
|
+
source,
|
|
2888
|
+
value.metrics,
|
|
2889
|
+
{
|
|
2890
|
+
kind: resource.kind,
|
|
2891
|
+
...(apiVersionToGroup(resource.apiVersion)
|
|
2892
|
+
? { group: apiVersionToGroup(resource.apiVersion) }
|
|
2893
|
+
: {}),
|
|
2894
|
+
namespace: resource.metadata.namespace,
|
|
2895
|
+
name: resource.metadata.name,
|
|
2896
|
+
},
|
|
2897
|
+
bundleRelevance,
|
|
2898
|
+
);
|
|
2788
2899
|
if (value.recentChangesSaturated === true) {
|
|
2789
2900
|
builder.limit(
|
|
2790
2901
|
source,
|
|
@@ -2973,14 +3084,12 @@ function adaptIssues(
|
|
|
2973
3084
|
tier: evidenceTierForRelevance("checked", relevance),
|
|
2974
3085
|
relevance,
|
|
2975
3086
|
tone: "neutral",
|
|
2976
|
-
title: "No
|
|
3087
|
+
title: "No live issues matched this search",
|
|
2977
3088
|
summary: scope,
|
|
2978
3089
|
data: {
|
|
2979
3090
|
type: "receipt",
|
|
2980
3091
|
checked: "issues",
|
|
2981
3092
|
scope,
|
|
2982
|
-
message:
|
|
2983
|
-
"Radar's live-issue query completed and returned no matching issues.",
|
|
2984
3093
|
},
|
|
2985
3094
|
});
|
|
2986
3095
|
} else {
|
|
@@ -3190,7 +3299,7 @@ function adaptListResources(
|
|
|
3190
3299
|
builder.limit(
|
|
3191
3300
|
source,
|
|
3192
3301
|
"Resource inventory",
|
|
3193
|
-
`Radar found no matching resources for ${scope}
|
|
3302
|
+
`Radar found no matching resources for ${scope}. Anything you do not have permission to read was not searched.`,
|
|
3194
3303
|
"unknown",
|
|
3195
3304
|
);
|
|
3196
3305
|
return;
|
|
@@ -3248,6 +3357,18 @@ function adaptEvents(
|
|
|
3248
3357
|
);
|
|
3249
3358
|
return;
|
|
3250
3359
|
}
|
|
3360
|
+
// A cluster-wide read the producer narrowed to the caller's namespaces
|
|
3361
|
+
// answers for those alone, so neither its empty receipt nor its card may
|
|
3362
|
+
// stand for the cluster.
|
|
3363
|
+
const narrowedTo = narrowedEventScope(value);
|
|
3364
|
+
if (narrowedTo && events.length > 0) {
|
|
3365
|
+
builder.limit(
|
|
3366
|
+
source,
|
|
3367
|
+
"Events",
|
|
3368
|
+
`This cluster-wide events read covered only the namespaces you can read (${narrowedTo}).`,
|
|
3369
|
+
"unknown",
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3251
3372
|
addEvents(
|
|
3252
3373
|
builder,
|
|
3253
3374
|
source,
|
|
@@ -3256,12 +3377,33 @@ function adaptEvents(
|
|
|
3256
3377
|
!nonEmptyString(value.narrowHint),
|
|
3257
3378
|
true,
|
|
3258
3379
|
sourceArgsRelevance(builder, source),
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3380
|
+
narrowedTo
|
|
3381
|
+
? {
|
|
3382
|
+
title: "No events in the namespaces you can read",
|
|
3383
|
+
message: `Read ${narrowedTo}. Namespaces outside your permissions were not read, so this does not clear the cluster.`,
|
|
3384
|
+
}
|
|
3385
|
+
: {
|
|
3386
|
+
title: "No events in this window",
|
|
3387
|
+
message:
|
|
3388
|
+
"Events outside this window or its filters are not covered, and a namespace you cannot read also returns nothing.",
|
|
3389
|
+
},
|
|
3390
|
+
);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* Names the namespaces a cluster-wide events read was actually narrowed to,
|
|
3395
|
+
* or undefined when the read covered everything the query asked for.
|
|
3396
|
+
*/
|
|
3397
|
+
function narrowedEventScope(
|
|
3398
|
+
value: Record<string, unknown>,
|
|
3399
|
+
): string | undefined {
|
|
3400
|
+
if (value.partialScope !== true) return undefined;
|
|
3401
|
+
const namespaces = (stringArray(value.scopeNamespaces) ?? []).filter(
|
|
3402
|
+
nonEmptyString,
|
|
3264
3403
|
);
|
|
3404
|
+
return namespaces.length > 0
|
|
3405
|
+
? namespaces.join(", ")
|
|
3406
|
+
: "the namespaces you can read";
|
|
3265
3407
|
}
|
|
3266
3408
|
|
|
3267
3409
|
function adaptPodLogs(
|
|
@@ -3586,11 +3728,13 @@ function adaptWorkloadLogs(
|
|
|
3586
3728
|
return;
|
|
3587
3729
|
}
|
|
3588
3730
|
if (!source.confirmedSuccess) return;
|
|
3731
|
+
// The producer's own sentence when it gave one; otherwise nothing, because
|
|
3732
|
+
// "no pods to read logs from" is already the whole answer.
|
|
3589
3733
|
const message = nonEmptyString(value.emptyMessage)
|
|
3590
3734
|
? value.emptyMessage
|
|
3591
3735
|
: nonEmptyString(value.logs)
|
|
3592
3736
|
? value.logs
|
|
3593
|
-
:
|
|
3737
|
+
: undefined;
|
|
3594
3738
|
builder.observe(
|
|
3595
3739
|
`workload-logs:${previous ? "previous" : "current"}:${scope}`,
|
|
3596
3740
|
"receipt",
|
|
@@ -3607,7 +3751,7 @@ function adaptWorkloadLogs(
|
|
|
3607
3751
|
return;
|
|
3608
3752
|
}
|
|
3609
3753
|
const logsRaw = value.logs;
|
|
3610
|
-
const noStreams = `
|
|
3754
|
+
const noStreams = `Radar found ${value.pods} pod${value.pods === 1 ? "" : "s"} but got no logs from ${value.pods === 1 ? "it" : "them"}, so the logs were not checked.`;
|
|
3611
3755
|
if (!Array.isArray(logsRaw)) {
|
|
3612
3756
|
if (logsRaw === undefined || logsRaw === null) {
|
|
3613
3757
|
builder.limit(source, "Workload logs", noStreams, "unknown");
|
|
@@ -3675,7 +3819,7 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
|
3675
3819
|
function producerEstablishedTargetPods(
|
|
3676
3820
|
builder: ProjectionBuilder,
|
|
3677
3821
|
): Set<string> {
|
|
3678
|
-
const pods = new Set<string>();
|
|
3822
|
+
const pods = new Set<string>(builder.establishedTargetPods);
|
|
3679
3823
|
const namespace = builder.target.namespace;
|
|
3680
3824
|
if (!namespace) return pods;
|
|
3681
3825
|
for (const group of builder.groups) {
|
|
@@ -3686,12 +3830,11 @@ function producerEstablishedTargetPods(
|
|
|
3686
3830
|
pods.add(data.pod);
|
|
3687
3831
|
} else if (data.type === "crash" && data.namespace === namespace) {
|
|
3688
3832
|
for (const pod of data.crash.pods) pods.add(pod);
|
|
3689
|
-
} else if (
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
pods.add(data.subject.name);
|
|
3833
|
+
} else if (data.type === "startup" && data.blocker.kind === "Pod") {
|
|
3834
|
+
// The blocker names the pods it holds whether the card merged them or
|
|
3835
|
+
// not; the subject is only set when there is exactly one, so reading
|
|
3836
|
+
// it alone lost the whole set the moment a second pod appeared.
|
|
3837
|
+
for (const pod of data.pods ?? []) pods.add(pod);
|
|
3695
3838
|
}
|
|
3696
3839
|
}
|
|
3697
3840
|
}
|
|
@@ -4002,7 +4145,7 @@ function adaptPrometheusRules(
|
|
|
4002
4145
|
tier: "checked",
|
|
4003
4146
|
relevance: "target",
|
|
4004
4147
|
tone: "neutral",
|
|
4005
|
-
title: `No ${stateFilter}
|
|
4148
|
+
title: `No ${stateFilter} alerts matched this ${displayKind(builder.target.kind)}`,
|
|
4006
4149
|
summary: identity,
|
|
4007
4150
|
data: {
|
|
4008
4151
|
type: "receipt",
|
|
@@ -4486,6 +4629,578 @@ function adaptSubjectPermissions(
|
|
|
4486
4629
|
}
|
|
4487
4630
|
}
|
|
4488
4631
|
|
|
4632
|
+
function timeSeries(value: unknown): TimeSeries | undefined {
|
|
4633
|
+
const item = record(value);
|
|
4634
|
+
if (!item || !Array.isArray(item.dataPoints)) return undefined;
|
|
4635
|
+
const labels = record(item.labels) ?? {};
|
|
4636
|
+
if (Object.values(labels).some((label) => typeof label !== "string")) {
|
|
4637
|
+
return undefined;
|
|
4638
|
+
}
|
|
4639
|
+
const dataPoints: TimeSeries["dataPoints"] = [];
|
|
4640
|
+
for (const raw of item.dataPoints) {
|
|
4641
|
+
const point = record(raw);
|
|
4642
|
+
if (!point || typeof point.timestamp !== "number") return undefined;
|
|
4643
|
+
if (
|
|
4644
|
+
point.value !== undefined &&
|
|
4645
|
+
point.value !== null &&
|
|
4646
|
+
typeof point.value !== "number"
|
|
4647
|
+
) {
|
|
4648
|
+
return undefined;
|
|
4649
|
+
}
|
|
4650
|
+
dataPoints.push({
|
|
4651
|
+
timestamp: point.timestamp,
|
|
4652
|
+
value: typeof point.value === "number" ? point.value : null,
|
|
4653
|
+
});
|
|
4654
|
+
}
|
|
4655
|
+
return { labels: labels as Record<string, string>, dataPoints };
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
function metricsSelector(
|
|
4659
|
+
value: unknown,
|
|
4660
|
+
): InvestigationMetricsSelector | undefined {
|
|
4661
|
+
const item = record(value);
|
|
4662
|
+
if (!item || typeof item.metric !== "string" || !Array.isArray(item.matchers))
|
|
4663
|
+
return undefined;
|
|
4664
|
+
const matchers: InvestigationMetricsSelector["matchers"] = [];
|
|
4665
|
+
for (const raw of item.matchers) {
|
|
4666
|
+
const matcher = record(raw);
|
|
4667
|
+
if (
|
|
4668
|
+
!matcher ||
|
|
4669
|
+
typeof matcher.label !== "string" ||
|
|
4670
|
+
typeof matcher.op !== "string" ||
|
|
4671
|
+
typeof matcher.value !== "string"
|
|
4672
|
+
) {
|
|
4673
|
+
return undefined;
|
|
4674
|
+
}
|
|
4675
|
+
matchers.push({
|
|
4676
|
+
label: matcher.label,
|
|
4677
|
+
op: matcher.op,
|
|
4678
|
+
value: matcher.value,
|
|
4679
|
+
});
|
|
4680
|
+
}
|
|
4681
|
+
return { metric: item.metric, matchers };
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
/**
|
|
4685
|
+
* How one selector relates to the investigated resource. "target": it names
|
|
4686
|
+
* the target itself (an identity label, or pods a producer established as
|
|
4687
|
+
* the target's own). "related": it mentions the target's namespace and
|
|
4688
|
+
* something in it the target cannot be proved to own (a pod named by prefix,
|
|
4689
|
+
* a sibling workload, an owner series). "namespace": only the namespace.
|
|
4690
|
+
* "foreign": another namespace or none.
|
|
4691
|
+
*/
|
|
4692
|
+
type SelectorVerdict = "target" | "related" | "namespace" | "foreign";
|
|
4693
|
+
|
|
4694
|
+
const VERDICT_RANK: Record<SelectorVerdict, number> = {
|
|
4695
|
+
foreign: 3,
|
|
4696
|
+
target: 2,
|
|
4697
|
+
related: 1,
|
|
4698
|
+
namespace: 0,
|
|
4699
|
+
};
|
|
4700
|
+
|
|
4701
|
+
/**
|
|
4702
|
+
* A regex matcher names the target only in the exact forms the plan admits,
|
|
4703
|
+
* with the name's regex metacharacters (a dot in a Kubernetes name) escaped
|
|
4704
|
+
* or absent. "api-?.*" also selects "apiworker-…" and an unescaped "api.v2"
|
|
4705
|
+
* also selects "api-v2", so neither may claim the target.
|
|
4706
|
+
*/
|
|
4707
|
+
function regexNamesExactly(
|
|
4708
|
+
value: string,
|
|
4709
|
+
name: string,
|
|
4710
|
+
suffix: string,
|
|
4711
|
+
): boolean {
|
|
4712
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4713
|
+
return value === `${escaped}${suffix}`;
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
/**
|
|
4717
|
+
* The pod names an exact-set regex `^(a|b)$` (or the unanchored `a|b`, which
|
|
4718
|
+
* Prometheus anchors) lists. A dot must be escaped to count: `^(api.v2-0)$`
|
|
4719
|
+
* also selects `api-v2-0`, so it names more than the pod it appears to and
|
|
4720
|
+
* cannot prove membership. Undefined for any other shape.
|
|
4721
|
+
*/
|
|
4722
|
+
function exactPodSet(value: string): string[] | undefined {
|
|
4723
|
+
const body =
|
|
4724
|
+
value.startsWith("^(") && value.endsWith(")$") ? value.slice(2, -2) : value;
|
|
4725
|
+
if (body === "" || /[^A-Za-z0-9\-|\\.]/.test(body)) return undefined;
|
|
4726
|
+
// Every dot has to arrive escaped; an unescaped one is a wildcard.
|
|
4727
|
+
if (/(^|[^\\])\./.test(body.replace(/\\\\/g, ""))) return undefined;
|
|
4728
|
+
const names = body.split("|").map((name) => name.replace(/\\\./g, "."));
|
|
4729
|
+
return names.every((name) => /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(name))
|
|
4730
|
+
? names
|
|
4731
|
+
: undefined;
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
/**
|
|
4735
|
+
* Whether a workload identity label names the target: the label
|
|
4736
|
+
* kube-state-metrics uses for the kind (`deployment`, `job_name`, …) or a
|
|
4737
|
+
* generic `workload` label, with the exact name.
|
|
4738
|
+
*/
|
|
4739
|
+
function identityLabelNamesTarget(
|
|
4740
|
+
target: InvestigationEvidenceTarget,
|
|
4741
|
+
label: string,
|
|
4742
|
+
value: string,
|
|
4743
|
+
exact: boolean,
|
|
4744
|
+
): boolean {
|
|
4745
|
+
const kind = target.kind.toLowerCase();
|
|
4746
|
+
const workloadLabel = WORKLOAD_LABEL_BY_KIND[kind];
|
|
4747
|
+
// `deployment=api` states the kind in the label itself; `workload=api` does
|
|
4748
|
+
// not, so a `workload_type` naming another kind vetoes it in
|
|
4749
|
+
// classifySelector, which is the only place that sees the whole selector.
|
|
4750
|
+
if (label !== workloadLabel && label !== "workload") return false;
|
|
4751
|
+
return exact
|
|
4752
|
+
? value === target.name
|
|
4753
|
+
: regexNamesExactly(value, target.name, "");
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
function classifyMatcher(
|
|
4757
|
+
target: InvestigationEvidenceTarget,
|
|
4758
|
+
matcher: InvestigationMetricsSelector["matchers"][number],
|
|
4759
|
+
establishedPods: ReadonlySet<string>,
|
|
4760
|
+
): SelectorVerdict {
|
|
4761
|
+
const { label, op, value } = matcher;
|
|
4762
|
+
const kind = target.kind.toLowerCase();
|
|
4763
|
+
if (label === "pod") {
|
|
4764
|
+
if (kind === "pod") {
|
|
4765
|
+
if (op === "=") return value === target.name ? "target" : "related";
|
|
4766
|
+
if (op === "=~") {
|
|
4767
|
+
if (regexNamesExactly(value, target.name, "")) return "target";
|
|
4768
|
+
// `^(name)$` is the form the diagnose prompt asks the agent to write,
|
|
4769
|
+
// so a Pod target has to recognise its own name in it. The set must
|
|
4770
|
+
// name nothing else: a query covering the target and a neighbour is
|
|
4771
|
+
// not evidence about the target alone.
|
|
4772
|
+
const names = exactPodSet(value);
|
|
4773
|
+
return names && names.every((name) => name === target.name)
|
|
4774
|
+
? "target"
|
|
4775
|
+
: "related";
|
|
4776
|
+
}
|
|
4777
|
+
return "related";
|
|
4778
|
+
}
|
|
4779
|
+
if (op === "=") return establishedPods.has(value) ? "target" : "related";
|
|
4780
|
+
if (op === "=~") {
|
|
4781
|
+
const names = exactPodSet(value);
|
|
4782
|
+
if (names && names.every((name) => establishedPods.has(name))) {
|
|
4783
|
+
return "target";
|
|
4784
|
+
}
|
|
4785
|
+
return "related";
|
|
4786
|
+
}
|
|
4787
|
+
return "related";
|
|
4788
|
+
}
|
|
4789
|
+
if (label === "namespace" || label === "container") return "namespace";
|
|
4790
|
+
if (op === "=" && identityLabelNamesTarget(target, label, value, true)) {
|
|
4791
|
+
return "target";
|
|
4792
|
+
}
|
|
4793
|
+
if (op === "=~" && identityLabelNamesTarget(target, label, value, false)) {
|
|
4794
|
+
return "target";
|
|
4795
|
+
}
|
|
4796
|
+
// Owner series (kube_pod_owner, kube_replicaset_owner) and every other
|
|
4797
|
+
// label in the namespace: about the namespace's things, not proved to be
|
|
4798
|
+
// the target's.
|
|
4799
|
+
return "related";
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
function classifySelector(
|
|
4803
|
+
target: InvestigationEvidenceTarget,
|
|
4804
|
+
selector: InvestigationMetricsSelector,
|
|
4805
|
+
establishedPods: ReadonlySet<string>,
|
|
4806
|
+
): SelectorVerdict {
|
|
4807
|
+
// `workload` is generic, so a `workload_type` naming another kind means
|
|
4808
|
+
// the series is about a different workload that happens to share a name.
|
|
4809
|
+
// A regex counts when it names an exact set, the same way the namespace
|
|
4810
|
+
// matcher below does: `workload_type=~"statefulset"` excludes a Deployment
|
|
4811
|
+
// as plainly as `=` does, and reading only `=` let a sibling's chart take
|
|
4812
|
+
// the target's identity. A set that includes the target kind is no conflict,
|
|
4813
|
+
// and an inexact regex says nothing either way.
|
|
4814
|
+
const workloadType = selector.matchers.find(
|
|
4815
|
+
(matcher) =>
|
|
4816
|
+
matcher.label === "workload_type" &&
|
|
4817
|
+
(matcher.op === "=" || matcher.op === "=~"),
|
|
4818
|
+
);
|
|
4819
|
+
const workloadTypeNames =
|
|
4820
|
+
workloadType === undefined
|
|
4821
|
+
? undefined
|
|
4822
|
+
: workloadType.op === "="
|
|
4823
|
+
? [workloadType.value]
|
|
4824
|
+
: exactPodSet(workloadType.value);
|
|
4825
|
+
const workloadTypeConflicts =
|
|
4826
|
+
workloadTypeNames !== undefined &&
|
|
4827
|
+
workloadTypeNames.length > 0 &&
|
|
4828
|
+
!workloadTypeNames.some(
|
|
4829
|
+
(name) => name.toLowerCase() === target.kind.toLowerCase(),
|
|
4830
|
+
);
|
|
4831
|
+
// Prometheus anchors regex matchers, so namespace=~"shop" is exact too.
|
|
4832
|
+
const inNamespace = selector.matchers.some(
|
|
4833
|
+
(matcher) =>
|
|
4834
|
+
matcher.label === "namespace" &&
|
|
4835
|
+
((matcher.op === "=" && matcher.value === target.namespace) ||
|
|
4836
|
+
(matcher.op === "=~" &&
|
|
4837
|
+
regexNamesExactly(matcher.value, target.namespace ?? "", ""))),
|
|
4838
|
+
);
|
|
4839
|
+
if (!inNamespace) return "foreign";
|
|
4840
|
+
let verdict: SelectorVerdict = "namespace";
|
|
4841
|
+
for (const matcher of selector.matchers) {
|
|
4842
|
+
if (workloadTypeConflicts && matcher.label === "workload") continue;
|
|
4843
|
+
const candidate = classifyMatcher(target, matcher, establishedPods);
|
|
4844
|
+
if (candidate === "foreign") return "foreign";
|
|
4845
|
+
if (VERDICT_RANK[candidate] > VERDICT_RANK[verdict]) verdict = candidate;
|
|
4846
|
+
}
|
|
4847
|
+
return verdict;
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
/**
|
|
4851
|
+
* A metrics result is about the target only when every selector names it:
|
|
4852
|
+
* the target namespace plus pods a producer established as the target's own
|
|
4853
|
+
* or an identity label with its exact name. A selector that only mentions
|
|
4854
|
+
* something in the namespace the target cannot be proved to own (a pod
|
|
4855
|
+
* prefix, a sibling, an owner join) makes the result producer-related: kept,
|
|
4856
|
+
* not promoted, no change markers. A namespace-only query, a bare or foreign
|
|
4857
|
+
* selector, or an inventory the producer could not extract is broader.
|
|
4858
|
+
*/
|
|
4859
|
+
export function metricsScope(
|
|
4860
|
+
target: InvestigationEvidenceTarget,
|
|
4861
|
+
selectors: readonly InvestigationMetricsSelector[],
|
|
4862
|
+
selectorsUnknown: boolean,
|
|
4863
|
+
establishedPods: ReadonlySet<string> = new Set(),
|
|
4864
|
+
): InvestigationEvidenceRelevance {
|
|
4865
|
+
if (selectorsUnknown || selectors.length === 0 || !target.namespace) {
|
|
4866
|
+
return "broader";
|
|
4867
|
+
}
|
|
4868
|
+
let allTarget = true;
|
|
4869
|
+
let anyRelated = false;
|
|
4870
|
+
for (const selector of selectors) {
|
|
4871
|
+
const verdict = classifySelector(target, selector, establishedPods);
|
|
4872
|
+
if (verdict === "foreign") return "broader";
|
|
4873
|
+
if (verdict !== "target") allTarget = false;
|
|
4874
|
+
if (verdict === "related") anyRelated = true;
|
|
4875
|
+
}
|
|
4876
|
+
if (allTarget) return "target";
|
|
4877
|
+
return anyRelated ? "producer-related" : "broader";
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
function metricsWindowLabel(data: {
|
|
4881
|
+
mode: "range" | "instant";
|
|
4882
|
+
start?: string;
|
|
4883
|
+
end?: string;
|
|
4884
|
+
step?: string;
|
|
4885
|
+
}): string | undefined {
|
|
4886
|
+
if (data.mode !== "range" || !data.start || !data.end) return undefined;
|
|
4887
|
+
const startMs = Date.parse(data.start);
|
|
4888
|
+
const endMs = Date.parse(data.end);
|
|
4889
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
|
|
4890
|
+
return undefined;
|
|
4891
|
+
const minutes = Math.round((endMs - startMs) / 60_000);
|
|
4892
|
+
const window =
|
|
4893
|
+
minutes >= 60 * 24 * 2
|
|
4894
|
+
? `${Math.round(minutes / (60 * 24))}d`
|
|
4895
|
+
: minutes >= 120
|
|
4896
|
+
? `${Math.round(minutes / 60)}h`
|
|
4897
|
+
: `${minutes}m`;
|
|
4898
|
+
return data.step
|
|
4899
|
+
? `${window} window · ${data.step} step`
|
|
4900
|
+
: `${window} window`;
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
/**
|
|
4904
|
+
* One half of a Go↔TS contract: `diagnoseMetricsCategories` in
|
|
4905
|
+
* internal/mcp/tools_diagnose_metrics.go decides which categories the producer
|
|
4906
|
+
* captures, and a series whose category is missing here is discarded. Change
|
|
4907
|
+
* both together.
|
|
4908
|
+
*/
|
|
4909
|
+
const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
|
|
4910
|
+
cpu: "CPU usage",
|
|
4911
|
+
memory: "Memory working set",
|
|
4912
|
+
restarts: "Restarts",
|
|
4913
|
+
};
|
|
4914
|
+
|
|
4915
|
+
/**
|
|
4916
|
+
* The vitals `diagnose` captured inside the same call: one chart per category
|
|
4917
|
+
* over the exact pods the bundle covers. They take the bundle's relevance, so a
|
|
4918
|
+
* neighbour's diagnose never charts as evidence for the target, and they carry
|
|
4919
|
+
* the diagnosed resource as their subject so recorded changes can be marked
|
|
4920
|
+
* on them. An absent field means Prometheus was not available or the read was
|
|
4921
|
+
* not permitted, which is not a collection failure the producer reported.
|
|
4922
|
+
*/
|
|
4923
|
+
function addDiagnoseMetrics(
|
|
4924
|
+
builder: ProjectionBuilder,
|
|
4925
|
+
source: InvestigationEvidenceSource,
|
|
4926
|
+
raw: unknown,
|
|
4927
|
+
subject: DiagnosisResourceRef,
|
|
4928
|
+
relevance: InvestigationEvidenceRelevance,
|
|
4929
|
+
): void {
|
|
4930
|
+
if (raw === undefined) return;
|
|
4931
|
+
const value = record(raw);
|
|
4932
|
+
const window = record(value?.window);
|
|
4933
|
+
if (
|
|
4934
|
+
!value ||
|
|
4935
|
+
!window ||
|
|
4936
|
+
!nonEmptyString(window.start) ||
|
|
4937
|
+
!nonEmptyString(window.end) ||
|
|
4938
|
+
!nonEmptyString(window.step) ||
|
|
4939
|
+
!(Date.parse(window.end) > Date.parse(window.start)) ||
|
|
4940
|
+
!nonNegativeInteger(value.pods) ||
|
|
4941
|
+
(value.partial !== undefined && typeof value.partial !== "boolean") ||
|
|
4942
|
+
(value.omittedPods !== undefined &&
|
|
4943
|
+
!nonNegativeInteger(value.omittedPods)) ||
|
|
4944
|
+
(value.error !== undefined && typeof value.error !== "string") ||
|
|
4945
|
+
!Array.isArray(value.series)
|
|
4946
|
+
) {
|
|
4947
|
+
invalidPayload(builder, source, "Workload metrics");
|
|
4948
|
+
return;
|
|
4949
|
+
}
|
|
4950
|
+
const scope = scopeFromArgs(source);
|
|
4951
|
+
const partial = value.partial === true;
|
|
4952
|
+
// The pods the chart covers: the ones the workload controlled when the
|
|
4953
|
+
// bundle was captured, which a rollout during the window can miss.
|
|
4954
|
+
const podsLabel = partial
|
|
4955
|
+
? `first ${value.pods} of ${typeof value.omittedPods === "number" ? value.pods + value.omittedPods : "the"} current pods`
|
|
4956
|
+
: `${value.pods} current pod${value.pods === 1 ? "" : "s"}`;
|
|
4957
|
+
const windowLabel = metricsWindowLabel({
|
|
4958
|
+
mode: "range",
|
|
4959
|
+
start: window.start,
|
|
4960
|
+
end: window.end,
|
|
4961
|
+
step: window.step,
|
|
4962
|
+
});
|
|
4963
|
+
for (const rawEntry of value.series) {
|
|
4964
|
+
const entry = record(rawEntry);
|
|
4965
|
+
const rawSeries = entry?.series;
|
|
4966
|
+
const series = Array.isArray(rawSeries)
|
|
4967
|
+
? rawSeries
|
|
4968
|
+
.map(timeSeries)
|
|
4969
|
+
.filter((item): item is TimeSeries => Boolean(item))
|
|
4970
|
+
: undefined;
|
|
4971
|
+
if (
|
|
4972
|
+
!entry ||
|
|
4973
|
+
!nonEmptyString(entry.category) ||
|
|
4974
|
+
!Object.hasOwn(DIAGNOSE_METRICS_LABELS, entry.category) ||
|
|
4975
|
+
!nonEmptyString(entry.query) ||
|
|
4976
|
+
!nonEmptyString(entry.unit) ||
|
|
4977
|
+
!Array.isArray(rawSeries) ||
|
|
4978
|
+
!series ||
|
|
4979
|
+
series.length !== rawSeries.length
|
|
4980
|
+
) {
|
|
4981
|
+
invalidPayload(builder, source, "Workload metrics");
|
|
4982
|
+
continue;
|
|
4983
|
+
}
|
|
4984
|
+
const label = DIAGNOSE_METRICS_LABELS[entry.category];
|
|
4985
|
+
const data: InvestigationMetricsEvidence = {
|
|
4986
|
+
type: "metrics",
|
|
4987
|
+
origin: "diagnose",
|
|
4988
|
+
query: entry.query,
|
|
4989
|
+
mode: "range",
|
|
4990
|
+
start: window.start,
|
|
4991
|
+
end: window.end,
|
|
4992
|
+
step: window.step,
|
|
4993
|
+
unit: entry.unit,
|
|
4994
|
+
label,
|
|
4995
|
+
series,
|
|
4996
|
+
truncated: false,
|
|
4997
|
+
subject,
|
|
4998
|
+
pods: value.pods,
|
|
4999
|
+
partial,
|
|
5000
|
+
};
|
|
5001
|
+
builder.observe(
|
|
5002
|
+
`metrics:diagnose:${subject.group ?? ""}:${subject.kind}:${subject.namespace ?? ""}:${subject.name}:${entry.category}`,
|
|
5003
|
+
"metrics",
|
|
5004
|
+
source,
|
|
5005
|
+
{
|
|
5006
|
+
tier: evidenceTierForRelevance("supporting", relevance),
|
|
5007
|
+
relevance,
|
|
5008
|
+
tone: "neutral",
|
|
5009
|
+
title: `${label} · ${scope}`,
|
|
5010
|
+
summary: [
|
|
5011
|
+
series.length === 0 ? "No samples in the window" : podsLabel,
|
|
5012
|
+
windowLabel,
|
|
5013
|
+
partial ? "partial pod set" : undefined,
|
|
5014
|
+
]
|
|
5015
|
+
.filter((part): part is string => Boolean(part))
|
|
5016
|
+
.join(" · "),
|
|
5017
|
+
data,
|
|
5018
|
+
},
|
|
5019
|
+
);
|
|
5020
|
+
}
|
|
5021
|
+
if (nonEmptyString(value.error)) {
|
|
5022
|
+
builder.limit(source, "Workload metrics", value.error, "error");
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
// Radar's reading of a metric name, so an agent-written query gets a title a
|
|
5027
|
+
// reader can scan instead of the raw series name. Deliberately small and exact:
|
|
5028
|
+
// an unrecognised metric keeps its own name rather than being handed a meaning
|
|
5029
|
+
// Radar cannot derive from it.
|
|
5030
|
+
const METRIC_FAMILY_LABELS: Record<string, string> = {
|
|
5031
|
+
container_cpu_usage_seconds_total: "CPU usage",
|
|
5032
|
+
container_cpu_cfs_throttled_seconds_total: "CPU throttling",
|
|
5033
|
+
container_memory_working_set_bytes: "Memory working set",
|
|
5034
|
+
container_memory_usage_bytes: "Memory usage",
|
|
5035
|
+
container_memory_rss: "Memory RSS",
|
|
5036
|
+
kube_pod_container_status_restarts_total: "Restarts",
|
|
5037
|
+
container_network_receive_bytes_total: "Network received",
|
|
5038
|
+
container_network_transmit_bytes_total: "Network transmitted",
|
|
5039
|
+
container_fs_usage_bytes: "Filesystem usage",
|
|
5040
|
+
up: "Scrape target up",
|
|
5041
|
+
};
|
|
5042
|
+
|
|
5043
|
+
function adaptQueryPrometheus(
|
|
5044
|
+
builder: ProjectionBuilder,
|
|
5045
|
+
source: InvestigationEvidenceSource,
|
|
5046
|
+
payload: unknown,
|
|
5047
|
+
): void {
|
|
5048
|
+
const value = record(payload);
|
|
5049
|
+
const mode = value?.type;
|
|
5050
|
+
if (
|
|
5051
|
+
!value ||
|
|
5052
|
+
!nonEmptyString(value.query) ||
|
|
5053
|
+
(mode !== "range" && mode !== "instant") ||
|
|
5054
|
+
!Array.isArray(value.series) ||
|
|
5055
|
+
!Array.isArray(value.selectors) ||
|
|
5056
|
+
(value.selectorsUnknown !== undefined &&
|
|
5057
|
+
typeof value.selectorsUnknown !== "boolean") ||
|
|
5058
|
+
(value.truncated !== undefined && typeof value.truncated !== "boolean")
|
|
5059
|
+
) {
|
|
5060
|
+
invalidPayload(builder, source, "Prometheus query");
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
const series = value.series
|
|
5064
|
+
.map(timeSeries)
|
|
5065
|
+
.filter((item): item is TimeSeries => Boolean(item));
|
|
5066
|
+
const selectors = value.selectors
|
|
5067
|
+
.map(metricsSelector)
|
|
5068
|
+
.filter((item): item is InvestigationMetricsSelector => Boolean(item));
|
|
5069
|
+
if (
|
|
5070
|
+
series.length !== value.series.length ||
|
|
5071
|
+
selectors.length !== value.selectors.length
|
|
5072
|
+
) {
|
|
5073
|
+
invalidPayload(builder, source, "Prometheus query");
|
|
5074
|
+
return;
|
|
5075
|
+
}
|
|
5076
|
+
const selectorsUnknown = value.selectorsUnknown === true;
|
|
5077
|
+
const relevance = metricsScope(
|
|
5078
|
+
builder.target,
|
|
5079
|
+
selectors,
|
|
5080
|
+
selectorsUnknown,
|
|
5081
|
+
producerEstablishedTargetPods(builder),
|
|
5082
|
+
);
|
|
5083
|
+
const start = nonEmptyString(value.start) ? value.start : undefined;
|
|
5084
|
+
const end = nonEmptyString(value.end) ? value.end : undefined;
|
|
5085
|
+
const step = nonEmptyString(value.step) ? value.step : undefined;
|
|
5086
|
+
const note = nonEmptyString(value.note) ? value.note : undefined;
|
|
5087
|
+
if (value.truncated === true) {
|
|
5088
|
+
const summary = record(value.summary);
|
|
5089
|
+
const cardinality = record(summary?.labelCardinality);
|
|
5090
|
+
const widest = cardinality
|
|
5091
|
+
? Object.entries(cardinality).find(
|
|
5092
|
+
([, count]) => typeof count === "number",
|
|
5093
|
+
)
|
|
5094
|
+
: undefined;
|
|
5095
|
+
const parts = [
|
|
5096
|
+
typeof summary?.seriesCount === "number"
|
|
5097
|
+
? `${summary.seriesCount} series`
|
|
5098
|
+
: undefined,
|
|
5099
|
+
typeof summary?.totalDataPoints === "number"
|
|
5100
|
+
? `${summary.totalDataPoints} samples`
|
|
5101
|
+
: undefined,
|
|
5102
|
+
widest ? `${widest[1]} distinct ${widest[0]} values` : undefined,
|
|
5103
|
+
].filter((part): part is string => Boolean(part));
|
|
5104
|
+
builder.limit(
|
|
5105
|
+
source,
|
|
5106
|
+
"Prometheus query",
|
|
5107
|
+
`The result of \`${value.query}\` was too large to keep${parts.length ? ` (${parts.join(", ")})` : ""}, so Radar cannot chart it.${note ? ` ${note}` : ""}`,
|
|
5108
|
+
"truncated",
|
|
5109
|
+
);
|
|
5110
|
+
return;
|
|
5111
|
+
}
|
|
5112
|
+
const windowLabel = metricsWindowLabel({ mode, start, end, step });
|
|
5113
|
+
// One metric names the card; the title is Radar's reading of the query,
|
|
5114
|
+
// never the agent's, so a wrong claim cannot become the card's identity.
|
|
5115
|
+
// A bare matcher block such as `{namespace="shop"}` names no metric.
|
|
5116
|
+
const metricNames = [
|
|
5117
|
+
...new Set(
|
|
5118
|
+
selectors.flatMap((selector) =>
|
|
5119
|
+
selector.metric.trim() ? [selector.metric.trim()] : [],
|
|
5120
|
+
),
|
|
5121
|
+
),
|
|
5122
|
+
];
|
|
5123
|
+
// A recognised family names the card; the scope is added only when Radar
|
|
5124
|
+
// already decided the query is about the target, so the title never widens
|
|
5125
|
+
// or narrows what the relevance check concluded.
|
|
5126
|
+
const family =
|
|
5127
|
+
metricNames.length === 1 ? METRIC_FAMILY_LABELS[metricNames[0]] : undefined;
|
|
5128
|
+
const scope =
|
|
5129
|
+
relevance === "target"
|
|
5130
|
+
? `${displayKind(builder.target.kind)} ${builder.target.namespace ? `${builder.target.namespace}/` : ""}${builder.target.name}`
|
|
5131
|
+
: undefined;
|
|
5132
|
+
const title = family
|
|
5133
|
+
? scope
|
|
5134
|
+
? `${family} · ${scope}`
|
|
5135
|
+
: family
|
|
5136
|
+
: metricNames.length === 1
|
|
5137
|
+
? metricNames[0]
|
|
5138
|
+
: mode === "range"
|
|
5139
|
+
? "Prometheus metrics"
|
|
5140
|
+
: "Prometheus values";
|
|
5141
|
+
const data: InvestigationMetricsEvidence = {
|
|
5142
|
+
type: "metrics",
|
|
5143
|
+
origin: "query",
|
|
5144
|
+
query: value.query,
|
|
5145
|
+
mode,
|
|
5146
|
+
start,
|
|
5147
|
+
end,
|
|
5148
|
+
step,
|
|
5149
|
+
unit: metricsUnitForExpression(value.query, selectors),
|
|
5150
|
+
series,
|
|
5151
|
+
truncated: false,
|
|
5152
|
+
note,
|
|
5153
|
+
selectors,
|
|
5154
|
+
selectorsUnknown,
|
|
5155
|
+
subject:
|
|
5156
|
+
relevance === "target"
|
|
5157
|
+
? {
|
|
5158
|
+
kind: builder.target.kind,
|
|
5159
|
+
...(builder.target.group ? { group: builder.target.group } : {}),
|
|
5160
|
+
namespace: builder.target.namespace,
|
|
5161
|
+
name: builder.target.name,
|
|
5162
|
+
}
|
|
5163
|
+
: undefined,
|
|
5164
|
+
};
|
|
5165
|
+
builder.observe(
|
|
5166
|
+
`metrics:${mode}:${value.query}:${start ?? ""}:${end ?? ""}:${step ?? ""}`,
|
|
5167
|
+
"metrics",
|
|
5168
|
+
source,
|
|
5169
|
+
{
|
|
5170
|
+
tier: evidenceTierForRelevance("supporting", relevance),
|
|
5171
|
+
relevance,
|
|
5172
|
+
tone: "neutral",
|
|
5173
|
+
title,
|
|
5174
|
+
summary: [
|
|
5175
|
+
// When a family named the card, the metric name moves here so it stays
|
|
5176
|
+
// visible; otherwise it is already the title.
|
|
5177
|
+
family
|
|
5178
|
+
? metricNames[0]
|
|
5179
|
+
: metricNames.length === 1
|
|
5180
|
+
? "Prometheus"
|
|
5181
|
+
: undefined,
|
|
5182
|
+
series.length === 0 ? "No series matched" : `${series.length} series`,
|
|
5183
|
+
windowLabel,
|
|
5184
|
+
]
|
|
5185
|
+
.filter((part): part is string => Boolean(part))
|
|
5186
|
+
.join(" · "),
|
|
5187
|
+
data,
|
|
5188
|
+
},
|
|
5189
|
+
);
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
/**
|
|
5193
|
+
* Adapters whose verdict depends on which pods a producer established as the
|
|
5194
|
+
* target's. They run after every other tool in the transcript, so the answer
|
|
5195
|
+
* does not depend on whether the agent happened to query Prometheus before or
|
|
5196
|
+
* after the read that named the pods. Their source order is captured before
|
|
5197
|
+
* they are queued, so deferring the classification does not reorder evidence.
|
|
5198
|
+
*/
|
|
5199
|
+
const MEMBERSHIP_DEPENDENT_ADAPTERS = new Set([
|
|
5200
|
+
"query_prometheus",
|
|
5201
|
+
"get_prometheus_rules",
|
|
5202
|
+
]);
|
|
5203
|
+
|
|
4489
5204
|
const ADAPTERS: Record<
|
|
4490
5205
|
string,
|
|
4491
5206
|
(
|
|
@@ -4507,15 +5222,72 @@ const ADAPTERS: Record<
|
|
|
4507
5222
|
get_prometheus_rules: adaptPrometheusRules,
|
|
4508
5223
|
get_helm_release: adaptHelmRelease,
|
|
4509
5224
|
get_subject_permissions: adaptSubjectPermissions,
|
|
5225
|
+
query_prometheus: adaptQueryPrometheus,
|
|
4510
5226
|
};
|
|
4511
5227
|
|
|
5228
|
+
/**
|
|
5229
|
+
* The pods every diagnose of the target listed as its own, gathered before
|
|
5230
|
+
* anything is classified. A metrics query is target evidence when it names
|
|
5231
|
+
* pods a producer established, and that must not depend on whether the agent
|
|
5232
|
+
* happened to run diagnose before or after the query: the same investigation
|
|
5233
|
+
* would otherwise read differently for the same facts.
|
|
5234
|
+
*/
|
|
5235
|
+
function collectEstablishedTargetPods(
|
|
5236
|
+
builder: ProjectionBuilder,
|
|
5237
|
+
turns: readonly InvestigationEvidenceTurn[],
|
|
5238
|
+
): void {
|
|
5239
|
+
for (const turn of turns) {
|
|
5240
|
+
for (const item of turn.timeline) {
|
|
5241
|
+
if (
|
|
5242
|
+
item.kind !== "tool" ||
|
|
5243
|
+
item.tool !== "diagnose" ||
|
|
5244
|
+
item.radarEvidence !== true ||
|
|
5245
|
+
item.status !== "done" ||
|
|
5246
|
+
// Confirmed success, the same test the sources use. This set is what
|
|
5247
|
+
// proves a Prometheus selector is about the target, so a result that
|
|
5248
|
+
// never said it succeeded must not put pods into it.
|
|
5249
|
+
item.isError !== false ||
|
|
5250
|
+
!nonEmptyString(item.result)
|
|
5251
|
+
) {
|
|
5252
|
+
continue;
|
|
5253
|
+
}
|
|
5254
|
+
const value = record(parseJSON(item.result));
|
|
5255
|
+
const resource = kubernetesResource(value?.resource);
|
|
5256
|
+
if (!value || !resource || !Array.isArray(value.podNames)) continue;
|
|
5257
|
+
if (
|
|
5258
|
+
relevanceForResource(builder, {
|
|
5259
|
+
kind: resource.kind,
|
|
5260
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
5261
|
+
namespace: resource.metadata.namespace,
|
|
5262
|
+
name: resource.metadata.name,
|
|
5263
|
+
}) !== "target"
|
|
5264
|
+
) {
|
|
5265
|
+
continue;
|
|
5266
|
+
}
|
|
5267
|
+
for (const pod of value.podNames) {
|
|
5268
|
+
if (nonEmptyString(pod)) builder.establishedTargetPods.add(pod);
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
5273
|
+
|
|
4512
5274
|
export function projectInvestigationEvidence(
|
|
4513
5275
|
turns: readonly InvestigationEvidenceTurn[],
|
|
4514
5276
|
target: InvestigationEvidenceTarget,
|
|
4515
5277
|
): InvestigationEvidenceProjection {
|
|
4516
5278
|
const builder = new ProjectionBuilder(target);
|
|
5279
|
+
collectEstablishedTargetPods(builder, turns);
|
|
4517
5280
|
const evidenceRefSources: InvestigationEvidenceSource[] = [];
|
|
4518
5281
|
const citableSources: InvestigationEvidenceSource[] = [];
|
|
5282
|
+
const deferred: {
|
|
5283
|
+
adapt: (
|
|
5284
|
+
builder: ProjectionBuilder,
|
|
5285
|
+
source: InvestigationEvidenceSource,
|
|
5286
|
+
payload: unknown,
|
|
5287
|
+
) => void;
|
|
5288
|
+
source: InvestigationEvidenceSource;
|
|
5289
|
+
payload: unknown;
|
|
5290
|
+
}[] = [];
|
|
4519
5291
|
let order = 0;
|
|
4520
5292
|
for (const [turnIndex, turn] of turns.entries()) {
|
|
4521
5293
|
for (const [timelineIndex, item] of turn.timeline.entries()) {
|
|
@@ -4590,7 +5362,11 @@ export function projectInvestigationEvidence(
|
|
|
4590
5362
|
invalidPayload(builder, source);
|
|
4591
5363
|
continue;
|
|
4592
5364
|
}
|
|
4593
|
-
|
|
5365
|
+
if (MEMBERSHIP_DEPENDENT_ADAPTERS.has(item.tool)) {
|
|
5366
|
+
deferred.push({ adapt, source, payload });
|
|
5367
|
+
} else {
|
|
5368
|
+
adapt(builder, source, payload);
|
|
5369
|
+
}
|
|
4594
5370
|
if (item.isError !== false) {
|
|
4595
5371
|
builder.limit(
|
|
4596
5372
|
source,
|
|
@@ -4602,6 +5378,13 @@ export function projectInvestigationEvidence(
|
|
|
4602
5378
|
}
|
|
4603
5379
|
}
|
|
4604
5380
|
|
|
5381
|
+
// Membership is settled now: every read that could name one of the target's
|
|
5382
|
+
// pods has been adapted, so these see the same set whatever order the agent
|
|
5383
|
+
// worked in.
|
|
5384
|
+
for (const { adapt, source, payload } of deferred) {
|
|
5385
|
+
adapt(builder, source, payload);
|
|
5386
|
+
}
|
|
5387
|
+
|
|
4605
5388
|
const tierRank: Record<InvestigationEvidenceTier, number> = {
|
|
4606
5389
|
key: 0,
|
|
4607
5390
|
supporting: 1,
|
|
@@ -4770,7 +5553,12 @@ export function projectInvestigationEvidence(
|
|
|
4770
5553
|
|
|
4771
5554
|
return {
|
|
4772
5555
|
groups: builder.groups,
|
|
4773
|
-
|
|
5556
|
+
// Qualifications read in the order the investigation produced them, which
|
|
5557
|
+
// is the transcript's order and not the order the adapters happened to
|
|
5558
|
+
// run in: the membership-dependent ones are adapted last.
|
|
5559
|
+
limitations: [...builder.limitations].sort(
|
|
5560
|
+
(a, b) => a.firstOrder - b.firstOrder,
|
|
5561
|
+
),
|
|
4774
5562
|
sources: builder.sources,
|
|
4775
5563
|
evidenceRefSources,
|
|
4776
5564
|
citableSources,
|