@skyhook-io/radar-app 1.13.5 → 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 (201) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +321 -16
  6. package/src/api/diagnose.ts +1 -0
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +179 -42
  13. package/src/components/CloudFunnelButton.tsx +107 -36
  14. package/src/components/SyncProgressPanel.tsx +73 -0
  15. package/src/components/audit/AuditView.tsx +2 -0
  16. package/src/components/cloudConnectHandoff.test.ts +140 -0
  17. package/src/components/cloudConnectHandoff.ts +124 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  19. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  20. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  21. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  22. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  23. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  24. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  25. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  26. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  35. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  36. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  37. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  38. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  39. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  40. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  41. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  42. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  43. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  44. package/src/components/diagnose/target.test.ts +19 -0
  45. package/src/components/diagnose/target.ts +3 -2
  46. package/src/components/gitops/GitOpsView.tsx +7 -0
  47. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  48. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  49. package/src/components/resource/PrometheusCharts.tsx +4 -3
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  51. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  52. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  53. package/src/components/resource/RightsizingStrip.tsx +15 -3
  54. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  55. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  56. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  57. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  58. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  59. package/src/components/resource/workloadMetricValues.ts +30 -0
  60. package/src/components/resources/ResourcesView.tsx +44 -6
  61. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  63. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  64. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  65. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  66. package/src/components/settings/SettingsDialog.tsx +94 -14
  67. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  68. package/src/components/traffic/TrafficGraph.tsx +6 -6
  69. package/src/components/traffic/TrafficView.tsx +10 -9
  70. package/src/components/traffic/trafficFilters.test.ts +28 -1
  71. package/src/components/traffic/trafficFilters.ts +31 -0
  72. package/src/components/workload/WorkloadView.tsx +19 -8
  73. package/src/context/ConnectionContext.tsx +48 -3
  74. package/src/context/DiagnoseCustomization.tsx +1 -0
  75. package/src/index.css +17 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/topology-namespace.test.ts +64 -0
  78. package/src/utils/topology-namespace.ts +18 -0
  79. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  80. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  81. package/src/components/gitops/GitOpsActions.tsx +0 -1
  82. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  83. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  84. package/src/components/gitops/SyncCountdown.tsx +0 -1
  85. package/src/components/gitops/index.ts +0 -4
  86. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  87. package/src/components/logs/JsonLogLine.tsx +0 -1
  88. package/src/components/logs/LogCore.tsx +0 -2
  89. package/src/components/logs/useLogBuffer.ts +0 -2
  90. package/src/components/logs/useLogSearch.ts +0 -1
  91. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  92. package/src/components/resources/drawer-components.tsx +0 -1
  93. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  94. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  95. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  96. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  97. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  98. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  99. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  100. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  101. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  102. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  103. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  104. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  105. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  106. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  107. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  108. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  109. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  110. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  111. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  112. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  113. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  114. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  115. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  116. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  117. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  118. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  119. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  120. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  121. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  122. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  162. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  169. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  170. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  171. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  172. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  173. package/src/components/resources/renderers/index.ts +0 -94
  174. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  175. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  176. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  177. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  178. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  179. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  180. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  181. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  182. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  183. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  184. package/src/components/resources/resource-utils-argo.ts +0 -2
  185. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  186. package/src/components/resources/resource-utils-eso.ts +0 -2
  187. package/src/components/resources/resource-utils-flux.ts +0 -2
  188. package/src/components/resources/resource-utils-istio.ts +0 -2
  189. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  190. package/src/components/resources/resource-utils-knative.ts +0 -2
  191. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  192. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  193. package/src/components/resources/resource-utils-traefik.ts +0 -1
  194. package/src/components/resources/resource-utils-trivy.ts +0 -2
  195. package/src/components/resources/resource-utils-velero.ts +0 -2
  196. package/src/components/shared/EditableYamlView.tsx +0 -24
  197. package/src/components/timeline/DiffViewer.tsx +0 -1
  198. package/src/components/traffic/index.ts +0 -3
  199. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  200. package/src/components/ui/MetricsChart.tsx +0 -1
  201. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,573 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { investigationEvidenceSubjectRef } from "../index";
3
+ import {
4
+ criticalIssue,
5
+ deployment,
6
+ groupsOf,
7
+ project,
8
+ tool,
9
+ warningEvent,
10
+ } from "../evidenceFixtures";
11
+
12
+ describe("semantic diagnose evidence projection", () => {
13
+ it("promotes only classified smoking guns and audits every partiality signal", () => {
14
+ const result = project([
15
+ tool("diagnose-1", "diagnose", {
16
+ resource: deployment,
17
+ resourceContext: {
18
+ tier: "diagnostic",
19
+ issueSummary: {
20
+ count: 1,
21
+ highestSeverity: "critical",
22
+ topReason: "CrashLoopBackOff",
23
+ },
24
+ // Static posture stays attached to Resource; it is never its own Key.
25
+ auditSummary: {
26
+ count: 3,
27
+ highestSeverity: "high",
28
+ topFinding: "run-as-root",
29
+ },
30
+ referencedBy: { total: 8, items: [], truncated: true },
31
+ omitted: [{ field: "uses.secrets", reason: "rbac_denied" }],
32
+ },
33
+ pods: 2,
34
+ relatedIssues: [criticalIssue],
35
+ startupBlockers: [
36
+ {
37
+ kind: "Pod",
38
+ name: "api-abc",
39
+ reason: "ImagePullBackOff",
40
+ severity: "critical",
41
+ message: "The image tag does not exist.",
42
+ },
43
+ ],
44
+ crashCause: [
45
+ {
46
+ pods: ["api-abc"],
47
+ container: "api",
48
+ state: "terminated",
49
+ reason: "Error",
50
+ exitCode: 1,
51
+ logLine: "FATAL: missing DATABASE_URL",
52
+ logSource: "previous",
53
+ logLineSelection: "fatal_pattern",
54
+ },
55
+ ],
56
+ crashCauseTruncated: true,
57
+ logsCurrent: [
58
+ {
59
+ pod: "api-abc",
60
+ container: "api",
61
+ logs: {
62
+ lines: ["ERROR database unavailable"],
63
+ totalLines: 30,
64
+ matchedLines: 1,
65
+ fallback: false,
66
+ },
67
+ },
68
+ {
69
+ pod: "api-def",
70
+ container: "api",
71
+ logs: {
72
+ lines: ["server started"],
73
+ totalLines: 1,
74
+ matchedLines: 0,
75
+ fallback: true,
76
+ },
77
+ },
78
+ ],
79
+ logCoverage: {
80
+ resolvedPods: 5,
81
+ selectedPods: 2,
82
+ selectionTruncated: true,
83
+ shownLines: 2,
84
+ totalLines: 8,
85
+ contentTruncated: true,
86
+ },
87
+ events: [warningEvent],
88
+ eventsTotalGroups: 3,
89
+ recentChanges: [
90
+ {
91
+ kind: "Deployment",
92
+ namespace: "shop",
93
+ name: "api",
94
+ changeType: "update",
95
+ timestamp: "2026-09-02T09:55:00Z",
96
+ summary: "image changed",
97
+ },
98
+ ],
99
+ recentChangesSaturated: true,
100
+ dnsContext: { signals: ["lookup failures in application logs"] },
101
+ narrowHint: "log fan-out selected 2 of 5 pods",
102
+ warnings: ["Managed by Helm; edit the source of truth."],
103
+ }),
104
+ ]);
105
+
106
+ expect(groupsOf(result.groups, "issue")[0].latest).toMatchObject({
107
+ tier: "key",
108
+ relevance: "target",
109
+ });
110
+ expect(groupsOf(result.groups, "startup")[0].latest.tier).toBe("key");
111
+ expect(groupsOf(result.groups, "crash")[0].latest.tier).toBe("key");
112
+ expect(groupsOf(result.groups, "startup")[0].latest.relevance).toBe(
113
+ "producer-related",
114
+ );
115
+ expect(groupsOf(result.groups, "startup")[0].latest.data).toMatchObject({
116
+ type: "startup",
117
+ subject: { kind: "Pod", namespace: "shop", name: "api-abc" },
118
+ });
119
+ expect(groupsOf(result.groups, "crash")[0].latest.relevance).toBe(
120
+ "producer-related",
121
+ );
122
+ // The detailed critical Issue owns the Key slot; its aggregate resource
123
+ // rollup stays supporting rather than counting the same signal twice.
124
+ expect(groupsOf(result.groups, "resource")[0].latest.tier).toBe(
125
+ "supporting",
126
+ );
127
+ expect(groupsOf(result.groups, "resource")[0].latest.data).toMatchObject({
128
+ type: "resource",
129
+ warnings: ["Managed by Helm; edit the source of truth."],
130
+ });
131
+ expect(
132
+ groupsOf(result.groups, "logs").map((group) => group.latest.tier),
133
+ ).toEqual(["supporting", "context"]);
134
+ expect(groupsOf(result.groups, "dns")).toHaveLength(1);
135
+ expect(
136
+ result.groups.some((group) => group.identity.includes("audit")),
137
+ ).toBe(false);
138
+
139
+ const limitationText = result.limitations
140
+ .map((item) => item.message)
141
+ .join("\n");
142
+ expect(limitationText).toContain("selected 2 of 5");
143
+ expect(limitationText).toContain("includes 2 of 8 selected log lines");
144
+ expect(limitationText).toContain("not the container's full log history");
145
+ expect(limitationText).toContain("part of the crash-cause candidates");
146
+ expect(limitationText).toContain("received 1 of 3 event groups");
147
+ expect(limitationText).toContain("rbac denied");
148
+ expect(limitationText).toContain("referenced-by relationships");
149
+ expect(limitationText).toContain("recent-change result limit");
150
+ expect(result.coverage).toEqual({
151
+ attempted: 1,
152
+ projected: 1,
153
+ limited: 1,
154
+ checked: 0,
155
+ });
156
+ expect(result.sources[0].primaryGroupId).toBe(
157
+ groupsOf(result.groups, "issue")[0].id,
158
+ );
159
+ });
160
+
161
+ it("uses a critical live issueSummary as Key when no detailed issue duplicates it", () => {
162
+ const result = project([
163
+ tool("resource-summary", "diagnose", {
164
+ resource: deployment,
165
+ resourceContext: {
166
+ tier: "basic",
167
+ issueSummary: {
168
+ count: 2,
169
+ highestSeverity: "critical",
170
+ topReason: "Workload unavailable",
171
+ },
172
+ },
173
+ pods: 0,
174
+ events: [],
175
+ recentChanges: [],
176
+ }),
177
+ ]);
178
+ expect(groupsOf(result.groups, "resource")[0].latest.tier).toBe("key");
179
+ });
180
+
181
+ it("keeps an explicit replica shortfall or failing controller condition visible as Supporting", () => {
182
+ const result = project([
183
+ tool("diagnose-state", "diagnose", {
184
+ resource: deployment,
185
+ resourceContext: {
186
+ tier: "basic",
187
+ workloadSummary: {
188
+ replicas: { desired: 2, ready: 1, available: 1, unavailable: 1 },
189
+ },
190
+ statusSummary: {
191
+ conditions: [
192
+ {
193
+ type: "ReplicaFailure",
194
+ status: "True",
195
+ reason: "FailedCreate",
196
+ },
197
+ ],
198
+ },
199
+ },
200
+ pods: 1,
201
+ events: [],
202
+ recentChanges: [],
203
+ }),
204
+ ]);
205
+ expect(groupsOf(result.groups, "resource")[0].latest).toMatchObject({
206
+ tier: "supporting",
207
+ tone: "warning",
208
+ });
209
+ });
210
+ });
211
+
212
+ describe("diagnose metrics evidence", () => {
213
+ const window = {
214
+ start: "2026-09-06T07:00:00Z",
215
+ end: "2026-09-06T08:00:00Z",
216
+ step: "1m2s",
217
+ };
218
+ const samples = [
219
+ { timestamp: Date.parse(window.start) / 1000, value: 0.003457 },
220
+ { timestamp: Date.parse(window.end) / 1000, value: 0.004 },
221
+ ];
222
+ function vitals(patch: Record<string, unknown> = {}) {
223
+ return {
224
+ window,
225
+ pods: 2,
226
+ series: [
227
+ {
228
+ category: "cpu",
229
+ unit: "cores",
230
+ query:
231
+ "sum(rate(container_cpu_usage_seconds_total{container!='',namespace='shop',pod=~'^(api-a|api-b)$'}[5m]))",
232
+ series: [{ labels: {}, dataPoints: samples }],
233
+ },
234
+ {
235
+ category: "memory",
236
+ unit: "bytes",
237
+ query:
238
+ "sum(max by (pod,namespace,container) (container_memory_working_set_bytes{container!='',namespace='shop',pod=~'^(api-a|api-b)$'}))",
239
+ series: [{ labels: {}, dataPoints: samples }],
240
+ },
241
+ {
242
+ category: "restarts",
243
+ unit: "count",
244
+ query:
245
+ "sum(round(increase(kube_pod_container_status_restarts_total{namespace='shop',pod=~'^(api-a|api-b)$'}[1h])))",
246
+ series: [],
247
+ },
248
+ ],
249
+ ...patch,
250
+ };
251
+ }
252
+ const args = { kind: "Deployment", namespace: "shop", name: "api" };
253
+
254
+ it("charts each captured category as target evidence with the diagnosed resource as subject", () => {
255
+ const projection = project([
256
+ tool(
257
+ "diag",
258
+ "diagnose",
259
+ { resource: deployment, pods: 2, metrics: vitals() },
260
+ { summary: JSON.stringify(args) },
261
+ ),
262
+ ]);
263
+ const groups = groupsOf(projection.groups, "metrics");
264
+ expect(groups.map((group) => group.latest.title)).toEqual([
265
+ "CPU usage · Deployment shop/api",
266
+ "Memory working set · Deployment shop/api",
267
+ "Restarts · Deployment shop/api",
268
+ ]);
269
+ for (const group of groups) {
270
+ expect(group.latest.relevance).toBe("target");
271
+ expect(group.latest.tier).toBe("supporting");
272
+ expect(group.latest.tone).toBe("neutral");
273
+ const data = group.latest.data;
274
+ if (data.type !== "metrics") throw new Error("expected metrics");
275
+ expect(data.origin).toBe("diagnose");
276
+ expect(data.mode).toBe("range");
277
+ expect(data.start).toBe(window.start);
278
+ expect(data.end).toBe(window.end);
279
+ expect(data.step).toBe(window.step);
280
+ expect(data.pods).toBe(2);
281
+ expect(data.partial).toBe(false);
282
+ expect(data.truncated).toBe(false);
283
+ expect(data.subject).toEqual({
284
+ kind: "Deployment",
285
+ group: "apps",
286
+ namespace: "shop",
287
+ name: "api",
288
+ });
289
+ expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
290
+ }
291
+ const [cpu, , restarts] = groups;
292
+ expect(cpu.latest.summary).toBe("2 current pods · 60m window · 1m2s step");
293
+ const cpuData = cpu.latest.data;
294
+ if (cpuData.type !== "metrics") throw new Error("expected metrics");
295
+ expect(cpuData.unit).toBe("cores");
296
+ expect(cpuData.label).toBe("CPU usage");
297
+ expect(cpuData.query).toContain("pod=~'^(api-a|api-b)$'");
298
+ expect(cpuData.series).toEqual([{ labels: {}, dataPoints: samples }]);
299
+ expect(restarts.latest.summary).toBe(
300
+ "No samples in the window · 60m window · 1m2s step",
301
+ );
302
+ expect(
303
+ projection.limitations.filter(
304
+ (item) => item.source === "Workload metrics",
305
+ ),
306
+ ).toHaveLength(0);
307
+ });
308
+
309
+ it("merges a re-diagnose of the same workload into the same chart per category", () => {
310
+ const later = vitals({
311
+ window: { ...window, end: "2026-09-06T08:30:00Z" },
312
+ });
313
+ const projection = project(
314
+ [
315
+ tool(
316
+ "diag-1",
317
+ "diagnose",
318
+ { resource: deployment, pods: 2, metrics: vitals() },
319
+ { summary: JSON.stringify(args) },
320
+ ),
321
+ ],
322
+ [
323
+ tool(
324
+ "diag-2",
325
+ "diagnose",
326
+ { resource: deployment, pods: 2, metrics: later },
327
+ { summary: JSON.stringify(args) },
328
+ ),
329
+ ],
330
+ );
331
+ const groups = groupsOf(projection.groups, "metrics");
332
+ expect(groups).toHaveLength(3);
333
+ expect(groups.every((group) => group.observations.length === 2)).toBe(true);
334
+ const data = groups[0].latest.data;
335
+ if (data.type !== "metrics") throw new Error("expected metrics");
336
+ expect(data.end).toBe("2026-09-06T08:30:00Z");
337
+ });
338
+
339
+ it("reports a producer error as a limitation and still charts what answered", () => {
340
+ const projection = project([
341
+ tool(
342
+ "diag",
343
+ "diagnose",
344
+ {
345
+ resource: deployment,
346
+ pods: 2,
347
+ metrics: vitals({
348
+ series: [vitals().series[1]],
349
+ error:
350
+ "cpu: prom: query error from prometheus: cpu exploded (execution)",
351
+ }),
352
+ },
353
+ { summary: JSON.stringify(args) },
354
+ ),
355
+ ]);
356
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(1);
357
+ const limitation = projection.limitations.find(
358
+ (item) => item.source === "Workload metrics",
359
+ );
360
+ expect(limitation?.kind).toBe("error");
361
+ expect(limitation?.message).toContain("cpu exploded");
362
+ });
363
+
364
+ it("reports an unreachable Prometheus without inventing a chart", () => {
365
+ const projection = project([
366
+ tool(
367
+ "diag",
368
+ "diagnose",
369
+ {
370
+ resource: deployment,
371
+ pods: 2,
372
+ metrics: vitals({
373
+ series: [],
374
+ error:
375
+ "prometheus unreachable: dial tcp 10.0.0.9:9090: connection refused",
376
+ }),
377
+ },
378
+ { summary: JSON.stringify(args) },
379
+ ),
380
+ ]);
381
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
382
+ const limitation = projection.limitations.find(
383
+ (item) => item.source === "Workload metrics",
384
+ );
385
+ expect(limitation?.kind).toBe("error");
386
+ expect(limitation?.message).toBe(
387
+ "prometheus unreachable: dial tcp 10.0.0.9:9090: connection refused",
388
+ );
389
+ });
390
+
391
+ it("says nothing when diagnose carried no metrics field", () => {
392
+ const projection = project([
393
+ tool(
394
+ "diag",
395
+ "diagnose",
396
+ { resource: deployment, pods: 2 },
397
+ { summary: JSON.stringify(args) },
398
+ ),
399
+ ]);
400
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
401
+ expect(
402
+ projection.limitations.some((item) => item.source === "Workload metrics"),
403
+ ).toBe(false);
404
+ });
405
+
406
+ it("notes a partial pod set in the summary and keeps the cap on the card", () => {
407
+ const projection = project([
408
+ tool(
409
+ "diag",
410
+ "diagnose",
411
+ {
412
+ resource: deployment,
413
+ pods: 55,
414
+ metrics: vitals({ pods: 50, partial: true, omittedPods: 5 }),
415
+ },
416
+ { summary: JSON.stringify(args) },
417
+ ),
418
+ ]);
419
+ const [cpu] = groupsOf(projection.groups, "metrics");
420
+ expect(cpu.latest.summary).toBe(
421
+ "first 50 of 55 current pods · 60m window · 1m2s step · partial pod set",
422
+ );
423
+ const data = cpu.latest.data;
424
+ if (data.type !== "metrics") throw new Error("expected metrics");
425
+ expect(data.pods).toBe(50);
426
+ expect(data.partial).toBe(true);
427
+ });
428
+
429
+ it("keeps a neighbour's vitals broader with the neighbour as subject", () => {
430
+ const worker = {
431
+ ...deployment,
432
+ metadata: { namespace: "shop", name: "worker" },
433
+ };
434
+ const projection = project([
435
+ tool(
436
+ "diag-worker",
437
+ "diagnose",
438
+ { resource: worker, pods: 2, metrics: vitals() },
439
+ {
440
+ summary: JSON.stringify({
441
+ kind: "Deployment",
442
+ namespace: "shop",
443
+ name: "worker",
444
+ }),
445
+ },
446
+ ),
447
+ ]);
448
+ const groups = groupsOf(projection.groups, "metrics");
449
+ expect(groups).toHaveLength(3);
450
+ for (const group of groups) {
451
+ expect(group.latest.relevance).toBe("broader");
452
+ expect(group.latest.tier).toBe("context");
453
+ const data = group.latest.data;
454
+ if (data.type !== "metrics") throw new Error("expected metrics");
455
+ expect(data.subject).toEqual({
456
+ kind: "Deployment",
457
+ group: "apps",
458
+ namespace: "shop",
459
+ name: "worker",
460
+ });
461
+ }
462
+ });
463
+
464
+ it("rejects a malformed metrics field instead of charting it", () => {
465
+ const malformed: Record<string, unknown>[] = [
466
+ { window: { start: window.start }, pods: "two", series: [] },
467
+ { window: { ...window, end: "not a date" }, pods: 2, series: [] },
468
+ { window: { ...window, end: window.start }, pods: 2, series: [] },
469
+ { window, pods: -1, series: [] },
470
+ { window, pods: 1.5, series: [] },
471
+ { window, pods: 2, omittedPods: -3, series: [] },
472
+ ];
473
+ for (const metrics of malformed) {
474
+ const projection = project([
475
+ tool(
476
+ "diag",
477
+ "diagnose",
478
+ { resource: deployment, pods: 2, metrics },
479
+ { summary: JSON.stringify(args) },
480
+ ),
481
+ ]);
482
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
483
+ expect(
484
+ projection.limitations.some(
485
+ (item) =>
486
+ item.source === "Workload metrics" && item.kind === "unknown",
487
+ ),
488
+ ).toBe(true);
489
+ }
490
+ });
491
+
492
+ it("keeps same-named workloads in different API groups on separate charts", () => {
493
+ const rollout = {
494
+ ...deployment,
495
+ apiVersion: "argoproj.io/v1alpha1",
496
+ kind: "Rollout",
497
+ };
498
+ const projection = project([
499
+ tool(
500
+ "diag-deployment",
501
+ "diagnose",
502
+ { resource: deployment, pods: 2, metrics: vitals() },
503
+ { summary: JSON.stringify(args) },
504
+ ),
505
+ tool(
506
+ "diag-rollout",
507
+ "diagnose",
508
+ { resource: rollout, pods: 2, metrics: vitals() },
509
+ { summary: JSON.stringify(args) },
510
+ ),
511
+ ]);
512
+ const groups = groupsOf(projection.groups, "metrics");
513
+ expect(groups).toHaveLength(6);
514
+ expect(groups.every((group) => group.observations.length === 1)).toBe(true);
515
+ expect(new Set(groups.map((group) => group.identity)).size).toBe(6);
516
+ });
517
+
518
+ it("rejects inherited object keys posing as categories", () => {
519
+ for (const category of ["__proto__", "constructor", "toString"]) {
520
+ const projection = project([
521
+ tool(
522
+ "diag",
523
+ "diagnose",
524
+ {
525
+ resource: deployment,
526
+ pods: 2,
527
+ metrics: vitals({
528
+ series: [{ ...vitals().series[0], category }],
529
+ }),
530
+ },
531
+ { summary: JSON.stringify(args) },
532
+ ),
533
+ ]);
534
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
535
+ expect(
536
+ projection.limitations.some(
537
+ (item) =>
538
+ item.source === "Workload metrics" && item.kind === "unknown",
539
+ ),
540
+ ).toBe(true);
541
+ }
542
+ });
543
+
544
+ it("rejects a category outside the contract or a series without a unit", () => {
545
+ const projection = project([
546
+ tool(
547
+ "diag",
548
+ "diagnose",
549
+ {
550
+ resource: deployment,
551
+ pods: 2,
552
+ metrics: vitals({
553
+ series: [
554
+ { ...vitals().series[0], category: "latency" },
555
+ { ...vitals().series[1], unit: undefined },
556
+ vitals().series[2],
557
+ ],
558
+ }),
559
+ },
560
+ { summary: JSON.stringify(args) },
561
+ ),
562
+ ]);
563
+ const groups = groupsOf(projection.groups, "metrics");
564
+ expect(groups.map((group) => group.latest.title)).toEqual([
565
+ "Restarts · Deployment shop/api",
566
+ ]);
567
+ expect(
568
+ projection.limitations.some(
569
+ (item) => item.source === "Workload metrics" && item.kind === "unknown",
570
+ ),
571
+ ).toBe(true);
572
+ });
573
+ });