@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,935 @@
1
+ import { type Issue, type IssueRecentChange } from "@skyhook-io/k8s-ui";
2
+ import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
3
+ import { type TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
4
+ import { apiVersionToGroup } from "../../../../utils/navigation";
5
+ import {
6
+ type DiagnosisDNSContext,
7
+ type DiagnosisPodContainerRef,
8
+ type DiagnosisPodLogEntry,
9
+ type DiagnosisResourceRef,
10
+ type DiagnosisStartupBlocker,
11
+ diagnosisSeverityTone,
12
+ } from "../../diagnoseEvidenceTypes";
13
+ import { metricsWindowLabel } from "./prometheus";
14
+ import {
15
+ ProjectionBuilder,
16
+ addChanges,
17
+ addEvents,
18
+ addIssueObservation,
19
+ addLogs,
20
+ addNarrowHint,
21
+ addResourceObservation,
22
+ contextFrom,
23
+ evidenceTierForRelevance,
24
+ invalidPayload,
25
+ isDiagnosableWorkloadKind,
26
+ relevanceForResource,
27
+ resourceMatchesTarget,
28
+ scopeFromArgs,
29
+ } from "../observations";
30
+ import {
31
+ crashCause,
32
+ diagnosisChangeContext,
33
+ event,
34
+ gitOpsDiagnosis,
35
+ issue,
36
+ kubernetesResource,
37
+ networkEvidence,
38
+ nonEmptyString,
39
+ nonNegativeInteger,
40
+ parseJSON,
41
+ parseLogEntry,
42
+ podContainerRef,
43
+ recentChange,
44
+ record,
45
+ startupBlocker,
46
+ stringArray,
47
+ timeSeries,
48
+ } from "../parse";
49
+ import {
50
+ type InvestigationEventEvidence,
51
+ type InvestigationEvidenceRelevance,
52
+ type InvestigationEvidenceSource,
53
+ type InvestigationMetricsEvidence,
54
+ } from "../types";
55
+
56
+ function diagnoseCrashQueryCoversTarget(
57
+ source: InvestigationEvidenceSource,
58
+ ): boolean {
59
+ const args = record(source.args ? parseJSON(source.args) : undefined);
60
+ if (!args) return false;
61
+
62
+ // Crash retirement is target-wide. It therefore requires the producer's
63
+ // normal all-container, full-time-window read. A caller-selected container,
64
+ // since window, or shorter-than-default tail can validate that slice only;
65
+ // it cannot clear a smoking gun from a stream it never revisited.
66
+ if (nonEmptyString(args.container) || nonEmptyString(args.since))
67
+ return false;
68
+ if (args.tail_lines === undefined) return true;
69
+ return (
70
+ nonNegativeInteger(args.tail_lines) &&
71
+ (args.tail_lines === 0 || args.tail_lines >= 100)
72
+ );
73
+ }
74
+ /**
75
+ * A missing crash candidate is meaningful only when diagnose actually read the
76
+ * complete log surface that its crash classifier consumes. Keep this tied to
77
+ * the current producer contract: a partial pod sample, a capped response, or a
78
+ * failed stream is absence of evidence and must not clear earlier crash proof.
79
+ */
80
+ function diagnoseCrashCoverageComplete(
81
+ value: Record<string, unknown>,
82
+ ): boolean {
83
+ if (
84
+ !nonNegativeInteger(value.pods) ||
85
+ value.pods === 0 ||
86
+ value.logsError !== undefined ||
87
+ (value.crashCauseTruncated !== undefined &&
88
+ typeof value.crashCauseTruncated !== "boolean") ||
89
+ value.crashCauseTruncated === true
90
+ ) {
91
+ return false;
92
+ }
93
+
94
+ const coverage = record(value.logCoverage);
95
+ if (
96
+ !coverage ||
97
+ !nonNegativeInteger(coverage.resolvedPods) ||
98
+ !nonNegativeInteger(coverage.selectedPods) ||
99
+ !nonNegativeInteger(coverage.shownLines) ||
100
+ !nonNegativeInteger(coverage.totalLines) ||
101
+ !nonNegativeInteger(coverage.shownPods) ||
102
+ !nonNegativeInteger(coverage.totalPods) ||
103
+ coverage.resolvedPods !== value.pods ||
104
+ coverage.selectedPods !== coverage.resolvedPods ||
105
+ (coverage.selectionTruncated !== undefined &&
106
+ typeof coverage.selectionTruncated !== "boolean") ||
107
+ coverage.selectionTruncated === true ||
108
+ (coverage.contentTruncated !== undefined &&
109
+ typeof coverage.contentTruncated !== "boolean") ||
110
+ coverage.contentTruncated === true ||
111
+ coverage.shownLines !== coverage.totalLines ||
112
+ coverage.shownPods !== coverage.totalPods
113
+ ) {
114
+ return false;
115
+ }
116
+
117
+ const currentRaw = value.logsCurrent;
118
+ const previousRaw = value.logsPrevious;
119
+ if (
120
+ !Array.isArray(currentRaw) ||
121
+ currentRaw.length === 0 ||
122
+ !Array.isArray(previousRaw)
123
+ ) {
124
+ return false;
125
+ }
126
+
127
+ const current = currentRaw.map(parseLogEntry);
128
+ const previous = previousRaw.map(parseLogEntry);
129
+ if (
130
+ current.some(
131
+ (entry) => !entry || entry.error !== undefined || !entry.logs,
132
+ ) ||
133
+ previous.some((entry) => !entry || !entry.logs)
134
+ ) {
135
+ return false;
136
+ }
137
+
138
+ const currentEntries = current as DiagnosisPodLogEntry[];
139
+ const previousEntries = previous as DiagnosisPodLogEntry[];
140
+ const streamKey = (entry: DiagnosisPodContainerRef) =>
141
+ `${entry.pod}\u0000${entry.container}`;
142
+ const currentKeys = new Set(currentEntries.map(streamKey));
143
+ const previousKeys = new Set(previousEntries.map(streamKey));
144
+ if (
145
+ currentKeys.size !== current.length ||
146
+ previousKeys.size !== previous.length ||
147
+ currentKeys.size !== previousKeys.size ||
148
+ [...currentKeys].some((key) => !previousKeys.has(key))
149
+ ) {
150
+ return false;
151
+ }
152
+
153
+ const absencesRaw = value.expectedPreviousLogAbsences;
154
+ if (absencesRaw !== undefined && !Array.isArray(absencesRaw)) return false;
155
+ const absences = Array.isArray(absencesRaw)
156
+ ? absencesRaw.map(podContainerRef)
157
+ : [];
158
+ if (absences.some((entry) => !entry)) return false;
159
+ const absenceEntries = absences as DiagnosisPodContainerRef[];
160
+ const absenceKeys = new Set(absenceEntries.map(streamKey));
161
+ if ([...absenceKeys].some((key) => !previousKeys.has(key))) return false;
162
+
163
+ return previousEntries.every((entry) => {
164
+ if (entry.error === undefined) return true;
165
+ return nonEmptyString(entry.error) && absenceKeys.has(streamKey(entry));
166
+ });
167
+ }
168
+ export function adaptDiagnose(
169
+ builder: ProjectionBuilder,
170
+ source: InvestigationEvidenceSource,
171
+ payload: unknown,
172
+ ): void {
173
+ const value = record(payload);
174
+ if (value) {
175
+ const network = networkEvidence(value);
176
+ if (network) {
177
+ const relevance = relevanceForResource(builder, network.subject);
178
+ const adverse =
179
+ network.verdict === "broken" || network.verdict === "degraded";
180
+ builder.observe(
181
+ `network:${network.subject.group ?? ""}:${network.subject.kind}:${network.subject.namespace ?? ""}:${network.subject.name}`,
182
+ "network",
183
+ source,
184
+ {
185
+ tier: evidenceTierForRelevance(
186
+ adverse ? "supporting" : "context",
187
+ relevance,
188
+ ),
189
+ relevance,
190
+ tone:
191
+ network.verdict === "broken"
192
+ ? "error"
193
+ : network.verdict === "degraded"
194
+ ? "warning"
195
+ : network.verdict === "healthy"
196
+ ? "info"
197
+ : "neutral",
198
+ title: `${network.subject.kind} path · ${network.subject.namespace ? `${network.subject.namespace}/` : ""}${network.subject.name}`,
199
+ summary:
200
+ network.diagnosis?.summary ||
201
+ network.reason ||
202
+ network.summary.headline,
203
+ data: { type: "network", network },
204
+ },
205
+ );
206
+ if (network.summary.skipped > 0) {
207
+ builder.limit(
208
+ source,
209
+ "Network path coverage",
210
+ `${network.summary.skipped} intended route${network.summary.skipped === 1 ? " was" : "s were"} not tested. ${network.summary.headline}`,
211
+ "unknown",
212
+ );
213
+ }
214
+ return;
215
+ }
216
+ }
217
+ const resource = kubernetesResource(value?.resource);
218
+ if (!value || !resource) {
219
+ invalidPayload(builder, source);
220
+ return;
221
+ }
222
+ const bundleRelevance = relevanceForResource(builder, {
223
+ kind: resource.kind,
224
+ group: apiVersionToGroup(resource.apiVersion),
225
+ namespace: resource.metadata.namespace,
226
+ name: resource.metadata.name,
227
+ });
228
+ const relatedRelevance: InvestigationEvidenceRelevance =
229
+ bundleRelevance === "target" ? "producer-related" : "broader";
230
+
231
+ const bundledRowRelevance = (
232
+ kind: string,
233
+ name: string,
234
+ ): InvestigationEvidenceRelevance => {
235
+ if (bundleRelevance !== "target") return "broader";
236
+ const sameAsRoot =
237
+ kind.toLowerCase() === resource.kind.toLowerCase() &&
238
+ name === resource.metadata.name;
239
+ const group = sameAsRoot
240
+ ? apiVersionToGroup(resource.apiVersion)
241
+ : kind.toLowerCase() === "pod"
242
+ ? ""
243
+ : undefined;
244
+ return resourceMatchesTarget(builder.target, {
245
+ kind,
246
+ group,
247
+ namespace: resource.metadata.namespace,
248
+ name,
249
+ })
250
+ ? "target"
251
+ : "producer-related";
252
+ };
253
+ addNarrowHint(builder, source, value);
254
+
255
+ const relatedRaw = value.relatedIssues;
256
+ const related = Array.isArray(relatedRaw)
257
+ ? relatedRaw.map(issue).filter((item): item is Issue => Boolean(item))
258
+ : [];
259
+ const relatedValid =
260
+ relatedRaw === undefined ||
261
+ (Array.isArray(relatedRaw) && related.length === relatedRaw.length);
262
+ if (!relatedValid) {
263
+ invalidPayload(builder, source, "Classified issues");
264
+ }
265
+ const blockersRaw = value.startupBlockers;
266
+ let blockersValid = blockersRaw === undefined || Array.isArray(blockersRaw);
267
+ // Pods of one workload usually share a blocker word for word. Merge those
268
+ // into one finding with the pod list; anything else keeps its own card and
269
+ // its own identity so saved runs match as before.
270
+ const blockerGroups: Array<{
271
+ blocker: DiagnosisStartupBlocker;
272
+ pods: string[];
273
+ foldedInto?: Issue;
274
+ }> = [];
275
+ if (Array.isArray(blockersRaw)) {
276
+ const merged = new Map<string, (typeof blockerGroups)[number]>();
277
+ for (const raw of blockersRaw) {
278
+ const blocker = startupBlocker(raw);
279
+ if (!blocker) {
280
+ blockersValid = false;
281
+ invalidPayload(builder, source, "Startup evidence");
282
+ continue;
283
+ }
284
+ const key =
285
+ blocker.kind === "Pod"
286
+ ? `${blocker.reason} ${blocker.severity} ${blocker.message}`
287
+ : undefined;
288
+ const existing = key ? merged.get(key) : undefined;
289
+ if (existing) {
290
+ if (!existing.pods.includes(blocker.name)) {
291
+ existing.pods.push(blocker.name);
292
+ }
293
+ continue;
294
+ }
295
+ const entry = { blocker, pods: [blocker.name] };
296
+ if (key) merged.set(key, entry);
297
+ blockerGroups.push(entry);
298
+ }
299
+ }
300
+ for (const item of related) {
301
+ // `diagnose` is itself scoped to one resource and declares these rows as
302
+ // related evidence. That producer contract is stronger than a broad
303
+ // `issues` query, even when the related row is a child Pod.
304
+ // A classified issue and the pods' startup blocker are the same fact
305
+ // seen from the workload and from its pods; one card carries both.
306
+ const folded = blockerGroups.find(
307
+ (group) =>
308
+ !group.foldedInto &&
309
+ group.blocker.kind === "Pod" &&
310
+ group.blocker.reason === item.reason &&
311
+ (group.blocker.message === item.message ||
312
+ group.blocker.message === item.cause),
313
+ );
314
+ if (folded) folded.foldedInto = item;
315
+ addIssueObservation(builder, source, item, relatedRelevance, folded?.pods);
316
+ }
317
+
318
+ const context = contextFrom(value.resourceContext);
319
+ if (value.resourceContext !== undefined && !context) {
320
+ invalidPayload(builder, source, "Resource context");
321
+ }
322
+ const gitOps =
323
+ value.gitopsDiagnosis === undefined
324
+ ? undefined
325
+ : gitOpsDiagnosis(value.gitopsDiagnosis);
326
+ if (value.gitopsDiagnosis !== undefined && !gitOps) {
327
+ invalidPayload(builder, source, "GitOps status");
328
+ }
329
+ if (
330
+ source.confirmedSuccess &&
331
+ related.length === 0 &&
332
+ relatedValid &&
333
+ isDiagnosableWorkloadKind(resource.kind)
334
+ ) {
335
+ const scope = scopeFromArgs(source);
336
+ builder.observe(`issues:diagnose:${scope}`, "receipt", source, {
337
+ tier: evidenceTierForRelevance("checked", bundleRelevance),
338
+ relevance: bundleRelevance,
339
+ tone: "neutral",
340
+ title: "Radar's diagnosis found no live issues",
341
+ summary: scope,
342
+ data: {
343
+ type: "receipt",
344
+ checked: "issues",
345
+ scope,
346
+ },
347
+ });
348
+ }
349
+ const warnings = stringArray(value.warnings) ?? [];
350
+ addResourceObservation(
351
+ builder,
352
+ source,
353
+ resource,
354
+ context,
355
+ warnings,
356
+ gitOps,
357
+ related.some((item) => item.severity === "critical"),
358
+ bundleRelevance,
359
+ );
360
+
361
+ if (Array.isArray(blockersRaw)) {
362
+ for (const { blocker, pods, foldedInto } of blockerGroups) {
363
+ if (foldedInto) continue;
364
+ // One blocker keeps one identity however many pods share it. Keying a
365
+ // merged card differently from a single-pod one made a re-diagnose that
366
+ // crossed one pod look like a new fact, so the same reason appeared
367
+ // twice; a Pod blocker is the same finding whether it holds one pod or
368
+ // nine.
369
+ const grouped = blocker.kind === "Pod" && pods.length > 1;
370
+ builder.observe(
371
+ blocker.kind === "Pod"
372
+ ? `startup:Pod:${blocker.reason}:${fnv1a32(blocker.message).toString(36)}`
373
+ : `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
374
+ "startup",
375
+ source,
376
+ {
377
+ tier: evidenceTierForRelevance(
378
+ "key",
379
+ bundledRowRelevance(blocker.kind, blocker.name),
380
+ ),
381
+ relevance: bundledRowRelevance(blocker.kind, blocker.name),
382
+ tone: diagnosisSeverityTone(blocker.severity),
383
+ title: blocker.reason,
384
+ summary: grouped
385
+ ? `${pods.length} pods · ${blocker.message}`
386
+ : blocker.message,
387
+ data: {
388
+ type: "startup",
389
+ blocker,
390
+ // Always carry the pods, whether one or nine: they are what puts
391
+ // this workload's pods into the set a later Prometheus query is
392
+ // proved against, and a merged card that dropped them quietly
393
+ // weakened attribution elsewhere.
394
+ pods,
395
+ subject: grouped
396
+ ? undefined
397
+ : (() => {
398
+ const namespace = resource.metadata.namespace;
399
+ if (!namespace) return undefined;
400
+ const rootGroup = apiVersionToGroup(resource.apiVersion);
401
+ const group =
402
+ blocker.kind === resource.kind
403
+ ? rootGroup
404
+ : blocker.kind === "Pod"
405
+ ? ""
406
+ : blocker.kind === "ReplicaSet"
407
+ ? "apps"
408
+ : undefined;
409
+ if (group === undefined) return undefined;
410
+ return {
411
+ kind: blocker.kind,
412
+ ...(group ? { group } : {}),
413
+ namespace,
414
+ name: blocker.name,
415
+ };
416
+ })(),
417
+ },
418
+ },
419
+ );
420
+ }
421
+ } else if (blockersRaw !== undefined) {
422
+ invalidPayload(builder, source, "Startup evidence");
423
+ }
424
+
425
+ const crashesRaw = value.crashCause;
426
+ let crashesValid =
427
+ (crashesRaw === undefined || Array.isArray(crashesRaw)) &&
428
+ (value.crashCauseTruncated === undefined ||
429
+ typeof value.crashCauseTruncated === "boolean");
430
+ if (Array.isArray(crashesRaw)) {
431
+ for (const raw of crashesRaw) {
432
+ const crash = crashCause(raw);
433
+ if (!crash) {
434
+ crashesValid = false;
435
+ invalidPayload(builder, source, "Crash evidence");
436
+ continue;
437
+ }
438
+ const pods = [...crash.pods].sort().join(",");
439
+ const crashRelevance =
440
+ crash.pods.length === 1
441
+ ? bundledRowRelevance("Pod", crash.pods[0])
442
+ : relatedRelevance;
443
+ builder.observe(
444
+ `crash:${pods}:${crash.container}:${crash.state}:${crash.reason ?? ""}`,
445
+ "crash",
446
+ source,
447
+ {
448
+ tier: evidenceTierForRelevance("key", crashRelevance),
449
+ relevance: crashRelevance,
450
+ tone: "error",
451
+ title: `${crash.container} ${crash.reason || crash.state}`,
452
+ summary: crash.logLine,
453
+ data: {
454
+ type: "crash",
455
+ crash,
456
+ namespace: resource.metadata.namespace,
457
+ },
458
+ },
459
+ );
460
+ }
461
+ } else if (crashesRaw !== undefined) {
462
+ invalidPayload(builder, source, "Crash evidence");
463
+ }
464
+ if (value.crashCauseTruncated === true) {
465
+ builder.limit(
466
+ source,
467
+ "Crash evidence",
468
+ "Radar returned part of the crash-cause candidates.",
469
+ "truncated",
470
+ );
471
+ }
472
+
473
+ if (
474
+ source.confirmedSuccess &&
475
+ bundleRelevance === "target" &&
476
+ isDiagnosableWorkloadKind(resource.kind)
477
+ ) {
478
+ if (relatedValid) builder.coverSemantic(source, "issue");
479
+ if (blockersValid) builder.coverSemantic(source, "startup");
480
+ if (
481
+ crashesValid &&
482
+ diagnoseCrashQueryCoversTarget(source) &&
483
+ diagnoseCrashCoverageComplete(value)
484
+ ) {
485
+ builder.coverSemantic(source, "crash");
486
+ }
487
+ // DNSContext is emitted only for positive symptoms/configuration. The
488
+ // producer has no explicit negative DNS coverage receipt, so its absence
489
+ // cannot safely retire earlier DNS evidence.
490
+ }
491
+
492
+ const expectedAbsencesRaw = value.expectedPreviousLogAbsences;
493
+ const expectedAbsences = Array.isArray(expectedAbsencesRaw)
494
+ ? expectedAbsencesRaw
495
+ .map(podContainerRef)
496
+ .filter((item): item is DiagnosisPodContainerRef => Boolean(item))
497
+ : [];
498
+ if (
499
+ expectedAbsencesRaw !== undefined &&
500
+ (!Array.isArray(expectedAbsencesRaw) ||
501
+ expectedAbsences.length !== expectedAbsencesRaw.length)
502
+ ) {
503
+ invalidPayload(builder, source, "Previous-log status");
504
+ }
505
+ const expectedAbsenceKeys = new Set(
506
+ expectedAbsences.map((item) => `${item.pod}\u0000${item.container}`),
507
+ );
508
+ if (source.confirmedSuccess) {
509
+ for (const item of expectedAbsences) {
510
+ const logRelevance = bundledRowRelevance("Pod", item.pod);
511
+ builder.observe(
512
+ `previous-log-absence:${item.pod}:${item.container}`,
513
+ "receipt",
514
+ source,
515
+ {
516
+ tier: evidenceTierForRelevance("checked", logRelevance),
517
+ relevance: logRelevance,
518
+ tone: "neutral",
519
+ title: "No previous logs expected",
520
+ summary: `${item.pod} / ${item.container}`,
521
+ data: {
522
+ type: "receipt",
523
+ checked: "logs",
524
+ scope: `${item.pod} / ${item.container}`,
525
+ message:
526
+ "Captured container status shows zero restarts and no prior termination, so a previous log stream should not exist.",
527
+ },
528
+ },
529
+ );
530
+ }
531
+ }
532
+
533
+ for (const [field, previous] of [
534
+ ["logsCurrent", false],
535
+ ["logsPrevious", true],
536
+ ] as const) {
537
+ const raw = value[field];
538
+ if (Array.isArray(raw)) {
539
+ for (const item of raw) {
540
+ const entry = parseLogEntry(item);
541
+ if (
542
+ entry &&
543
+ previous &&
544
+ expectedAbsenceKeys.has(`${entry.pod}\u0000${entry.container}`) &&
545
+ (entry.logs?.lines?.length ?? 0) === 0
546
+ ) {
547
+ continue;
548
+ }
549
+ if (entry) {
550
+ addLogs(
551
+ builder,
552
+ source,
553
+ entry,
554
+ previous,
555
+ [],
556
+ bundledRowRelevance("Pod", entry.pod),
557
+ resource.metadata.namespace,
558
+ );
559
+ } else
560
+ invalidPayload(
561
+ builder,
562
+ source,
563
+ previous ? "Previous logs" : "Current logs",
564
+ );
565
+ }
566
+ if (raw.length === 0 && field === "logsCurrent") {
567
+ builder.limit(
568
+ source,
569
+ "Current logs",
570
+ "No container logs were available, so Radar could not evaluate them.",
571
+ "unknown",
572
+ );
573
+ }
574
+ } else if (raw !== undefined) {
575
+ invalidPayload(
576
+ builder,
577
+ source,
578
+ previous ? "Previous logs" : "Current logs",
579
+ );
580
+ } else if (
581
+ field === "logsCurrent" &&
582
+ typeof value.pods === "number" &&
583
+ value.pods > 0 &&
584
+ !nonEmptyString(value.logsError)
585
+ ) {
586
+ // Empty slices are omitted by the Go producer. With resolved pods this
587
+ // means the read yielded no stream rows, not that logs proved anything.
588
+ builder.limit(
589
+ source,
590
+ "Current logs",
591
+ "No container logs were available, so Radar could not evaluate them.",
592
+ "unknown",
593
+ );
594
+ }
595
+ }
596
+ if (nonEmptyString(value.logsError)) {
597
+ builder.limit(source, "Logs", value.logsError, "error");
598
+ }
599
+ const logCoverage = record(value.logCoverage);
600
+ if (logCoverage?.selectionTruncated === true) {
601
+ const selected =
602
+ typeof logCoverage.selectedPods === "number"
603
+ ? logCoverage.selectedPods
604
+ : "some";
605
+ const resolved =
606
+ typeof logCoverage.resolvedPods === "number"
607
+ ? logCoverage.resolvedPods
608
+ : "the resolved";
609
+ builder.limit(
610
+ source,
611
+ "Log pod coverage",
612
+ `Log collection selected ${selected} of ${resolved} pods.`,
613
+ "truncated",
614
+ );
615
+ }
616
+ if (logCoverage?.contentTruncated === true) {
617
+ const shown =
618
+ typeof logCoverage.shownLines === "number"
619
+ ? logCoverage.shownLines
620
+ : "a subset of";
621
+ const total =
622
+ typeof logCoverage.totalLines === "number"
623
+ ? logCoverage.totalLines
624
+ : "the returned";
625
+ builder.limit(
626
+ source,
627
+ "Log excerpt coverage",
628
+ `The response includes ${shown} of ${total} selected log lines after the size limit; these are excerpts, not the container's full log history.`,
629
+ "truncated",
630
+ );
631
+ }
632
+
633
+ const eventsRaw = value.events;
634
+ if (Array.isArray(eventsRaw)) {
635
+ const events = eventsRaw
636
+ .map(event)
637
+ .filter((item): item is InvestigationEventEvidence => Boolean(item));
638
+ if (events.length === eventsRaw.length) {
639
+ addEvents(
640
+ builder,
641
+ source,
642
+ events,
643
+ `events:diagnose:${scopeFromArgs(source)}`,
644
+ typeof value.eventsTotalGroups !== "number" ||
645
+ value.eventsTotalGroups <= events.length,
646
+ true,
647
+ relatedRelevance,
648
+ );
649
+ } else {
650
+ invalidPayload(builder, source, "Events");
651
+ }
652
+ } else if (
653
+ eventsRaw === undefined &&
654
+ value.gitopsDiagnosis === undefined &&
655
+ !nonEmptyString(value.eventsError)
656
+ ) {
657
+ addEvents(
658
+ builder,
659
+ source,
660
+ [],
661
+ `events:diagnose:${scopeFromArgs(source)}`,
662
+ true,
663
+ true,
664
+ relatedRelevance,
665
+ );
666
+ } else if (eventsRaw !== undefined) {
667
+ invalidPayload(builder, source, "Events");
668
+ }
669
+ if (nonEmptyString(value.eventsError)) {
670
+ builder.limit(source, "Events", value.eventsError, "error");
671
+ }
672
+ if (
673
+ typeof value.eventsTotalGroups === "number" &&
674
+ Array.isArray(eventsRaw) &&
675
+ value.eventsTotalGroups > eventsRaw.length
676
+ ) {
677
+ builder.limit(
678
+ source,
679
+ "Events",
680
+ `Radar received ${eventsRaw.length} of ${value.eventsTotalGroups} event groups.`,
681
+ "truncated",
682
+ );
683
+ }
684
+
685
+ const changesRaw = value.recentChanges;
686
+ const changesCoverageLimitedRaw = value.recentChangesCoverageLimited;
687
+ const changesCoverageLimitedValid =
688
+ changesCoverageLimitedRaw === undefined ||
689
+ typeof changesCoverageLimitedRaw === "boolean";
690
+ const changesCoverageLimited = changesCoverageLimitedRaw === true;
691
+ if (!changesCoverageLimitedValid) {
692
+ invalidPayload(builder, source, "Recent changes");
693
+ }
694
+ const changeContext =
695
+ value.changeContext === undefined
696
+ ? undefined
697
+ : diagnosisChangeContext(value.changeContext);
698
+ if (value.changeContext !== undefined && !changeContext) {
699
+ invalidPayload(builder, source, "Change correlation");
700
+ }
701
+ const diagnoseChangesSubject = {
702
+ kind: resource.kind,
703
+ namespace: resource.metadata.namespace,
704
+ name: resource.metadata.name,
705
+ };
706
+ if (Array.isArray(changesRaw)) {
707
+ const changes = changesRaw
708
+ .map(recentChange)
709
+ .filter((item): item is IssueRecentChange => Boolean(item));
710
+ if (changes.length === changesRaw.length) {
711
+ addChanges(
712
+ builder,
713
+ source,
714
+ changes,
715
+ `changes:diagnose:${scopeFromArgs(source)}`,
716
+ changeContext,
717
+ value.recentChangesSaturated !== true &&
718
+ !changesCoverageLimited &&
719
+ changesCoverageLimitedValid,
720
+ true,
721
+ relatedRelevance,
722
+ diagnoseChangesSubject,
723
+ );
724
+ } else {
725
+ invalidPayload(builder, source, "Recent changes");
726
+ }
727
+ } else if (
728
+ changesRaw === undefined &&
729
+ value.gitopsDiagnosis === undefined &&
730
+ !nonEmptyString(value.recentChangesError)
731
+ ) {
732
+ addChanges(
733
+ builder,
734
+ source,
735
+ [],
736
+ `changes:diagnose:${scopeFromArgs(source)}`,
737
+ changeContext,
738
+ value.recentChangesSaturated !== true &&
739
+ !changesCoverageLimited &&
740
+ changesCoverageLimitedValid,
741
+ true,
742
+ relatedRelevance,
743
+ diagnoseChangesSubject,
744
+ );
745
+ } else if (changesRaw !== undefined) {
746
+ invalidPayload(builder, source, "Recent changes");
747
+ }
748
+ if (nonEmptyString(value.recentChangesError)) {
749
+ builder.limit(source, "Recent changes", value.recentChangesError, "error");
750
+ }
751
+
752
+ addDiagnoseMetrics(
753
+ builder,
754
+ source,
755
+ value.metrics,
756
+ {
757
+ kind: resource.kind,
758
+ ...(apiVersionToGroup(resource.apiVersion)
759
+ ? { group: apiVersionToGroup(resource.apiVersion) }
760
+ : {}),
761
+ namespace: resource.metadata.namespace,
762
+ name: resource.metadata.name,
763
+ },
764
+ bundleRelevance,
765
+ );
766
+ if (value.recentChangesSaturated === true) {
767
+ builder.limit(
768
+ source,
769
+ "Recent changes",
770
+ "The recent-change result limit was reached; additional changes may exist in the requested window.",
771
+ "truncated",
772
+ );
773
+ }
774
+ if (changesCoverageLimited) {
775
+ builder.limit(
776
+ source,
777
+ "Recent changes",
778
+ "Recent-change coverage is limited because Radar could not confirm permission to read every referenced source. The visible result cannot prove that no recent changes exist.",
779
+ "unknown",
780
+ );
781
+ }
782
+
783
+ const dns = record(value.dnsContext);
784
+ if (dns) {
785
+ const signals = stringArray(dns.signals) ?? [];
786
+ const findings = Array.isArray(dns.coreDNSFindings)
787
+ ? dns.coreDNSFindings
788
+ : [];
789
+ if (signals.length > 0 || findings.length > 0) {
790
+ const adverse =
791
+ findings.length > 0 ||
792
+ signals.some((signal) =>
793
+ /\b(error|failed|failures?|timeouts?|nxdomain|servfail)\b/i.test(
794
+ signal,
795
+ ),
796
+ );
797
+ builder.observe(`dns:${scopeFromArgs(source)}`, "dns", source, {
798
+ tier: evidenceTierForRelevance(
799
+ adverse ? "supporting" : "context",
800
+ relatedRelevance,
801
+ ),
802
+ relevance: relatedRelevance,
803
+ tone: adverse ? "warning" : "info",
804
+ title: adverse ? "DNS failure signals" : "DNS configuration",
805
+ summary:
806
+ signals[0] ||
807
+ `${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
808
+ data: { type: "dns", dns: dns as unknown as DiagnosisDNSContext },
809
+ });
810
+ }
811
+ } else if (value.dnsContext !== undefined) {
812
+ invalidPayload(builder, source, "DNS context");
813
+ }
814
+ }
815
+ /**
816
+ * One half of a Go↔TS contract: `diagnoseMetricsCategories` in
817
+ * internal/mcp/tools_diagnose_metrics.go decides which categories the producer
818
+ * captures, and a series whose category is missing here is discarded. Change
819
+ * both together.
820
+ */
821
+ const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
822
+ cpu: "CPU usage",
823
+ memory: "Memory working set",
824
+ restarts: "Restarts",
825
+ };
826
+ /**
827
+ * The vitals `diagnose` captured inside the same call: one chart per category
828
+ * over the exact pods the bundle covers. They take the bundle's relevance, so a
829
+ * neighbour's diagnose never charts as evidence for the target, and they carry
830
+ * the diagnosed resource as their subject so recorded changes can be marked
831
+ * on them. An absent field means Prometheus was not available or the read was
832
+ * not permitted, which is not a collection failure the producer reported.
833
+ */
834
+ function addDiagnoseMetrics(
835
+ builder: ProjectionBuilder,
836
+ source: InvestigationEvidenceSource,
837
+ raw: unknown,
838
+ subject: DiagnosisResourceRef,
839
+ relevance: InvestigationEvidenceRelevance,
840
+ ): void {
841
+ if (raw === undefined) return;
842
+ const value = record(raw);
843
+ const window = record(value?.window);
844
+ if (
845
+ !value ||
846
+ !window ||
847
+ !nonEmptyString(window.start) ||
848
+ !nonEmptyString(window.end) ||
849
+ !nonEmptyString(window.step) ||
850
+ !(Date.parse(window.end) > Date.parse(window.start)) ||
851
+ !nonNegativeInteger(value.pods) ||
852
+ (value.partial !== undefined && typeof value.partial !== "boolean") ||
853
+ (value.omittedPods !== undefined &&
854
+ !nonNegativeInteger(value.omittedPods)) ||
855
+ (value.error !== undefined && typeof value.error !== "string") ||
856
+ !Array.isArray(value.series)
857
+ ) {
858
+ invalidPayload(builder, source, "Workload metrics");
859
+ return;
860
+ }
861
+ const scope = scopeFromArgs(source);
862
+ const partial = value.partial === true;
863
+ // The pods the chart covers: the ones the workload controlled when the
864
+ // bundle was captured, which a rollout during the window can miss.
865
+ const podsLabel = partial
866
+ ? `first ${value.pods} of ${typeof value.omittedPods === "number" ? value.pods + value.omittedPods : "the"} current pods`
867
+ : `${value.pods} current pod${value.pods === 1 ? "" : "s"}`;
868
+ const windowLabel = metricsWindowLabel({
869
+ mode: "range",
870
+ start: window.start,
871
+ end: window.end,
872
+ step: window.step,
873
+ });
874
+ for (const rawEntry of value.series) {
875
+ const entry = record(rawEntry);
876
+ const rawSeries = entry?.series;
877
+ const series = Array.isArray(rawSeries)
878
+ ? rawSeries
879
+ .map(timeSeries)
880
+ .filter((item): item is TimeSeries => Boolean(item))
881
+ : undefined;
882
+ if (
883
+ !entry ||
884
+ !nonEmptyString(entry.category) ||
885
+ !Object.hasOwn(DIAGNOSE_METRICS_LABELS, entry.category) ||
886
+ !nonEmptyString(entry.query) ||
887
+ !nonEmptyString(entry.unit) ||
888
+ !Array.isArray(rawSeries) ||
889
+ !series ||
890
+ series.length !== rawSeries.length
891
+ ) {
892
+ invalidPayload(builder, source, "Workload metrics");
893
+ continue;
894
+ }
895
+ const label = DIAGNOSE_METRICS_LABELS[entry.category];
896
+ const data: InvestigationMetricsEvidence = {
897
+ type: "metrics",
898
+ origin: "diagnose",
899
+ query: entry.query,
900
+ mode: "range",
901
+ start: window.start,
902
+ end: window.end,
903
+ step: window.step,
904
+ unit: entry.unit,
905
+ label,
906
+ series,
907
+ truncated: false,
908
+ subject,
909
+ pods: value.pods,
910
+ partial,
911
+ };
912
+ builder.observe(
913
+ `metrics:diagnose:${subject.group ?? ""}:${subject.kind}:${subject.namespace ?? ""}:${subject.name}:${entry.category}`,
914
+ "metrics",
915
+ source,
916
+ {
917
+ tier: evidenceTierForRelevance("supporting", relevance),
918
+ relevance,
919
+ tone: "neutral",
920
+ title: `${label} · ${scope}`,
921
+ summary: [
922
+ series.length === 0 ? "No samples in the window" : podsLabel,
923
+ windowLabel,
924
+ partial ? "partial pod set" : undefined,
925
+ ]
926
+ .filter((part): part is string => Boolean(part))
927
+ .join(" · "),
928
+ data,
929
+ },
930
+ );
931
+ }
932
+ if (nonEmptyString(value.error)) {
933
+ builder.limit(source, "Workload metrics", value.error, "error");
934
+ }
935
+ }