@skyhook-io/radar-app 1.13.4 → 1.14.0
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 +8 -8
- package/src/App.tsx +60 -24
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +325 -16
- package/src/api/diagnose.ts +62 -15
- package/src/api/drain-plan.test.ts +86 -0
- package/src/api/quotas.test.ts +26 -0
- package/src/api/quotas.ts +20 -0
- package/src/api/workloadMetrics.test.ts +40 -0
- package/src/api/workloadMetrics.ts +68 -0
- package/src/components/CloudConnectFlow.tsx +179 -42
- package/src/components/CloudFunnelButton.tsx +107 -36
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/SyncProgressPanel.tsx +73 -0
- package/src/components/audit/AuditView.tsx +2 -0
- package/src/components/cloudConnectHandoff.test.ts +140 -0
- package/src/components/cloudConnectHandoff.ts +124 -0
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- 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/adapters/changes.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
- package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
- package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
- package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
- package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
- package/src/components/diagnose/investigationEvidence/index.ts +55 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
- package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
- package/src/components/diagnose/investigationEvidence/types.ts +512 -0
- 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/diagnose/target.test.ts +19 -0
- package/src/components/diagnose/target.ts +3 -2
- package/src/components/gitops/GitOpsView.tsx +7 -0
- package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
- package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
- package/src/components/resource/PrometheusCharts.tsx +4 -3
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
- package/src/components/resource/RightsizingStrip.test.ts +38 -0
- package/src/components/resource/RightsizingStrip.tsx +15 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
- package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
- package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
- package/src/components/resource/workloadMetricValues.test.ts +65 -0
- package/src/components/resource/workloadMetricValues.ts +30 -0
- 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 +44 -6
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
- package/src/components/settings/SettingsDialog.tsx +94 -14
- package/src/components/traffic/TrafficFlowList.tsx +229 -70
- package/src/components/traffic/TrafficGraph.tsx +6 -6
- package/src/components/traffic/TrafficView.tsx +10 -9
- package/src/components/traffic/trafficFilters.test.ts +28 -1
- package/src/components/traffic/trafficFilters.ts +31 -0
- package/src/components/workload/WorkloadView.tsx +19 -8
- package/src/context/ConnectionContext.tsx +48 -3
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +15 -2
- package/src/index.css +17 -0
- package/src/index.ts +1 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
- package/src/utils/topology-namespace.test.ts +64 -0
- package/src/utils/topology-namespace.ts +18 -0
- package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
- package/src/components/diagnose/investigationEvidence.ts +0 -4784
- package/src/components/gitops/GitOpsActions.tsx +0 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
- package/src/components/gitops/ManagedResourcesList.tsx +0 -1
- package/src/components/gitops/SyncCountdown.tsx +0 -1
- package/src/components/gitops/index.ts +0 -4
- package/src/components/helm/ManifestDiffViewer.tsx +0 -69
- package/src/components/logs/JsonLogLine.tsx +0 -1
- package/src/components/logs/LogCore.tsx +0 -2
- package/src/components/logs/useLogBuffer.ts +0 -2
- package/src/components/logs/useLogSearch.ts +0 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
- package/src/components/resources/drawer-components.tsx +0 -1
- package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
- package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/EventRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/JobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/VPARenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
- package/src/components/resources/renderers/argo-cells.tsx +0 -1
- package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
- package/src/components/resources/renderers/eso-cells.tsx +0 -1
- package/src/components/resources/renderers/flux-cells.tsx +0 -1
- package/src/components/resources/renderers/index.ts +0 -94
- package/src/components/resources/renderers/istio-cells.tsx +0 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
- package/src/components/resources/renderers/keda-cells.tsx +0 -1
- package/src/components/resources/renderers/knative-cells.tsx +0 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
- package/src/components/resources/renderers/traefik-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-shared.tsx +0 -1
- package/src/components/resources/renderers/velero-cells.tsx +0 -1
- package/src/components/resources/resource-utils-argo.ts +0 -2
- package/src/components/resources/resource-utils-certmanager.ts +0 -2
- package/src/components/resources/resource-utils-eso.ts +0 -2
- package/src/components/resources/resource-utils-flux.ts +0 -2
- package/src/components/resources/resource-utils-istio.ts +0 -2
- package/src/components/resources/resource-utils-karpenter.ts +0 -2
- package/src/components/resources/resource-utils-knative.ts +0 -2
- package/src/components/resources/resource-utils-kyverno.ts +0 -2
- package/src/components/resources/resource-utils-prometheus.ts +0 -2
- package/src/components/resources/resource-utils-traefik.ts +0 -1
- package/src/components/resources/resource-utils-trivy.ts +0 -2
- package/src/components/resources/resource-utils-velero.ts +0 -2
- package/src/components/shared/EditableYamlView.tsx +0 -24
- package/src/components/timeline/DiffViewer.tsx +0 -1
- package/src/components/traffic/index.ts +0 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
- package/src/components/ui/MetricsChart.tsx +0 -1
- package/src/components/ui/ResourceBar.tsx +0 -1
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { displayKind } from "@skyhook-io/k8s-ui";
|
|
2
|
+
import { type TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
|
|
3
|
+
import { metricsUnitForExpression } from "../../investigationMetrics";
|
|
4
|
+
import { stableHash } from "../identity";
|
|
5
|
+
import {
|
|
6
|
+
ProjectionBuilder,
|
|
7
|
+
evidenceTierForRelevance,
|
|
8
|
+
invalidPayload,
|
|
9
|
+
} from "../observations";
|
|
10
|
+
import {
|
|
11
|
+
nonEmptyString,
|
|
12
|
+
parseJSON,
|
|
13
|
+
record,
|
|
14
|
+
stringRecord,
|
|
15
|
+
timeSeries,
|
|
16
|
+
} from "../parse";
|
|
17
|
+
import {
|
|
18
|
+
type InvestigationAlertInstance,
|
|
19
|
+
type InvestigationAlertRule,
|
|
20
|
+
type InvestigationEvidenceRelevance,
|
|
21
|
+
type InvestigationEvidenceSource,
|
|
22
|
+
type InvestigationEvidenceTarget,
|
|
23
|
+
type InvestigationMetricsEvidence,
|
|
24
|
+
type InvestigationMetricsSelector,
|
|
25
|
+
} from "../types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pods a producer has already tied to the investigated workload in this
|
|
29
|
+
* projection: the streams diagnose or a workload-logs read resolved for it,
|
|
30
|
+
* its crash candidates, and its Pod startup blockers. A pod name matched
|
|
31
|
+
* against this set is producer-established membership, never inference from
|
|
32
|
+
* a name prefix, which a sibling such as `api-worker` would also satisfy.
|
|
33
|
+
*/
|
|
34
|
+
function producerEstablishedTargetPods(
|
|
35
|
+
builder: ProjectionBuilder,
|
|
36
|
+
): Set<string> {
|
|
37
|
+
const pods = new Set<string>(builder.establishedTargetPods);
|
|
38
|
+
const namespace = builder.target.namespace;
|
|
39
|
+
if (!namespace) return pods;
|
|
40
|
+
for (const group of builder.groups) {
|
|
41
|
+
for (const observation of group.observations) {
|
|
42
|
+
if (observation.relevance === "broader") continue;
|
|
43
|
+
const data = observation.data;
|
|
44
|
+
if (data.type === "logs" && data.namespace === namespace) {
|
|
45
|
+
pods.add(data.pod);
|
|
46
|
+
} else if (data.type === "crash" && data.namespace === namespace) {
|
|
47
|
+
for (const pod of data.crash.pods) pods.add(pod);
|
|
48
|
+
} else if (data.type === "startup" && data.blocker.kind === "Pod") {
|
|
49
|
+
// The blocker names the pods it holds whether the card merged them or
|
|
50
|
+
// not; the subject is only set when there is exactly one, so reading
|
|
51
|
+
// it alone lost the whole set the moment a second pod appeared.
|
|
52
|
+
for (const pod of data.pods ?? []) pods.add(pod);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return pods;
|
|
57
|
+
}
|
|
58
|
+
// kube-state-metrics label names for the owning workload.
|
|
59
|
+
const WORKLOAD_LABEL_BY_KIND: Readonly<Record<string, string>> = {
|
|
60
|
+
deployment: "deployment",
|
|
61
|
+
statefulset: "statefulset",
|
|
62
|
+
daemonset: "daemonset",
|
|
63
|
+
rollout: "rollout",
|
|
64
|
+
job: "job_name",
|
|
65
|
+
cronjob: "cronjob",
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Whether a Prometheus label set names the investigated resource: the target
|
|
69
|
+
* namespace plus a label identifying it directly, or a `pod` label naming a
|
|
70
|
+
* pod a producer already tied to it.
|
|
71
|
+
*/
|
|
72
|
+
function labelsNameTarget(
|
|
73
|
+
target: InvestigationEvidenceTarget,
|
|
74
|
+
labels: Record<string, string>,
|
|
75
|
+
targetPods: ReadonlySet<string>,
|
|
76
|
+
): boolean {
|
|
77
|
+
if (!target.namespace || labels.namespace !== target.namespace) return false;
|
|
78
|
+
const kind = target.kind.toLowerCase();
|
|
79
|
+
if (kind === "pod") return labels.pod === target.name;
|
|
80
|
+
const workloadLabel = WORKLOAD_LABEL_BY_KIND[kind];
|
|
81
|
+
if (workloadLabel && labels[workloadLabel] === target.name) return true;
|
|
82
|
+
if (
|
|
83
|
+
labels.workload === target.name &&
|
|
84
|
+
(labels.workload_type === undefined ||
|
|
85
|
+
labels.workload_type.toLowerCase() === kind)
|
|
86
|
+
) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return labels.pod !== undefined && targetPods.has(labels.pod);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The label set places the instance in another namespace outright. Only a
|
|
93
|
+
* namespaced target can be excluded this way; for a cluster-scoped target
|
|
94
|
+
* every namespace label is "different" and proves nothing.
|
|
95
|
+
*/
|
|
96
|
+
function labelsPlaceElsewhere(
|
|
97
|
+
target: InvestigationEvidenceTarget,
|
|
98
|
+
labels: Record<string, string>,
|
|
99
|
+
): boolean {
|
|
100
|
+
return (
|
|
101
|
+
Boolean(target.namespace) &&
|
|
102
|
+
nonEmptyString(labels.namespace) &&
|
|
103
|
+
labels.namespace !== target.namespace
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
function alertRule(value: unknown):
|
|
107
|
+
| {
|
|
108
|
+
rule: InvestigationAlertRule;
|
|
109
|
+
instances: Omit<InvestigationAlertInstance, "namesTarget">[];
|
|
110
|
+
annotations: Record<string, string>;
|
|
111
|
+
}
|
|
112
|
+
| undefined {
|
|
113
|
+
const candidate = record(value);
|
|
114
|
+
if (
|
|
115
|
+
!candidate ||
|
|
116
|
+
!nonEmptyString(candidate.group) ||
|
|
117
|
+
!nonEmptyString(candidate.name) ||
|
|
118
|
+
!nonEmptyString(candidate.type) ||
|
|
119
|
+
typeof candidate.query !== "string"
|
|
120
|
+
) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
for (const field of ["state", "health"] as const) {
|
|
124
|
+
if (candidate[field] !== undefined && typeof candidate[field] !== "string")
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const labels =
|
|
128
|
+
candidate.labels === undefined ? {} : stringRecord(candidate.labels);
|
|
129
|
+
const annotations =
|
|
130
|
+
candidate.annotations === undefined
|
|
131
|
+
? {}
|
|
132
|
+
: stringRecord(candidate.annotations);
|
|
133
|
+
if (!labels || !annotations) return undefined;
|
|
134
|
+
const alertsRaw = candidate.alerts === undefined ? [] : candidate.alerts;
|
|
135
|
+
if (!Array.isArray(alertsRaw)) return undefined;
|
|
136
|
+
const instances: Omit<InvestigationAlertInstance, "namesTarget">[] = [];
|
|
137
|
+
for (const raw of alertsRaw) {
|
|
138
|
+
const instance = record(raw);
|
|
139
|
+
const instanceLabels =
|
|
140
|
+
instance?.labels === undefined ? {} : stringRecord(instance.labels);
|
|
141
|
+
if (
|
|
142
|
+
!instance ||
|
|
143
|
+
!nonEmptyString(instance.state) ||
|
|
144
|
+
!instanceLabels ||
|
|
145
|
+
(instance.activeAt !== undefined &&
|
|
146
|
+
typeof instance.activeAt !== "string") ||
|
|
147
|
+
(instance.value !== undefined && typeof instance.value !== "string")
|
|
148
|
+
) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
instances.push({
|
|
152
|
+
state: instance.state,
|
|
153
|
+
activeAt: instance.activeAt as string | undefined,
|
|
154
|
+
value: instance.value as string | undefined,
|
|
155
|
+
labels: instanceLabels,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
rule: {
|
|
160
|
+
group: candidate.group,
|
|
161
|
+
name: candidate.name,
|
|
162
|
+
type: candidate.type,
|
|
163
|
+
state: candidate.state as string | undefined,
|
|
164
|
+
health: candidate.health as string | undefined,
|
|
165
|
+
query: candidate.query,
|
|
166
|
+
labels,
|
|
167
|
+
},
|
|
168
|
+
instances,
|
|
169
|
+
annotations,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function targetIdentity(target: InvestigationEvidenceTarget): string {
|
|
173
|
+
return `${displayKind(target.kind)} ${target.namespace ? `${target.namespace}/` : ""}${target.name}`;
|
|
174
|
+
}
|
|
175
|
+
export function adaptPrometheusRules(
|
|
176
|
+
builder: ProjectionBuilder,
|
|
177
|
+
source: InvestigationEvidenceSource,
|
|
178
|
+
payload: unknown,
|
|
179
|
+
): void {
|
|
180
|
+
const value = record(payload);
|
|
181
|
+
if (
|
|
182
|
+
!value ||
|
|
183
|
+
!Array.isArray(value.rules) ||
|
|
184
|
+
typeof value.count !== "number" ||
|
|
185
|
+
value.count !== value.rules.length ||
|
|
186
|
+
(value.truncated !== undefined && typeof value.truncated !== "boolean") ||
|
|
187
|
+
(value.note !== undefined && typeof value.note !== "string")
|
|
188
|
+
) {
|
|
189
|
+
invalidPayload(builder, source);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const rules = value.rules
|
|
193
|
+
.map(alertRule)
|
|
194
|
+
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
|
195
|
+
if (rules.length !== value.rules.length) {
|
|
196
|
+
invalidPayload(builder, source);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
200
|
+
const stateFilter = nonEmptyString(args?.state)
|
|
201
|
+
? args.state.toLowerCase()
|
|
202
|
+
: undefined;
|
|
203
|
+
const typeFilter = nonEmptyString(args?.type)
|
|
204
|
+
? args.type.toLowerCase()
|
|
205
|
+
: undefined;
|
|
206
|
+
const truncated = value.truncated === true;
|
|
207
|
+
if (truncated) {
|
|
208
|
+
builder.limit(
|
|
209
|
+
source,
|
|
210
|
+
"Alert rules",
|
|
211
|
+
`The rule list was capped, so additional matching rules may exist${nonEmptyString(value.note) ? `; ${value.note}` : ""}.`,
|
|
212
|
+
"truncated",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const identity = targetIdentity(builder.target);
|
|
216
|
+
const targetPods = producerEstablishedTargetPods(builder);
|
|
217
|
+
// Rule names are not unique within a group, and group names are unique
|
|
218
|
+
// only per rule file, which the producer does not return. The expression
|
|
219
|
+
// and static labels distinguish rules; identical rows within one response
|
|
220
|
+
// cannot be told apart at all, so they stay scoped to this read rather
|
|
221
|
+
// than inherit each other's history in whatever order they arrive.
|
|
222
|
+
const ruleDefinition = (rule: InvestigationAlertRule) =>
|
|
223
|
+
`alerts:${rule.group}:${rule.name}:${stableHash(
|
|
224
|
+
JSON.stringify([rule.query, Object.entries(rule.labels).sort()]),
|
|
225
|
+
)}`;
|
|
226
|
+
const definitionCounts = new Map<string, number>();
|
|
227
|
+
for (const { rule } of rules) {
|
|
228
|
+
const definition = ruleDefinition(rule);
|
|
229
|
+
definitionCounts.set(
|
|
230
|
+
definition,
|
|
231
|
+
(definitionCounts.get(definition) ?? 0) + 1,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const seenRuleIdentities = new Map<string, number>();
|
|
235
|
+
let namesTarget = false;
|
|
236
|
+
let healthGap = false;
|
|
237
|
+
let alertingCount = 0;
|
|
238
|
+
let everyInstanceElsewhere = true;
|
|
239
|
+
for (const { rule, instances: rawInstances, annotations } of rules) {
|
|
240
|
+
// Recording rules carry no state and never describe a resource.
|
|
241
|
+
if (rule.type.toLowerCase() !== "alerting") continue;
|
|
242
|
+
alertingCount += 1;
|
|
243
|
+
const instances = rawInstances.map((instance) => ({
|
|
244
|
+
...instance,
|
|
245
|
+
namesTarget: labelsNameTarget(
|
|
246
|
+
builder.target,
|
|
247
|
+
instance.labels,
|
|
248
|
+
targetPods,
|
|
249
|
+
),
|
|
250
|
+
}));
|
|
251
|
+
if (
|
|
252
|
+
instances.length === 0 ||
|
|
253
|
+
instances.some(
|
|
254
|
+
(instance) => !labelsPlaceElsewhere(builder.target, instance.labels),
|
|
255
|
+
)
|
|
256
|
+
) {
|
|
257
|
+
everyInstanceElsewhere = false;
|
|
258
|
+
}
|
|
259
|
+
const definition = ruleDefinition(rule);
|
|
260
|
+
const ambiguous = (definitionCounts.get(definition) ?? 0) > 1;
|
|
261
|
+
const ordinal = seenRuleIdentities.get(definition) ?? 0;
|
|
262
|
+
seenRuleIdentities.set(definition, ordinal + 1);
|
|
263
|
+
const ruleIdentity = ambiguous
|
|
264
|
+
? `${definition}:${source.id}:${ordinal}`
|
|
265
|
+
: definition;
|
|
266
|
+
const previousRelevance = ambiguous
|
|
267
|
+
? undefined
|
|
268
|
+
: builder.latestRelevance("alerts", ruleIdentity);
|
|
269
|
+
const relevance: InvestigationEvidenceRelevance = instances.some(
|
|
270
|
+
(instance) => instance.namesTarget,
|
|
271
|
+
)
|
|
272
|
+
? "target"
|
|
273
|
+
: labelsNameTarget(builder.target, rule.labels, targetPods)
|
|
274
|
+
? "producer-related"
|
|
275
|
+
: // The same rule read again with no instances left has resolved for
|
|
276
|
+
// the target it named before; keep that provenance so the
|
|
277
|
+
// resolution stays visible instead of the stale firing card.
|
|
278
|
+
instances.length === 0 &&
|
|
279
|
+
previousRelevance !== undefined &&
|
|
280
|
+
previousRelevance !== "broader"
|
|
281
|
+
? previousRelevance
|
|
282
|
+
: "broader";
|
|
283
|
+
if (relevance !== "broader") namesTarget = true;
|
|
284
|
+
const state = (rule.state ?? "").toLowerCase();
|
|
285
|
+
const active = state === "firing" || state === "pending";
|
|
286
|
+
const severity = (rule.labels.severity ?? "").toLowerCase();
|
|
287
|
+
const targetInstances = instances.filter(
|
|
288
|
+
(instance) => instance.namesTarget,
|
|
289
|
+
).length;
|
|
290
|
+
builder.observe(ruleIdentity, "alerts", source, {
|
|
291
|
+
tier: evidenceTierForRelevance(
|
|
292
|
+
active ? "supporting" : "context",
|
|
293
|
+
relevance,
|
|
294
|
+
),
|
|
295
|
+
relevance,
|
|
296
|
+
tone:
|
|
297
|
+
state === "firing"
|
|
298
|
+
? severity === "critical"
|
|
299
|
+
? "error"
|
|
300
|
+
: "alert"
|
|
301
|
+
: state === "pending"
|
|
302
|
+
? "warning"
|
|
303
|
+
: "neutral",
|
|
304
|
+
title: `${rule.name}${state ? ` ${state}` : ""}`,
|
|
305
|
+
summary:
|
|
306
|
+
instances.length === 0
|
|
307
|
+
? `No active instances · ${rule.group}`
|
|
308
|
+
: `${instances.length} active instance${instances.length === 1 ? "" : "s"}${
|
|
309
|
+
targetInstances > 0
|
|
310
|
+
? `, ${targetInstances} naming ${identity}`
|
|
311
|
+
: ""
|
|
312
|
+
} · ${rule.group}`,
|
|
313
|
+
data: { type: "alerts", rule, instances, annotations },
|
|
314
|
+
});
|
|
315
|
+
const health = (rule.health ?? "").toLowerCase();
|
|
316
|
+
if (health === "err") {
|
|
317
|
+
healthGap = true;
|
|
318
|
+
builder.limit(
|
|
319
|
+
source,
|
|
320
|
+
`Alert rule ${rule.name}`,
|
|
321
|
+
"Prometheus reported an evaluation error for this rule, so its state may be stale or missing.",
|
|
322
|
+
"error",
|
|
323
|
+
);
|
|
324
|
+
} else if (health !== "ok") {
|
|
325
|
+
// Anything but a reported "ok" evaluation, including an unrecognized
|
|
326
|
+
// value, is an unevaluated rule for the purpose of a negative.
|
|
327
|
+
healthGap = true;
|
|
328
|
+
builder.limit(
|
|
329
|
+
source,
|
|
330
|
+
`Alert rule ${rule.name}`,
|
|
331
|
+
"Prometheus has not reported a successful evaluation for this rule, so its state is unknown.",
|
|
332
|
+
"unknown",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// A negative is only as strong as the query: an active-state filter over
|
|
337
|
+
// every alerting rule (a name or group filter never looked at the rest), a
|
|
338
|
+
// complete list, every rule evaluated, and every returned instance placed
|
|
339
|
+
// in another namespace outright. An instance this projection merely fails
|
|
340
|
+
// to recognize is not evidence of absence.
|
|
341
|
+
if (
|
|
342
|
+
source.confirmedSuccess &&
|
|
343
|
+
!truncated &&
|
|
344
|
+
!namesTarget &&
|
|
345
|
+
!healthGap &&
|
|
346
|
+
typeFilter !== "record" &&
|
|
347
|
+
!nonEmptyString(args?.name) &&
|
|
348
|
+
!nonEmptyString(args?.group) &&
|
|
349
|
+
(stateFilter === "firing" || stateFilter === "pending") &&
|
|
350
|
+
(alertingCount === 0 || everyInstanceElsewhere)
|
|
351
|
+
) {
|
|
352
|
+
const filter = `state=${stateFilter}`;
|
|
353
|
+
builder.observe(`alerts:receipt:${filter}`, "receipt", source, {
|
|
354
|
+
tier: "checked",
|
|
355
|
+
relevance: "target",
|
|
356
|
+
tone: "neutral",
|
|
357
|
+
title: `No ${stateFilter} alerts matched this ${displayKind(builder.target.kind)}`,
|
|
358
|
+
summary: identity,
|
|
359
|
+
data: {
|
|
360
|
+
type: "receipt",
|
|
361
|
+
checked: "alerts",
|
|
362
|
+
scope: identity,
|
|
363
|
+
message:
|
|
364
|
+
alertingCount === 0
|
|
365
|
+
? `0 alerting rules returned (filter: ${filter}).`
|
|
366
|
+
: `${alertingCount} alerting rule${alertingCount === 1 ? "" : "s"} returned (filter: ${filter}), every instance in another namespace; none names ${identity}.`,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function metricsSelector(
|
|
372
|
+
value: unknown,
|
|
373
|
+
): InvestigationMetricsSelector | undefined {
|
|
374
|
+
const item = record(value);
|
|
375
|
+
if (!item || typeof item.metric !== "string" || !Array.isArray(item.matchers))
|
|
376
|
+
return undefined;
|
|
377
|
+
const matchers: InvestigationMetricsSelector["matchers"] = [];
|
|
378
|
+
for (const raw of item.matchers) {
|
|
379
|
+
const matcher = record(raw);
|
|
380
|
+
if (
|
|
381
|
+
!matcher ||
|
|
382
|
+
typeof matcher.label !== "string" ||
|
|
383
|
+
typeof matcher.op !== "string" ||
|
|
384
|
+
typeof matcher.value !== "string"
|
|
385
|
+
) {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
matchers.push({
|
|
389
|
+
label: matcher.label,
|
|
390
|
+
op: matcher.op,
|
|
391
|
+
value: matcher.value,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return { metric: item.metric, matchers };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* How one selector relates to the investigated resource. "target": it names
|
|
398
|
+
* the target itself (an identity label, or pods a producer established as
|
|
399
|
+
* the target's own). "related": it mentions the target's namespace and
|
|
400
|
+
* something in it the target cannot be proved to own (a pod named by prefix,
|
|
401
|
+
* a sibling workload, an owner series). "namespace": only the namespace.
|
|
402
|
+
* "foreign": another namespace or none.
|
|
403
|
+
*/
|
|
404
|
+
type SelectorVerdict = "target" | "related" | "namespace" | "foreign";
|
|
405
|
+
const VERDICT_RANK: Record<SelectorVerdict, number> = {
|
|
406
|
+
foreign: 3,
|
|
407
|
+
target: 2,
|
|
408
|
+
related: 1,
|
|
409
|
+
namespace: 0,
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* A regex matcher names the target only in the exact forms the plan admits,
|
|
413
|
+
* with the name's regex metacharacters (a dot in a Kubernetes name) escaped
|
|
414
|
+
* or absent. "api-?.*" also selects "apiworker-…" and an unescaped "api.v2"
|
|
415
|
+
* also selects "api-v2", so neither may claim the target.
|
|
416
|
+
*/
|
|
417
|
+
function regexNamesExactly(
|
|
418
|
+
value: string,
|
|
419
|
+
name: string,
|
|
420
|
+
suffix: string,
|
|
421
|
+
): boolean {
|
|
422
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
423
|
+
return value === `${escaped}${suffix}`;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The pod names an exact-set regex `^(a|b)$` (or the unanchored `a|b`, which
|
|
427
|
+
* Prometheus anchors) lists. A dot must be escaped to count: `^(api.v2-0)$`
|
|
428
|
+
* also selects `api-v2-0`, so it names more than the pod it appears to and
|
|
429
|
+
* cannot prove membership. Undefined for any other shape.
|
|
430
|
+
*/
|
|
431
|
+
function exactPodSet(value: string): string[] | undefined {
|
|
432
|
+
const body =
|
|
433
|
+
value.startsWith("^(") && value.endsWith(")$") ? value.slice(2, -2) : value;
|
|
434
|
+
if (body === "" || /[^A-Za-z0-9\-|\\.]/.test(body)) return undefined;
|
|
435
|
+
// Every dot has to arrive escaped; an unescaped one is a wildcard.
|
|
436
|
+
if (/(^|[^\\])\./.test(body.replace(/\\\\/g, ""))) return undefined;
|
|
437
|
+
const names = body.split("|").map((name) => name.replace(/\\\./g, "."));
|
|
438
|
+
return names.every((name) => /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(name))
|
|
439
|
+
? names
|
|
440
|
+
: undefined;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Whether a workload identity label names the target: the label
|
|
444
|
+
* kube-state-metrics uses for the kind (`deployment`, `job_name`, …) or a
|
|
445
|
+
* generic `workload` label, with the exact name.
|
|
446
|
+
*/
|
|
447
|
+
function identityLabelNamesTarget(
|
|
448
|
+
target: InvestigationEvidenceTarget,
|
|
449
|
+
label: string,
|
|
450
|
+
value: string,
|
|
451
|
+
exact: boolean,
|
|
452
|
+
): boolean {
|
|
453
|
+
const kind = target.kind.toLowerCase();
|
|
454
|
+
const workloadLabel = WORKLOAD_LABEL_BY_KIND[kind];
|
|
455
|
+
// `deployment=api` states the kind in the label itself; `workload=api` does
|
|
456
|
+
// not, so a `workload_type` naming another kind vetoes it in
|
|
457
|
+
// classifySelector, which is the only place that sees the whole selector.
|
|
458
|
+
if (label !== workloadLabel && label !== "workload") return false;
|
|
459
|
+
return exact
|
|
460
|
+
? value === target.name
|
|
461
|
+
: regexNamesExactly(value, target.name, "");
|
|
462
|
+
}
|
|
463
|
+
function classifyMatcher(
|
|
464
|
+
target: InvestigationEvidenceTarget,
|
|
465
|
+
matcher: InvestigationMetricsSelector["matchers"][number],
|
|
466
|
+
establishedPods: ReadonlySet<string>,
|
|
467
|
+
): SelectorVerdict {
|
|
468
|
+
const { label, op, value } = matcher;
|
|
469
|
+
const kind = target.kind.toLowerCase();
|
|
470
|
+
if (label === "pod") {
|
|
471
|
+
if (kind === "pod") {
|
|
472
|
+
if (op === "=") return value === target.name ? "target" : "related";
|
|
473
|
+
if (op === "=~") {
|
|
474
|
+
if (regexNamesExactly(value, target.name, "")) return "target";
|
|
475
|
+
// `^(name)$` is the form the diagnose prompt asks the agent to write,
|
|
476
|
+
// so a Pod target has to recognise its own name in it. The set must
|
|
477
|
+
// name nothing else: a query covering the target and a neighbour is
|
|
478
|
+
// not evidence about the target alone.
|
|
479
|
+
const names = exactPodSet(value);
|
|
480
|
+
return names && names.every((name) => name === target.name)
|
|
481
|
+
? "target"
|
|
482
|
+
: "related";
|
|
483
|
+
}
|
|
484
|
+
return "related";
|
|
485
|
+
}
|
|
486
|
+
if (op === "=") return establishedPods.has(value) ? "target" : "related";
|
|
487
|
+
if (op === "=~") {
|
|
488
|
+
const names = exactPodSet(value);
|
|
489
|
+
if (names && names.every((name) => establishedPods.has(name))) {
|
|
490
|
+
return "target";
|
|
491
|
+
}
|
|
492
|
+
return "related";
|
|
493
|
+
}
|
|
494
|
+
return "related";
|
|
495
|
+
}
|
|
496
|
+
if (label === "namespace" || label === "container") return "namespace";
|
|
497
|
+
if (op === "=" && identityLabelNamesTarget(target, label, value, true)) {
|
|
498
|
+
return "target";
|
|
499
|
+
}
|
|
500
|
+
if (op === "=~" && identityLabelNamesTarget(target, label, value, false)) {
|
|
501
|
+
return "target";
|
|
502
|
+
}
|
|
503
|
+
// Owner series (kube_pod_owner, kube_replicaset_owner) and every other
|
|
504
|
+
// label in the namespace: about the namespace's things, not proved to be
|
|
505
|
+
// the target's.
|
|
506
|
+
return "related";
|
|
507
|
+
}
|
|
508
|
+
function classifySelector(
|
|
509
|
+
target: InvestigationEvidenceTarget,
|
|
510
|
+
selector: InvestigationMetricsSelector,
|
|
511
|
+
establishedPods: ReadonlySet<string>,
|
|
512
|
+
): SelectorVerdict {
|
|
513
|
+
// `workload` is generic, so a `workload_type` naming another kind means
|
|
514
|
+
// the series is about a different workload that happens to share a name.
|
|
515
|
+
// A regex counts when it names an exact set, the same way the namespace
|
|
516
|
+
// matcher below does: `workload_type=~"statefulset"` excludes a Deployment
|
|
517
|
+
// as plainly as `=` does, and reading only `=` let a sibling's chart take
|
|
518
|
+
// the target's identity. A set that includes the target kind is no conflict,
|
|
519
|
+
// and an inexact regex says nothing either way.
|
|
520
|
+
const workloadType = selector.matchers.find(
|
|
521
|
+
(matcher) =>
|
|
522
|
+
matcher.label === "workload_type" &&
|
|
523
|
+
(matcher.op === "=" || matcher.op === "=~"),
|
|
524
|
+
);
|
|
525
|
+
const workloadTypeNames =
|
|
526
|
+
workloadType === undefined
|
|
527
|
+
? undefined
|
|
528
|
+
: workloadType.op === "="
|
|
529
|
+
? [workloadType.value]
|
|
530
|
+
: exactPodSet(workloadType.value);
|
|
531
|
+
const workloadTypeConflicts =
|
|
532
|
+
workloadTypeNames !== undefined &&
|
|
533
|
+
workloadTypeNames.length > 0 &&
|
|
534
|
+
!workloadTypeNames.some(
|
|
535
|
+
(name) => name.toLowerCase() === target.kind.toLowerCase(),
|
|
536
|
+
);
|
|
537
|
+
// Prometheus anchors regex matchers, so namespace=~"shop" is exact too.
|
|
538
|
+
const inNamespace = selector.matchers.some(
|
|
539
|
+
(matcher) =>
|
|
540
|
+
matcher.label === "namespace" &&
|
|
541
|
+
((matcher.op === "=" && matcher.value === target.namespace) ||
|
|
542
|
+
(matcher.op === "=~" &&
|
|
543
|
+
regexNamesExactly(matcher.value, target.namespace ?? "", ""))),
|
|
544
|
+
);
|
|
545
|
+
if (!inNamespace) return "foreign";
|
|
546
|
+
let verdict: SelectorVerdict = "namespace";
|
|
547
|
+
for (const matcher of selector.matchers) {
|
|
548
|
+
if (workloadTypeConflicts && matcher.label === "workload") continue;
|
|
549
|
+
const candidate = classifyMatcher(target, matcher, establishedPods);
|
|
550
|
+
if (candidate === "foreign") return "foreign";
|
|
551
|
+
if (VERDICT_RANK[candidate] > VERDICT_RANK[verdict]) verdict = candidate;
|
|
552
|
+
}
|
|
553
|
+
return verdict;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A metrics result is about the target only when every selector names it:
|
|
557
|
+
* the target namespace plus pods a producer established as the target's own
|
|
558
|
+
* or an identity label with its exact name. A selector that only mentions
|
|
559
|
+
* something in the namespace the target cannot be proved to own (a pod
|
|
560
|
+
* prefix, a sibling, an owner join) makes the result producer-related: kept,
|
|
561
|
+
* not promoted, no change markers. A namespace-only query, a bare or foreign
|
|
562
|
+
* selector, or an inventory the producer could not extract is broader.
|
|
563
|
+
*/
|
|
564
|
+
export function metricsScope(
|
|
565
|
+
target: InvestigationEvidenceTarget,
|
|
566
|
+
selectors: readonly InvestigationMetricsSelector[],
|
|
567
|
+
selectorsUnknown: boolean,
|
|
568
|
+
establishedPods: ReadonlySet<string> = new Set(),
|
|
569
|
+
): InvestigationEvidenceRelevance {
|
|
570
|
+
if (selectorsUnknown || selectors.length === 0 || !target.namespace) {
|
|
571
|
+
return "broader";
|
|
572
|
+
}
|
|
573
|
+
let allTarget = true;
|
|
574
|
+
let anyRelated = false;
|
|
575
|
+
for (const selector of selectors) {
|
|
576
|
+
const verdict = classifySelector(target, selector, establishedPods);
|
|
577
|
+
if (verdict === "foreign") return "broader";
|
|
578
|
+
if (verdict !== "target") allTarget = false;
|
|
579
|
+
if (verdict === "related") anyRelated = true;
|
|
580
|
+
}
|
|
581
|
+
if (allTarget) return "target";
|
|
582
|
+
return anyRelated ? "producer-related" : "broader";
|
|
583
|
+
}
|
|
584
|
+
export function metricsWindowLabel(data: {
|
|
585
|
+
mode: "range" | "instant";
|
|
586
|
+
start?: string;
|
|
587
|
+
end?: string;
|
|
588
|
+
step?: string;
|
|
589
|
+
}): string | undefined {
|
|
590
|
+
if (data.mode !== "range" || !data.start || !data.end) return undefined;
|
|
591
|
+
const startMs = Date.parse(data.start);
|
|
592
|
+
const endMs = Date.parse(data.end);
|
|
593
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
|
|
594
|
+
return undefined;
|
|
595
|
+
const minutes = Math.round((endMs - startMs) / 60_000);
|
|
596
|
+
const window =
|
|
597
|
+
minutes >= 60 * 24 * 2
|
|
598
|
+
? `${Math.round(minutes / (60 * 24))}d`
|
|
599
|
+
: minutes >= 120
|
|
600
|
+
? `${Math.round(minutes / 60)}h`
|
|
601
|
+
: `${minutes}m`;
|
|
602
|
+
return data.step
|
|
603
|
+
? `${window} window · ${data.step} step`
|
|
604
|
+
: `${window} window`;
|
|
605
|
+
}
|
|
606
|
+
// Radar's reading of a metric name, so an agent-written query gets a title a
|
|
607
|
+
// reader can scan instead of the raw series name. Deliberately small and exact:
|
|
608
|
+
// an unrecognised metric keeps its own name rather than being handed a meaning
|
|
609
|
+
// Radar cannot derive from it.
|
|
610
|
+
const METRIC_FAMILY_LABELS: Record<string, string> = {
|
|
611
|
+
container_cpu_usage_seconds_total: "CPU usage",
|
|
612
|
+
container_cpu_cfs_throttled_seconds_total: "CPU throttling",
|
|
613
|
+
container_memory_working_set_bytes: "Memory working set",
|
|
614
|
+
container_memory_usage_bytes: "Memory usage",
|
|
615
|
+
container_memory_rss: "Memory RSS",
|
|
616
|
+
kube_pod_container_status_restarts_total: "Restarts",
|
|
617
|
+
container_network_receive_bytes_total: "Network received",
|
|
618
|
+
container_network_transmit_bytes_total: "Network transmitted",
|
|
619
|
+
container_fs_usage_bytes: "Filesystem usage",
|
|
620
|
+
up: "Scrape target up",
|
|
621
|
+
};
|
|
622
|
+
export function adaptQueryPrometheus(
|
|
623
|
+
builder: ProjectionBuilder,
|
|
624
|
+
source: InvestigationEvidenceSource,
|
|
625
|
+
payload: unknown,
|
|
626
|
+
): void {
|
|
627
|
+
const value = record(payload);
|
|
628
|
+
const mode = value?.type;
|
|
629
|
+
if (
|
|
630
|
+
!value ||
|
|
631
|
+
!nonEmptyString(value.query) ||
|
|
632
|
+
(mode !== "range" && mode !== "instant") ||
|
|
633
|
+
!Array.isArray(value.series) ||
|
|
634
|
+
!Array.isArray(value.selectors) ||
|
|
635
|
+
(value.selectorsUnknown !== undefined &&
|
|
636
|
+
typeof value.selectorsUnknown !== "boolean") ||
|
|
637
|
+
(value.truncated !== undefined && typeof value.truncated !== "boolean")
|
|
638
|
+
) {
|
|
639
|
+
invalidPayload(builder, source, "Prometheus query");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const series = value.series
|
|
643
|
+
.map(timeSeries)
|
|
644
|
+
.filter((item): item is TimeSeries => Boolean(item));
|
|
645
|
+
const selectors = value.selectors
|
|
646
|
+
.map(metricsSelector)
|
|
647
|
+
.filter((item): item is InvestigationMetricsSelector => Boolean(item));
|
|
648
|
+
if (
|
|
649
|
+
series.length !== value.series.length ||
|
|
650
|
+
selectors.length !== value.selectors.length
|
|
651
|
+
) {
|
|
652
|
+
invalidPayload(builder, source, "Prometheus query");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const selectorsUnknown = value.selectorsUnknown === true;
|
|
656
|
+
const relevance = metricsScope(
|
|
657
|
+
builder.target,
|
|
658
|
+
selectors,
|
|
659
|
+
selectorsUnknown,
|
|
660
|
+
producerEstablishedTargetPods(builder),
|
|
661
|
+
);
|
|
662
|
+
const start = nonEmptyString(value.start) ? value.start : undefined;
|
|
663
|
+
const end = nonEmptyString(value.end) ? value.end : undefined;
|
|
664
|
+
const step = nonEmptyString(value.step) ? value.step : undefined;
|
|
665
|
+
const note = nonEmptyString(value.note) ? value.note : undefined;
|
|
666
|
+
if (value.truncated === true) {
|
|
667
|
+
const summary = record(value.summary);
|
|
668
|
+
const cardinality = record(summary?.labelCardinality);
|
|
669
|
+
const widest = cardinality
|
|
670
|
+
? Object.entries(cardinality).find(
|
|
671
|
+
([, count]) => typeof count === "number",
|
|
672
|
+
)
|
|
673
|
+
: undefined;
|
|
674
|
+
const parts = [
|
|
675
|
+
typeof summary?.seriesCount === "number"
|
|
676
|
+
? `${summary.seriesCount} series`
|
|
677
|
+
: undefined,
|
|
678
|
+
typeof summary?.totalDataPoints === "number"
|
|
679
|
+
? `${summary.totalDataPoints} samples`
|
|
680
|
+
: undefined,
|
|
681
|
+
widest ? `${widest[1]} distinct ${widest[0]} values` : undefined,
|
|
682
|
+
].filter((part): part is string => Boolean(part));
|
|
683
|
+
builder.limit(
|
|
684
|
+
source,
|
|
685
|
+
"Prometheus query",
|
|
686
|
+
`The result of \`${value.query}\` was too large to keep${parts.length ? ` (${parts.join(", ")})` : ""}, so Radar cannot chart it.${note ? ` ${note}` : ""}`,
|
|
687
|
+
"truncated",
|
|
688
|
+
);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const windowLabel = metricsWindowLabel({ mode, start, end, step });
|
|
692
|
+
// One metric names the card; the title is Radar's reading of the query,
|
|
693
|
+
// never the agent's, so a wrong claim cannot become the card's identity.
|
|
694
|
+
// A bare matcher block such as `{namespace="shop"}` names no metric.
|
|
695
|
+
const metricNames = [
|
|
696
|
+
...new Set(
|
|
697
|
+
selectors.flatMap((selector) =>
|
|
698
|
+
selector.metric.trim() ? [selector.metric.trim()] : [],
|
|
699
|
+
),
|
|
700
|
+
),
|
|
701
|
+
];
|
|
702
|
+
// A recognised family names the card; the scope is added only when Radar
|
|
703
|
+
// already decided the query is about the target, so the title never widens
|
|
704
|
+
// or narrows what the relevance check concluded.
|
|
705
|
+
const family =
|
|
706
|
+
metricNames.length === 1 ? METRIC_FAMILY_LABELS[metricNames[0]] : undefined;
|
|
707
|
+
const scope =
|
|
708
|
+
relevance === "target"
|
|
709
|
+
? `${displayKind(builder.target.kind)} ${builder.target.namespace ? `${builder.target.namespace}/` : ""}${builder.target.name}`
|
|
710
|
+
: undefined;
|
|
711
|
+
const title = family
|
|
712
|
+
? scope
|
|
713
|
+
? `${family} · ${scope}`
|
|
714
|
+
: family
|
|
715
|
+
: metricNames.length === 1
|
|
716
|
+
? metricNames[0]
|
|
717
|
+
: mode === "range"
|
|
718
|
+
? "Prometheus metrics"
|
|
719
|
+
: "Prometheus values";
|
|
720
|
+
const data: InvestigationMetricsEvidence = {
|
|
721
|
+
type: "metrics",
|
|
722
|
+
origin: "query",
|
|
723
|
+
query: value.query,
|
|
724
|
+
mode,
|
|
725
|
+
start,
|
|
726
|
+
end,
|
|
727
|
+
step,
|
|
728
|
+
unit: metricsUnitForExpression(value.query, selectors),
|
|
729
|
+
series,
|
|
730
|
+
truncated: false,
|
|
731
|
+
note,
|
|
732
|
+
selectors,
|
|
733
|
+
selectorsUnknown,
|
|
734
|
+
subject:
|
|
735
|
+
relevance === "target"
|
|
736
|
+
? {
|
|
737
|
+
kind: builder.target.kind,
|
|
738
|
+
...(builder.target.group ? { group: builder.target.group } : {}),
|
|
739
|
+
namespace: builder.target.namespace,
|
|
740
|
+
name: builder.target.name,
|
|
741
|
+
}
|
|
742
|
+
: undefined,
|
|
743
|
+
};
|
|
744
|
+
builder.observe(
|
|
745
|
+
`metrics:${mode}:${value.query}:${start ?? ""}:${end ?? ""}:${step ?? ""}`,
|
|
746
|
+
"metrics",
|
|
747
|
+
source,
|
|
748
|
+
{
|
|
749
|
+
tier: evidenceTierForRelevance("supporting", relevance),
|
|
750
|
+
relevance,
|
|
751
|
+
tone: "neutral",
|
|
752
|
+
title,
|
|
753
|
+
summary: [
|
|
754
|
+
// When a family named the card, the metric name moves here so it stays
|
|
755
|
+
// visible; otherwise it is already the title.
|
|
756
|
+
family
|
|
757
|
+
? metricNames[0]
|
|
758
|
+
: metricNames.length === 1
|
|
759
|
+
? "Prometheus"
|
|
760
|
+
: undefined,
|
|
761
|
+
series.length === 0 ? "No series matched" : `${series.length} series`,
|
|
762
|
+
windowLabel,
|
|
763
|
+
]
|
|
764
|
+
.filter((part): part is string => Boolean(part))
|
|
765
|
+
.join(" · "),
|
|
766
|
+
data,
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
}
|