@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,712 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ projectInvestigationEvidence,
5
+ type InvestigationEvidenceTimelineItem,
6
+ } from "./investigationEvidence";
7
+ import {
8
+ metricsChangeCoverage,
9
+ metricsChangeMarkers,
10
+ metricsDomain,
11
+ metricsUnitForExpression,
12
+ } from "./investigationMetrics";
13
+
14
+ const target = {
15
+ kind: "Deployment",
16
+ group: "apps",
17
+ namespace: "shop",
18
+ name: "api",
19
+ };
20
+
21
+ function tool(
22
+ id: string,
23
+ name: string,
24
+ result: unknown,
25
+ patch: Partial<
26
+ Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }>
27
+ > = {},
28
+ ): Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }> {
29
+ return {
30
+ kind: "tool",
31
+ id,
32
+ tool: name,
33
+ status: "done",
34
+ summary: JSON.stringify({ namespace: "shop", name: "api" }),
35
+ result: JSON.stringify(result),
36
+ isError: false,
37
+ radarEvidence: true,
38
+ ...patch,
39
+ };
40
+ }
41
+
42
+ const window = { start: "2026-09-06T07:00:00Z", end: "2026-09-06T09:00:00Z" };
43
+
44
+ function rangeResult(selectors: unknown[]) {
45
+ return {
46
+ query:
47
+ 'sum(container_memory_working_set_bytes{namespace="shop",workload="api",workload_type="deployment"})',
48
+ type: "range",
49
+ ...window,
50
+ step: "60s",
51
+ series: [
52
+ {
53
+ labels: {},
54
+ dataPoints: [
55
+ { timestamp: Date.parse(window.start) / 1000, value: 1 },
56
+ { timestamp: Date.parse(window.end) / 1000, value: 2 },
57
+ ],
58
+ },
59
+ ],
60
+ selectors,
61
+ };
62
+ }
63
+
64
+ const targetSelectors = [
65
+ {
66
+ metric: "container_memory_working_set_bytes",
67
+ matchers: [
68
+ { label: "namespace", op: "=", value: "shop" },
69
+ { label: "workload", op: "=", value: "api" },
70
+ { label: "workload_type", op: "=", value: "deployment" },
71
+ ],
72
+ },
73
+ ];
74
+
75
+ function change(
76
+ kind: string,
77
+ name: string,
78
+ timestamp: string,
79
+ apiVersion = "v1",
80
+ ) {
81
+ return {
82
+ kind,
83
+ apiVersion,
84
+ namespace: "shop",
85
+ name,
86
+ changeType: "update",
87
+ summary: `${kind} ${name} changed`,
88
+ timestamp,
89
+ };
90
+ }
91
+
92
+ const diagnoseBundle = {
93
+ resource: {
94
+ apiVersion: "apps/v1",
95
+ kind: "Deployment",
96
+ metadata: { namespace: "shop", name: "api" },
97
+ },
98
+ resourceContext: {
99
+ tier: "diagnostic",
100
+ uses: {
101
+ configMaps: [
102
+ { kind: "ConfigMap", namespace: "shop", name: "api-config" },
103
+ ],
104
+ secrets: [{ kind: "Secret", namespace: "shop", name: "api-secret" }],
105
+ },
106
+ },
107
+ logsCurrent: [
108
+ {
109
+ pod: "api-7f6-abc",
110
+ container: "api",
111
+ logs: { lines: ["boot"], totalLines: 1, matchedLines: 0, fallback: true },
112
+ },
113
+ ],
114
+ recentChanges: [
115
+ change("ConfigMap", "api-config", "2026-09-06T07:30:00Z"),
116
+ change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
117
+ change("Pod", "api-7f6-abc", "2026-09-06T08:11:00Z"),
118
+ change("Deployment", "worker", "2026-09-06T08:20:00Z", "apps/v1"),
119
+ change("ConfigMap", "api-config", "2026-09-06T06:30:00Z"),
120
+ change("ConfigMap", "api-config", "2026-09-06T07:30:00Z"),
121
+ ],
122
+ pods: 1,
123
+ };
124
+
125
+ describe("metricsUnitForExpression", () => {
126
+ const selector = (metric: string) => [{ metric, matchers: [] }];
127
+
128
+ it("keeps the unit every metric states through aggregations and wrappers", () => {
129
+ expect(
130
+ metricsUnitForExpression(
131
+ 'sum(max by (pod,namespace,container) (container_memory_working_set_bytes{namespace="shop", workload="api"}))',
132
+ selector("container_memory_working_set_bytes"),
133
+ ),
134
+ ).toBe("bytes");
135
+ expect(
136
+ metricsUnitForExpression(
137
+ 'sum(rate(container_network_receive_bytes_total{namespace="shop"}[5m])) by (pod)',
138
+ selector("container_network_receive_bytes_total"),
139
+ ),
140
+ ).toBe("bytes/s");
141
+ expect(
142
+ metricsUnitForExpression(
143
+ "increase(container_network_receive_bytes_total[1h])",
144
+ selector("container_network_receive_bytes_total"),
145
+ ),
146
+ ).toBe("bytes");
147
+ expect(
148
+ metricsUnitForExpression(
149
+ "max_over_time(http_request_duration_seconds[10m])",
150
+ selector("http_request_duration_seconds"),
151
+ ),
152
+ ).toBe("seconds");
153
+ });
154
+
155
+ it("stays unitless for ratios, mixed metrics and bare numbers", () => {
156
+ expect(
157
+ metricsUnitForExpression('a_bytes / b_bytes{path="/api/v1"}', [
158
+ ...selector("a_bytes"),
159
+ ...selector("b_bytes"),
160
+ ]),
161
+ ).toBe("");
162
+ expect(
163
+ metricsUnitForExpression("a_bytes + b_seconds", [
164
+ ...selector("a_bytes"),
165
+ ...selector("b_seconds"),
166
+ ]),
167
+ ).toBe("");
168
+ expect(
169
+ metricsUnitForExpression(
170
+ "container_memory_working_set_bytes + kube_pod_info",
171
+ [
172
+ ...selector("container_memory_working_set_bytes"),
173
+ ...selector("kube_pod_info"),
174
+ ],
175
+ ),
176
+ ).toBe("");
177
+ expect(metricsUnitForExpression("42", [])).toBe("");
178
+ expect(metricsUnitForExpression("1 / 2", [])).toBe("");
179
+ });
180
+
181
+ it("drops the unit when a function counts or flags instead of measuring", () => {
182
+ for (const query of [
183
+ 'count(container_memory_working_set_bytes{namespace="shop"})',
184
+ 'count by (pod) (container_memory_working_set_bytes{namespace="shop"})',
185
+ "count without (container) (container_memory_working_set_bytes)",
186
+ "count_over_time(container_memory_working_set_bytes[1h])",
187
+ "absent(container_memory_working_set_bytes)",
188
+ "changes(http_request_duration_seconds[10m])",
189
+ "resets(container_network_receive_bytes_total[1h])",
190
+ "timestamp(container_memory_working_set_bytes)",
191
+ ]) {
192
+ expect(
193
+ metricsUnitForExpression(
194
+ query,
195
+ selector(query.match(/[a-z_]+_(?:bytes|seconds)(?:_total)?/)![0]),
196
+ ),
197
+ query,
198
+ ).toBe("");
199
+ }
200
+ expect(
201
+ metricsUnitForExpression(
202
+ "topk(3, container_memory_working_set_bytes)",
203
+ selector("container_memory_working_set_bytes"),
204
+ ),
205
+ ).toBe("bytes");
206
+ });
207
+
208
+ it("does not turn a matcher value containing a slash into a ratio", () => {
209
+ expect(
210
+ metricsUnitForExpression(
211
+ 'container_memory_working_set_bytes{image="ghcr.io/example/api"}',
212
+ selector("container_memory_working_set_bytes"),
213
+ ),
214
+ ).toBe("bytes");
215
+ });
216
+
217
+ it("names a unit for a bare metric with a unit suffix", () => {
218
+ expect(
219
+ metricsUnitForExpression(
220
+ 'container_memory_working_set_bytes{namespace="shop"}',
221
+ selector("container_memory_working_set_bytes"),
222
+ ),
223
+ ).toBe("bytes");
224
+ expect(
225
+ metricsUnitForExpression(
226
+ "http_request_duration_seconds",
227
+ selector("http_request_duration_seconds"),
228
+ ),
229
+ ).toBe("seconds");
230
+ expect(
231
+ metricsUnitForExpression(
232
+ "rate(container_cpu_usage_seconds_total[5m])",
233
+ selector("container_cpu_usage_seconds_total"),
234
+ ),
235
+ ).toBe("");
236
+ expect(
237
+ metricsUnitForExpression("a_bytes / b_bytes", [
238
+ ...selector("a_bytes"),
239
+ ...selector("b_bytes"),
240
+ ]),
241
+ ).toBe("");
242
+ expect(
243
+ metricsUnitForExpression(
244
+ "kube_pod_container_status_restarts_total",
245
+ selector("kube_pod_container_status_restarts_total"),
246
+ ),
247
+ ).toBe("");
248
+ });
249
+ });
250
+
251
+ describe("metricsDomain", () => {
252
+ it("returns the range window in unix seconds and nothing for instant results", () => {
253
+ expect(metricsDomain({ mode: "range", ...window })).toEqual({
254
+ start: Date.parse(window.start) / 1000,
255
+ end: Date.parse(window.end) / 1000,
256
+ });
257
+ expect(metricsDomain({ mode: "instant" })).toBeUndefined();
258
+ expect(
259
+ metricsDomain({ mode: "range", start: window.end, end: window.start }),
260
+ ).toBeUndefined();
261
+ });
262
+ });
263
+
264
+ describe("metricsChangeMarkers", () => {
265
+ function markersFor(
266
+ timelines: InvestigationEvidenceTimelineItem[][],
267
+ sourceId: string,
268
+ ) {
269
+ const projection = projectInvestigationEvidence(
270
+ timelines.map((timeline) => ({ timeline })),
271
+ target,
272
+ );
273
+ const observation = projection.groups
274
+ .flatMap((group) => group.observations)
275
+ .find(
276
+ (item) =>
277
+ item.data.type === "metrics" && item.source.stepId === sourceId,
278
+ );
279
+ if (!observation) throw new Error("metrics observation missing");
280
+ return metricsChangeMarkers(projection.groups, observation);
281
+ }
282
+
283
+ it("marks same-turn changes to the subject and its producer-established relatives, once each, inside the window", () => {
284
+ const markers = markersFor(
285
+ [
286
+ [
287
+ tool("diag", "diagnose", diagnoseBundle),
288
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
289
+ ],
290
+ ],
291
+ "prom",
292
+ );
293
+ expect(markers).toEqual([
294
+ {
295
+ timestamp: Date.parse("2026-09-06T07:30:00Z") / 1000,
296
+ label: "ConfigMap api-config",
297
+ kind: "change",
298
+ },
299
+ {
300
+ timestamp: Date.parse("2026-09-06T08:10:00Z") / 1000,
301
+ label: "Deployment api",
302
+ kind: "change",
303
+ },
304
+ {
305
+ timestamp: Date.parse("2026-09-06T08:11:00Z") / 1000,
306
+ label: "Pod api-7f6-abc",
307
+ kind: "change",
308
+ },
309
+ ]);
310
+ });
311
+
312
+ it("excludes a sibling workload's change even from a target-scoped change query", () => {
313
+ const markers = markersFor(
314
+ [
315
+ [
316
+ tool("diag", "diagnose", { ...diagnoseBundle, recentChanges: [] }),
317
+ tool(
318
+ "changes",
319
+ "get_changes",
320
+ {
321
+ changes: [
322
+ change(
323
+ "Deployment",
324
+ "worker",
325
+ "2026-09-06T08:20:00Z",
326
+ "apps/v1",
327
+ ),
328
+ change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
329
+ ],
330
+ },
331
+ {
332
+ summary: JSON.stringify({
333
+ kind: "Deployment",
334
+ namespace: "shop",
335
+ name: "api",
336
+ }),
337
+ },
338
+ ),
339
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
340
+ ],
341
+ ],
342
+ "prom",
343
+ );
344
+ expect(markers.map((marker) => marker.label)).toEqual(["Deployment api"]);
345
+ });
346
+
347
+ it("ignores broader change results and other turns", () => {
348
+ const markers = markersFor(
349
+ [
350
+ [tool("diag", "diagnose", diagnoseBundle)],
351
+ [
352
+ tool(
353
+ "broad",
354
+ "get_changes",
355
+ {
356
+ changes: [
357
+ change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
358
+ ],
359
+ },
360
+ { summary: JSON.stringify({ namespace: "shop" }) },
361
+ ),
362
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
363
+ ],
364
+ ],
365
+ "prom",
366
+ );
367
+ expect(markers).toEqual([]);
368
+ });
369
+
370
+ it("marks a Helm change without an apiVersion on the subject's own kind, never on an unrelated kind", () => {
371
+ const helmChange = {
372
+ source: "helm",
373
+ kind: "Deployment",
374
+ namespace: "shop",
375
+ name: "api",
376
+ changeType: "upgrade",
377
+ summary: "Helm release api upgraded to 2.4.1",
378
+ timestamp: "2026-09-06T08:00:00Z",
379
+ };
380
+ const markers = markersFor(
381
+ [
382
+ [
383
+ tool("diag", "diagnose", {
384
+ ...diagnoseBundle,
385
+ recentChanges: [
386
+ helmChange,
387
+ { ...helmChange, kind: "Service", changeType: "update" },
388
+ { ...helmChange, name: "worker" },
389
+ ],
390
+ }),
391
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
392
+ ],
393
+ ],
394
+ "prom",
395
+ );
396
+ expect(markers).toEqual([
397
+ {
398
+ timestamp: Date.parse("2026-09-06T08:00:00Z") / 1000,
399
+ label: "Deployment api",
400
+ kind: "change",
401
+ },
402
+ ]);
403
+ });
404
+
405
+ it("keeps the strict identity when a change carries an apiVersion", () => {
406
+ const markers = markersFor(
407
+ [
408
+ [
409
+ tool("diag", "diagnose", {
410
+ ...diagnoseBundle,
411
+ recentChanges: [
412
+ change(
413
+ "Deployment",
414
+ "api",
415
+ "2026-09-06T08:00:00Z",
416
+ "example.io/v1",
417
+ ),
418
+ ],
419
+ }),
420
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
421
+ ],
422
+ ],
423
+ "prom",
424
+ );
425
+ expect(markers).toEqual([]);
426
+ });
427
+
428
+ it("never marks a chart that is not about the target", () => {
429
+ const markers = markersFor(
430
+ [
431
+ [
432
+ tool("diag", "diagnose", diagnoseBundle),
433
+ tool("prom", "query_prometheus", rangeResult([])),
434
+ ],
435
+ ],
436
+ "prom",
437
+ );
438
+ expect(markers).toEqual([]);
439
+ });
440
+ });
441
+
442
+ describe("units only survive expressions that keep the meaning", () => {
443
+ const bytes = [
444
+ {
445
+ metric: "container_memory_working_set_bytes",
446
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
447
+ },
448
+ ];
449
+ const seconds = [
450
+ {
451
+ metric: "http_request_duration_seconds_bucket",
452
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
453
+ },
454
+ ];
455
+
456
+ it("refuses a unit for expressions that produce a different quantity", () => {
457
+ // Each of these is a number the metric's name no longer describes: a
458
+ // boolean, a presence flag, a variance in bytes squared, a ratio.
459
+ for (const query of [
460
+ "container_memory_working_set_bytes > bool 0",
461
+ "group(container_memory_working_set_bytes)",
462
+ "stdvar(container_memory_working_set_bytes)",
463
+ "stddev(container_memory_working_set_bytes)",
464
+ "container_memory_working_set_bytes / 1024",
465
+ "container_memory_working_set_bytes * 2",
466
+ "count by (pod) (container_memory_working_set_bytes)",
467
+ "absent(container_memory_working_set_bytes)",
468
+ "changes(container_memory_working_set_bytes[5m])",
469
+ "predict_linear(container_memory_working_set_bytes[1h], 3600)",
470
+ ]) {
471
+ expect(metricsUnitForExpression(query, bytes), query).toBe("");
472
+ }
473
+ });
474
+
475
+ it("keeps the unit through aggregation and the counter's own change", () => {
476
+ for (const query of [
477
+ "sum(container_memory_working_set_bytes)",
478
+ "max by (pod) (container_memory_working_set_bytes)",
479
+ "sum(increase(container_memory_working_set_bytes[1h]))",
480
+ "delta(container_memory_working_set_bytes[5m])",
481
+ "max_over_time(container_memory_working_set_bytes[1h])",
482
+ ]) {
483
+ expect(metricsUnitForExpression(query, bytes), query).toBe("bytes");
484
+ }
485
+ });
486
+
487
+ it("refuses a unit for a histogram's counting parts", () => {
488
+ // A _bucket or _count series counts observations; only the `le` boundary
489
+ // carries the measured unit, and nothing here reads it. The quantile of
490
+ // those buckets is in seconds, but this cannot tell that from the
491
+ // expression, so it says nothing rather than something wrong.
492
+ expect(
493
+ metricsUnitForExpression(
494
+ "histogram_quantile(0.99, sum by (le) (http_request_duration_seconds_bucket))",
495
+ seconds,
496
+ ),
497
+ ).toBe("");
498
+ expect(
499
+ metricsUnitForExpression("sum(http_request_duration_seconds_count)", [
500
+ {
501
+ metric: "http_request_duration_seconds_count",
502
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
503
+ },
504
+ ]),
505
+ ).toBe("");
506
+ // The sum of the observed values does measure what was observed.
507
+ expect(
508
+ metricsUnitForExpression("sum(http_request_duration_seconds_sum)", [
509
+ {
510
+ metric: "http_request_duration_seconds_sum",
511
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
512
+ },
513
+ ]),
514
+ ).toBe("seconds");
515
+ });
516
+
517
+ it("refuses the shapes an axis label cannot survive", () => {
518
+ const bytesOf = (metric: string) => [
519
+ {
520
+ metric,
521
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
522
+ },
523
+ ];
524
+ // A set operator mixes two expressions whose units may differ; a second
525
+ // rate divides by time twice; a comment can hide a call from a reader and
526
+ // from this; a string can contain anything at all.
527
+ for (const [query, selectors] of [
528
+ [
529
+ "container_memory_working_set_bytes and rate(other_bytes_total[5m])",
530
+ bytesOf("container_memory_working_set_bytes"),
531
+ ],
532
+ [
533
+ "rate(container_memory_working_set_bytes[5m]) or container_memory_working_set_bytes",
534
+ bytesOf("container_memory_working_set_bytes"),
535
+ ],
536
+ [
537
+ "deriv(rate(container_memory_working_set_bytes[5m])[10m:])",
538
+ bytesOf("container_memory_working_set_bytes"),
539
+ ],
540
+ [
541
+ "container_memory_working_set_bytes-other_bytes",
542
+ bytesOf("container_memory_working_set_bytes"),
543
+ ],
544
+ ] as const) {
545
+ expect(metricsUnitForExpression(query, selectors), query).toBe("");
546
+ }
547
+ // A `#` inside a label value is not a comment. Reading comments before
548
+ // strings let it swallow the rest of the query, so a dimensionless 0/1
549
+ // and a ratio both came back in bytes.
550
+ expect(
551
+ metricsUnitForExpression(
552
+ 'container_memory_working_set_bytes{note="#"} > bool 0',
553
+ bytesOf("container_memory_working_set_bytes"),
554
+ ),
555
+ ).toBe("");
556
+ expect(
557
+ metricsUnitForExpression(
558
+ 'container_memory_working_set_bytes{note="#"} / container_memory_working_set_bytes',
559
+ bytesOf("container_memory_working_set_bytes"),
560
+ ),
561
+ ).toBe("");
562
+ // A comment is not a call: stripping it leaves a plain metric in bytes.
563
+ expect(
564
+ metricsUnitForExpression(
565
+ "container_memory_working_set_bytes # rate(",
566
+ bytesOf("container_memory_working_set_bytes"),
567
+ ),
568
+ ).toBe("bytes");
569
+ // A backtick string cannot smuggle a call past the reader either.
570
+ expect(
571
+ metricsUnitForExpression(
572
+ 'label_replace(container_memory_working_set_bytes, "note", `rate(`, "pod", ".*")',
573
+ bytesOf("container_memory_working_set_bytes"),
574
+ ),
575
+ ).toBe("bytes");
576
+ });
577
+
578
+ it("keeps the unit through ordering, and drops it for an angle", () => {
579
+ const bytesOf = (metric: string) => [
580
+ {
581
+ metric,
582
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
583
+ },
584
+ ];
585
+ // Sorting reorders samples without touching their values.
586
+ for (const fn of ["sort", "sort_desc"]) {
587
+ expect(
588
+ metricsUnitForExpression(
589
+ `${fn}(container_memory_working_set_bytes)`,
590
+ bytesOf("container_memory_working_set_bytes"),
591
+ ),
592
+ ).toBe("bytes");
593
+ }
594
+ expect(
595
+ metricsUnitForExpression(
596
+ 'sort_by_label(container_memory_working_set_bytes, "pod")',
597
+ bytesOf("container_memory_working_set_bytes"),
598
+ ),
599
+ ).toBe("bytes");
600
+ // atan2 is spelled as a word but is a binary operator, and returns an
601
+ // angle however many bytes went into it.
602
+ expect(
603
+ metricsUnitForExpression(
604
+ "container_memory_working_set_bytes atan2 container_memory_working_set_bytes",
605
+ bytesOf("container_memory_working_set_bytes"),
606
+ ),
607
+ ).toBe("");
608
+ });
609
+
610
+ it("still turns a rate of bytes into bytes per second and a rate of seconds into nothing", () => {
611
+ expect(
612
+ metricsUnitForExpression(
613
+ "sum(rate(container_memory_working_set_bytes[5m]))",
614
+ bytes,
615
+ ),
616
+ ).toBe("bytes/s");
617
+ expect(
618
+ metricsUnitForExpression(
619
+ "sum(rate(container_cpu_usage_seconds_total[5m]))",
620
+ [
621
+ {
622
+ metric: "container_cpu_usage_seconds_total",
623
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
624
+ },
625
+ ],
626
+ ),
627
+ ).toBe("");
628
+ });
629
+ });
630
+
631
+ describe("metricsChangeCoverage", () => {
632
+ function coverageFor(
633
+ timelines: InvestigationEvidenceTimelineItem[][],
634
+ sourceId: string,
635
+ ) {
636
+ const projection = projectInvestigationEvidence(
637
+ timelines.map((timeline) => ({ timeline })),
638
+ target,
639
+ );
640
+ const observation = projection.groups
641
+ .flatMap((group) => group.observations)
642
+ .find(
643
+ (item) =>
644
+ item.data.type === "metrics" && item.source.stepId === sourceId,
645
+ );
646
+ if (!observation) throw new Error("metrics observation missing");
647
+ return metricsChangeCoverage(projection.groups, observation);
648
+ }
649
+
650
+ it("reports a checked window when the turn captured changes and none landed in it", () => {
651
+ const coverage = coverageFor(
652
+ [
653
+ [
654
+ tool("diag", "diagnose", { ...diagnoseBundle, recentChanges: [] }),
655
+ tool(
656
+ "changes",
657
+ "get_changes",
658
+ {
659
+ changes: [
660
+ change("Deployment", "api", "2020-01-01T00:00:00Z", "apps/v1"),
661
+ ],
662
+ },
663
+ {
664
+ summary: JSON.stringify({
665
+ kind: "Deployment",
666
+ namespace: "shop",
667
+ name: "api",
668
+ }),
669
+ },
670
+ ),
671
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
672
+ ],
673
+ ],
674
+ "prom",
675
+ );
676
+ expect(coverage.markers).toEqual([]);
677
+ expect(coverage.checked).toBe(true);
678
+ });
679
+
680
+ it("does not call the window checked when only a broader change result was captured", () => {
681
+ const coverage = coverageFor(
682
+ [
683
+ [tool("diag", "diagnose", diagnoseBundle)],
684
+ [
685
+ tool(
686
+ "broad",
687
+ "get_changes",
688
+ {
689
+ changes: [
690
+ change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
691
+ ],
692
+ },
693
+ { summary: JSON.stringify({ namespace: "shop" }) },
694
+ ),
695
+ tool("prom", "query_prometheus", rangeResult(targetSelectors)),
696
+ ],
697
+ ],
698
+ "prom",
699
+ );
700
+ expect(coverage.markers).toEqual([]);
701
+ expect(coverage.checked).toBe(false);
702
+ });
703
+
704
+ it("does not call the window checked when the turn captured no changes at all", () => {
705
+ const coverage = coverageFor(
706
+ [[tool("prom", "query_prometheus", rangeResult(targetSelectors))]],
707
+ "prom",
708
+ );
709
+ expect(coverage.markers).toEqual([]);
710
+ expect(coverage.checked).toBe(false);
711
+ });
712
+ });