@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.
Files changed (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. 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
+ }