@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,954 @@
1
+ import {
2
+ type HPADiagnosisState,
3
+ type Issue,
4
+ type IssueRecentChange,
5
+ type Topology,
6
+ defaultConditionTone,
7
+ displayKind,
8
+ hpaStateLabel,
9
+ hpaStateLevel,
10
+ stripAnsi,
11
+ } from "@skyhook-io/k8s-ui";
12
+ import {
13
+ type DiagnosisChangeContext,
14
+ type DiagnosisEvidenceLimitationBase,
15
+ type DiagnosisPodLogEntry,
16
+ diagnosisSeverityTone,
17
+ } from "../diagnoseEvidenceTypes";
18
+ import { investigationResourceEvidenceSummary } from "../investigationResourceEvidenceModel";
19
+ import { kindToPluralWithGroup } from "../../../utils/navigation";
20
+ import { stableHash } from "./identity";
21
+ import { nonEmptyString, parseJSON, record, stringArray } from "./parse";
22
+ import {
23
+ type InvestigationEventEvidence,
24
+ type InvestigationEvidenceGroup,
25
+ type InvestigationEvidenceKind,
26
+ type InvestigationEvidenceLimitation,
27
+ type InvestigationEvidenceObservation,
28
+ type InvestigationEvidenceRelevance,
29
+ type InvestigationEvidenceSource,
30
+ type InvestigationEvidenceTarget,
31
+ type InvestigationEvidenceTier,
32
+ type InvestigationGitOpsDiagnosis,
33
+ type InvestigationKubernetesResource,
34
+ type InvestigationResourceContext,
35
+ type InvestigationSemanticDomain,
36
+ } from "./types";
37
+
38
+ type TopologyPartiality = {
39
+ warnings: string[];
40
+ largeCluster: boolean;
41
+ hiddenKinds: string[];
42
+ requiresNamespaceFilter: boolean;
43
+ crdDiscoveryStatus?: NonNullable<Topology["crdDiscoveryStatus"]>;
44
+ estimatedNodes?: number;
45
+ summaryMode: boolean;
46
+ };
47
+ /**
48
+ * Both get_topology wire shapes carry the same completeness metadata. Keep the
49
+ * adapter strict: silently dropping a malformed flag would make a partial graph
50
+ * look complete in Evidence.
51
+ */
52
+ export function topologyPartiality(
53
+ value: Record<string, unknown>,
54
+ ): TopologyPartiality | undefined {
55
+ const warnings =
56
+ value.warnings === undefined ? [] : stringArray(value.warnings);
57
+ const hiddenKinds =
58
+ value.hiddenKinds === undefined ? [] : stringArray(value.hiddenKinds);
59
+ const discovery = value.crdDiscoveryStatus;
60
+ const estimatedNodes = value.estimatedNodes;
61
+ if (
62
+ !warnings ||
63
+ !hiddenKinds ||
64
+ (value.largeCluster !== undefined &&
65
+ typeof value.largeCluster !== "boolean") ||
66
+ (value.requiresNamespaceFilter !== undefined &&
67
+ typeof value.requiresNamespaceFilter !== "boolean") ||
68
+ (value.summaryMode !== undefined &&
69
+ typeof value.summaryMode !== "boolean") ||
70
+ (discovery !== undefined &&
71
+ discovery !== "idle" &&
72
+ discovery !== "discovering" &&
73
+ discovery !== "ready") ||
74
+ (estimatedNodes !== undefined &&
75
+ (typeof estimatedNodes !== "number" ||
76
+ !Number.isSafeInteger(estimatedNodes) ||
77
+ estimatedNodes < 0))
78
+ ) {
79
+ return undefined;
80
+ }
81
+ return {
82
+ warnings,
83
+ largeCluster: value.largeCluster === true,
84
+ hiddenKinds,
85
+ requiresNamespaceFilter: value.requiresNamespaceFilter === true,
86
+ crdDiscoveryStatus: discovery as
87
+ NonNullable<Topology["crdDiscoveryStatus"]> | undefined,
88
+ estimatedNodes,
89
+ summaryMode: value.summaryMode === true,
90
+ };
91
+ }
92
+ export function addTopologyLimitations(
93
+ builder: ProjectionBuilder,
94
+ source: InvestigationEvidenceSource,
95
+ partiality: TopologyPartiality,
96
+ ): void {
97
+ for (const warning of partiality.warnings) {
98
+ const normalized = warning.toLowerCase();
99
+ builder.limit(
100
+ source,
101
+ "Topology coverage",
102
+ warning,
103
+ normalized.includes("large graph") || normalized.includes("too large")
104
+ ? "truncated"
105
+ : "unknown",
106
+ );
107
+ }
108
+
109
+ const scaleDetails: string[] = [];
110
+ const estimate = partiality.estimatedNodes
111
+ ? ` (about ${partiality.estimatedNodes} estimated nodes)`
112
+ : "";
113
+ if (partiality.requiresNamespaceFilter) {
114
+ scaleDetails.push(
115
+ `The all-namespace topology was not built because the cluster is too large${estimate}; run a namespace-scoped topology search to collect a smaller graph.`,
116
+ );
117
+ } else if (partiality.largeCluster) {
118
+ scaleDetails.push(
119
+ `Large-cluster optimizations were active${estimate}; high-cardinality detail may be grouped.`,
120
+ );
121
+ }
122
+ if (partiality.hiddenKinds.length > 0) {
123
+ scaleDetails.push(
124
+ `Resource kinds omitted by the large-cluster optimization: ${partiality.hiddenKinds.join(", ")}.`,
125
+ );
126
+ }
127
+ if (partiality.summaryMode) {
128
+ scaleDetails.push(
129
+ "Summary mode collapsed individual Pods into workload or Service counts.",
130
+ );
131
+ }
132
+ if (scaleDetails.length > 0) {
133
+ builder.limit(
134
+ source,
135
+ "Topology scale",
136
+ scaleDetails.join(" "),
137
+ "truncated",
138
+ );
139
+ }
140
+
141
+ if (
142
+ partiality.crdDiscoveryStatus === "idle" ||
143
+ partiality.crdDiscoveryStatus === "discovering"
144
+ ) {
145
+ builder.limit(
146
+ source,
147
+ "Custom Resource topology",
148
+ partiality.crdDiscoveryStatus === "idle"
149
+ ? "Custom Resource discovery had not started when this topology was captured; Custom Resource nodes and relationships may be missing."
150
+ : "Custom Resource discovery was still in progress when this topology was captured; Custom Resource nodes and relationships may be missing.",
151
+ "unknown",
152
+ );
153
+ }
154
+ }
155
+ export function scopeFromArgs(source: InvestigationEvidenceSource): string {
156
+ if (!source.args) return "requested scope";
157
+ const args = record(parseJSON(source.args));
158
+ if (!args) return "requested scope";
159
+ const kind = nonEmptyString(args.kind) ? displayKind(args.kind) : undefined;
160
+ const namespace = nonEmptyString(args.namespace) ? args.namespace : undefined;
161
+ const name = nonEmptyString(args.name) ? args.name : undefined;
162
+ if (kind && name) {
163
+ return kind + " " + (namespace ? namespace + "/" : "") + name;
164
+ }
165
+ if (kind && namespace) return kind + " resources in " + namespace;
166
+ if (kind) return kind + " resources";
167
+ if (namespace && name) return namespace + "/" + name;
168
+ if (namespace) return "namespace " + namespace;
169
+ if (name) return name;
170
+ return "requested scope";
171
+ }
172
+ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
173
+ diagnose: "Workload diagnosis",
174
+ issues: "Issue scan",
175
+ get_resource: "Resource details",
176
+ list_resources: "Resource inventory",
177
+ get_events: "Kubernetes events",
178
+ get_pod_logs: "Container logs",
179
+ get_changes: "Recent changes",
180
+ get_neighborhood: "Relationships",
181
+ get_topology: "Topology",
182
+ get_workload_logs: "Workload logs",
183
+ get_prometheus_rules: "Alert rules",
184
+ get_helm_release: "Helm release",
185
+ get_subject_permissions: "Permissions",
186
+ query_prometheus: "Prometheus query",
187
+ };
188
+ export function investigationResultLabel(
189
+ source: InvestigationEvidenceSource,
190
+ ): string {
191
+ return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
192
+ }
193
+ export function resourceMatchesTarget(
194
+ target: InvestigationEvidenceTarget,
195
+ resource: {
196
+ kind: string;
197
+ group?: string;
198
+ namespace?: string;
199
+ name: string;
200
+ },
201
+ ): boolean {
202
+ const group = (resource.group ?? "").toLowerCase();
203
+ return (
204
+ group === target.group.toLowerCase() &&
205
+ kindToPluralWithGroup(resource.kind, group) ===
206
+ kindToPluralWithGroup(target.kind, group) &&
207
+ (resource.namespace ?? "") === (target.namespace ?? "") &&
208
+ resource.name === target.name
209
+ );
210
+ }
211
+ export function relevanceForResource(
212
+ builder: ProjectionBuilder,
213
+ resource: {
214
+ kind: string;
215
+ group?: string;
216
+ namespace?: string;
217
+ name: string;
218
+ },
219
+ ): InvestigationEvidenceRelevance {
220
+ return resourceMatchesTarget(builder.target, resource) ? "target" : "broader";
221
+ }
222
+ export function sourceArgsRelevance(
223
+ builder: ProjectionBuilder,
224
+ source: InvestigationEvidenceSource,
225
+ impliedKind?: string,
226
+ ): InvestigationEvidenceRelevance {
227
+ const args = record(source.args ? parseJSON(source.args) : undefined);
228
+ const kind = nonEmptyString(args?.kind) ? args.kind : impliedKind;
229
+ if (!kind || !nonEmptyString(args?.name)) return "broader";
230
+ return relevanceForResource(builder, {
231
+ kind,
232
+ // get_events/get_changes/issues cannot express an API group today. An
233
+ // omitted group is therefore unspecified, not proof that the caller meant
234
+ // the core API group. Use the known investigation target for that missing
235
+ // dimension; if a producer does provide a group, exact matching still
236
+ // applies (including an explicitly empty core group).
237
+ group: typeof args?.group === "string" ? args.group : builder.target.group,
238
+ namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
239
+ name: args.name,
240
+ });
241
+ }
242
+ export function evidenceTierForRelevance(
243
+ intended: InvestigationEvidenceTier,
244
+ relevance: InvestigationEvidenceRelevance,
245
+ ): InvestigationEvidenceTier {
246
+ return relevance === "broader" ? "context" : intended;
247
+ }
248
+ const DIAGNOSABLE_WORKLOAD_KINDS = new Set([
249
+ "pod",
250
+ "deployment",
251
+ "statefulset",
252
+ "daemonset",
253
+ "rollout",
254
+ ]);
255
+ export function isDiagnosableWorkloadKind(kind: string): boolean {
256
+ return DIAGNOSABLE_WORKLOAD_KINDS.has(kind.toLowerCase());
257
+ }
258
+ export function previousFromArgs(source: InvestigationEvidenceSource): boolean {
259
+ return (
260
+ record(source.args ? parseJSON(source.args) : undefined)?.previous === true
261
+ );
262
+ }
263
+ export class ProjectionBuilder {
264
+ readonly groups: InvestigationEvidenceGroup[] = [];
265
+ readonly sources: InvestigationEvidenceSource[] = [];
266
+ readonly limitations: InvestigationEvidenceLimitation[] = [];
267
+ readonly projectedSources = new Set<string>();
268
+ readonly checkedSources = new Set<string>();
269
+ readonly limitedSources = new Set<string>();
270
+ readonly semanticCoverageBySource = new Map<
271
+ string,
272
+ Set<InvestigationSemanticDomain>
273
+ >();
274
+
275
+ /**
276
+ * Pods a target-scoped diagnose bundle listed as the workload's own, by
277
+ * controller ownership. Membership for an agent's pod-level Prometheus
278
+ * query is proved against this set, never inferred from a name.
279
+ */
280
+ readonly establishedTargetPods = new Set<string>();
281
+
282
+ private readonly groupByIdentity = new Map<
283
+ string,
284
+ InvestigationEvidenceGroup
285
+ >();
286
+ private readonly limitationByIdentity = new Map<
287
+ string,
288
+ InvestigationEvidenceLimitation
289
+ >();
290
+
291
+ constructor(readonly target: InvestigationEvidenceTarget) {}
292
+
293
+ addSource(source: InvestigationEvidenceSource): void {
294
+ this.sources.push(source);
295
+ }
296
+
297
+ coverSemantic(
298
+ source: InvestigationEvidenceSource,
299
+ domain: InvestigationSemanticDomain,
300
+ ): void {
301
+ const covered = this.semanticCoverageBySource.get(source.id);
302
+ if (covered) {
303
+ covered.add(domain);
304
+ } else {
305
+ this.semanticCoverageBySource.set(source.id, new Set([domain]));
306
+ }
307
+ }
308
+
309
+ observe(
310
+ identity: string,
311
+ kind: InvestigationEvidenceKind,
312
+ source: InvestigationEvidenceSource,
313
+ observation: Omit<
314
+ InvestigationEvidenceObservation,
315
+ "source" | "revision" | "historical" | "changedFromPrevious" | "relevance"
316
+ > & { relevance: InvestigationEvidenceRelevance },
317
+ ): void {
318
+ // Logs and synthesized startup/crash evidence do not carry a stable object
319
+ // UID (and logs do not carry namespace in the producer row). Keep different
320
+ // proof scopes separate so a same-named sibling can never inherit stronger
321
+ // target provenance merely by colliding on its display identity.
322
+ const partitionByRelevance =
323
+ kind === "logs" ||
324
+ kind === "startup" ||
325
+ kind === "crash" ||
326
+ (kind === "receipt" && identity.startsWith("previous-log-absence:"));
327
+ const mapKey = groupKey(
328
+ kind,
329
+ identity,
330
+ partitionByRelevance
331
+ ? `${observation.relevance}\u0000${scopeFromArgs(source)}`
332
+ : undefined,
333
+ );
334
+ let group = this.groupByIdentity.get(mapKey);
335
+ const previousObservation = group?.observations.at(-1);
336
+ const changedFromPrevious = previousObservation
337
+ ? evidenceSemanticSnapshot(previousObservation) !==
338
+ evidenceSemanticSnapshot(observation)
339
+ : false;
340
+ const next: InvestigationEvidenceObservation = {
341
+ ...observation,
342
+ source,
343
+ revision: group ? group.observations.length + 1 : 1,
344
+ historical: false,
345
+ changedFromPrevious,
346
+ };
347
+ if (!group) {
348
+ group = {
349
+ id: `evidence-${kind}-${stableHash(mapKey)}`,
350
+ identity,
351
+ kind,
352
+ historical: false,
353
+ firstOrder: source.order,
354
+ observations: [],
355
+ latest: next,
356
+ chronologicalLatest: next,
357
+ };
358
+ this.groupByIdentity.set(mapKey, group);
359
+ this.groups.push(group);
360
+ }
361
+ group.observations.push(next);
362
+ group.chronologicalLatest = next;
363
+ const relevanceRank: Record<InvestigationEvidenceRelevance, number> = {
364
+ target: 0,
365
+ "producer-related": 1,
366
+ broader: 2,
367
+ };
368
+ // A broad inventory/read can re-observe an item whose relationship to the
369
+ // target was already established by a scoped producer. Keep the broad read
370
+ // in revision history, but never let weaker provenance replace the card's
371
+ // authoritative observation. Equal provenance still advances normally.
372
+ // An alert rule's relevance is derived from its live instances, so a later
373
+ // read of the same rule is a state transition, not weaker provenance.
374
+ if (
375
+ kind === "alerts" ||
376
+ relevanceRank[next.relevance] <= relevanceRank[group.latest.relevance]
377
+ ) {
378
+ group.latest = next;
379
+ }
380
+ this.projectedSources.add(source.id);
381
+ if (next.tier === "checked") this.checkedSources.add(source.id);
382
+ }
383
+
384
+ /** Relevance of the current card for an unpartitioned identity, if observed. */
385
+ latestRelevance(
386
+ kind: InvestigationEvidenceKind,
387
+ identity: string,
388
+ ): InvestigationEvidenceRelevance | undefined {
389
+ return this.groupByIdentity.get(groupKey(kind, identity))?.latest.relevance;
390
+ }
391
+
392
+ limit(
393
+ source: InvestigationEvidenceSource,
394
+ label: string,
395
+ message: string | undefined,
396
+ kind: DiagnosisEvidenceLimitationBase["kind"],
397
+ presentation?: InvestigationEvidenceLimitation["presentation"],
398
+ ): void {
399
+ if (!message?.trim()) return;
400
+ const normalized = message.trim();
401
+ const key = `${kind}\u0000${presentation ?? ""}\u0000${label}\u0000${normalized}`;
402
+ const existing = this.limitationByIdentity.get(key);
403
+ if (existing) {
404
+ if (!existing.sources.some((item) => item.id === source.id)) {
405
+ existing.sources.push(source);
406
+ }
407
+ } else {
408
+ const limitation: InvestigationEvidenceLimitation = {
409
+ source: label,
410
+ message: normalized,
411
+ kind,
412
+ ...(presentation ? { presentation } : {}),
413
+ firstOrder: source.order,
414
+ sources: [source],
415
+ };
416
+ this.limitationByIdentity.set(key, limitation);
417
+ this.limitations.push(limitation);
418
+ }
419
+ this.limitedSources.add(source.id);
420
+ }
421
+ }
422
+ /**
423
+ * The one place a group's map key is built. NUL separates the parts so no
424
+ * kind or identity text can collide with another kind's key.
425
+ */
426
+ function groupKey(
427
+ kind: InvestigationEvidenceKind,
428
+ identity: string,
429
+ partition?: string,
430
+ ): string {
431
+ return `${kind}\u0000${identity}${partition === undefined ? "" : `\u0000${partition}`}`;
432
+ }
433
+ export function evidenceSemanticSnapshot(
434
+ observation: Pick<
435
+ InvestigationEvidenceObservation,
436
+ "tone" | "title" | "summary" | "data"
437
+ >,
438
+ ): string {
439
+ if (observation.data.type === "resource") {
440
+ // Tool-specific context can change the summary/tone without changing the
441
+ // object. Keep that context in history, not in the resource-change signal.
442
+ return JSON.stringify({
443
+ type: "resource",
444
+ resource: observation.data.resource,
445
+ });
446
+ }
447
+ const data =
448
+ observation.data.type === "alerts"
449
+ ? {
450
+ type: observation.data.type,
451
+ // An instance's sampled value and activation time move on every
452
+ // evaluation; the finding is which instances exist and their state.
453
+ rule: observation.data.rule,
454
+ instances: observation.data.instances.map((instance) => ({
455
+ state: instance.state,
456
+ labels: instance.labels,
457
+ })),
458
+ annotations: observation.data.annotations,
459
+ }
460
+ : observation.data.type === "issue"
461
+ ? {
462
+ type: observation.data.type,
463
+ // These are the finding and details shown in the evidence card.
464
+ // Collection timestamps and detector bookkeeping do not change it.
465
+ issue: {
466
+ kind: observation.data.issue.kind,
467
+ group: observation.data.issue.group,
468
+ namespace: observation.data.issue.namespace,
469
+ name: observation.data.issue.name,
470
+ reason: observation.data.issue.reason,
471
+ severity: observation.data.issue.severity,
472
+ cause: observation.data.issue.cause,
473
+ message: observation.data.issue.message,
474
+ },
475
+ }
476
+ : observation.data;
477
+ return JSON.stringify({
478
+ tone: observation.tone,
479
+ title: observation.title,
480
+ summary: observation.summary,
481
+ data,
482
+ });
483
+ }
484
+ export function contextFrom(
485
+ value: unknown,
486
+ ): InvestigationResourceContext | undefined {
487
+ const candidate = record(value);
488
+ if (!candidate || !nonEmptyString(candidate.tier)) return undefined;
489
+ return candidate as unknown as InvestigationResourceContext;
490
+ }
491
+ export function addNarrowHint(
492
+ builder: ProjectionBuilder,
493
+ source: InvestigationEvidenceSource,
494
+ value: Record<string, unknown>,
495
+ ): void {
496
+ if (nonEmptyString(value.narrowHint)) {
497
+ const label = investigationResultLabel(source);
498
+ builder.limit(
499
+ source,
500
+ label,
501
+ label +
502
+ " returned part of the matching results to keep this investigation fast. More may exist.",
503
+ "truncated",
504
+ );
505
+ }
506
+ }
507
+ function addResourceContextLimitations(
508
+ builder: ProjectionBuilder,
509
+ source: InvestigationEvidenceSource,
510
+ context: InvestigationResourceContext | undefined,
511
+ ): void {
512
+ if (!context) return;
513
+ for (const value of Array.isArray(context.omitted) ? context.omitted : []) {
514
+ const omitted = record(value);
515
+ if (
516
+ !omitted ||
517
+ !nonEmptyString(omitted.field) ||
518
+ !nonEmptyString(omitted.reason)
519
+ )
520
+ continue;
521
+ builder.limit(
522
+ source,
523
+ omitted.field,
524
+ `Radar left out part of the resource context (${omitted.reason.replaceAll("_", " ")}).`,
525
+ omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
526
+ );
527
+ }
528
+ if (context.referencedBy?.truncated) {
529
+ const shown = context.referencedBy.items?.length ?? 0;
530
+ builder.limit(
531
+ source,
532
+ "Relationships",
533
+ `Radar returned ${shown} of ${context.referencedBy.total} referenced-by relationships.`,
534
+ "truncated",
535
+ );
536
+ }
537
+ if (context.appReferences?.staleSecretEnvTruncated) {
538
+ builder.limit(
539
+ source,
540
+ "Application references",
541
+ "Radar returned part of the stale Secret references.",
542
+ "truncated",
543
+ );
544
+ }
545
+ }
546
+ function addIssueLimitations(
547
+ builder: ProjectionBuilder,
548
+ source: InvestigationEvidenceSource,
549
+ value: Issue,
550
+ ): void {
551
+ if (value.members_truncated) {
552
+ builder.limit(
553
+ source,
554
+ `Radar Issue ${value.id}`,
555
+ "Radar returned part of the affected-resource list.",
556
+ "truncated",
557
+ );
558
+ }
559
+ }
560
+ function resourceObservationSummary(
561
+ resource: InvestigationKubernetesResource,
562
+ context: InvestigationResourceContext | undefined,
563
+ warnings: string[],
564
+ gitOps?: InvestigationGitOpsDiagnosis,
565
+ detailedIssueShown = false,
566
+ ): string | undefined {
567
+ if (!detailedIssueShown && context?.issueSummary?.topReason) {
568
+ return context.issueSummary.topReason;
569
+ }
570
+ if (gitOps?.health) return `Health ${gitOps.health}`;
571
+ if (gitOps?.ready) return `Ready ${gitOps.ready}`;
572
+ if (gitOps?.sync) return `Sync ${gitOps.sync}`;
573
+ if (gitOps?.suspended) return "Reconciliation suspended";
574
+ const replicas = context?.workloadSummary?.replicas;
575
+ const adverseScaler = adverseScalerStates(context)[0];
576
+ if (replicas?.desired !== undefined) {
577
+ const desired = replicas.desired;
578
+ const ready = replicas.ready ?? 0;
579
+ const readiness = `${ready}/${desired} replicas ready`;
580
+ return adverseScaler
581
+ ? `${readiness} · HPA: ${hpaStateLabel(adverseScaler)}`
582
+ : readiness;
583
+ }
584
+ if (adverseScaler) return `HPA: ${hpaStateLabel(adverseScaler)}`;
585
+ if (context?.statusSummary?.phase) return context.statusSummary.phase;
586
+ if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
587
+ return (
588
+ investigationResourceEvidenceSummary(resource) ||
589
+ warnings[0] ||
590
+ resource.metadata.namespace
591
+ );
592
+ }
593
+ // A scaler that cannot act (no metrics, cannot read the target) or is pinned
594
+ // at its ceiling is a captured Radar fact about the workload, so it lifts the
595
+ // card the way an adverse condition does. Ordinary scale-ups and scale-downs
596
+ // are the autoscaler working and stay as detail.
597
+ function adverseScalerStates(
598
+ context: InvestigationResourceContext | undefined,
599
+ ): HPADiagnosisState[] {
600
+ return (context?.scaledBy ?? []).flatMap((scaler) => {
601
+ const state = scaler.hpaSummary?.state;
602
+ if (!state) return [];
603
+ return hpaStateLevel(state) === "unhealthy" || state === "limited_max"
604
+ ? [state]
605
+ : [];
606
+ });
607
+ }
608
+ export function addResourceObservation(
609
+ builder: ProjectionBuilder,
610
+ source: InvestigationEvidenceSource,
611
+ resource: InvestigationKubernetesResource,
612
+ context: InvestigationResourceContext | undefined,
613
+ warnings: string[],
614
+ gitOpsDiagnosis: InvestigationGitOpsDiagnosis | undefined,
615
+ hasDetailedCriticalIssue: boolean,
616
+ relevance: InvestigationEvidenceRelevance,
617
+ ): void {
618
+ const issueSummary = context?.issueSummary;
619
+ const severity = issueSummary?.highestSeverity ?? "";
620
+ const critical = severity.toLowerCase() === "critical";
621
+ const hasLiveIssue = (issueSummary?.count ?? 0) > 0;
622
+ const replicas = context?.workloadSummary?.replicas;
623
+ const desired = replicas?.desired;
624
+ const ready = replicas ? (replicas.ready ?? 0) : undefined;
625
+ const available = replicas ? (replicas.available ?? 0) : undefined;
626
+ const replicaShortfall =
627
+ desired !== undefined &&
628
+ desired > 0 &&
629
+ ((ready !== undefined && ready < desired) ||
630
+ (available !== undefined && available < desired) ||
631
+ (replicas?.unavailable ?? 0) > 0);
632
+ const adverseCondition = (context?.statusSummary?.conditions ?? []).some(
633
+ (condition) => defaultConditionTone(condition) === "fail",
634
+ );
635
+ const gitOpsAdverse = Boolean(
636
+ gitOpsDiagnosis &&
637
+ (gitOpsDiagnosis.health?.toLowerCase() === "degraded" ||
638
+ gitOpsDiagnosis.health?.toLowerCase() === "missing" ||
639
+ gitOpsDiagnosis.sync?.toLowerCase() === "outofsync" ||
640
+ gitOpsDiagnosis.ready?.toLowerCase().startsWith("false") ||
641
+ ["failed", "error"].includes(
642
+ gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
643
+ )),
644
+ );
645
+ const hasAdverseState =
646
+ replicaShortfall ||
647
+ adverseCondition ||
648
+ gitOpsAdverse ||
649
+ adverseScalerStates(context).length > 0;
650
+ const intendedTier: InvestigationEvidenceTier =
651
+ critical && !hasDetailedCriticalIssue
652
+ ? "key"
653
+ : hasLiveIssue
654
+ ? "supporting"
655
+ : hasAdverseState
656
+ ? "supporting"
657
+ : "context";
658
+ const tier = evidenceTierForRelevance(intendedTier, relevance);
659
+ const tone = hasLiveIssue
660
+ ? diagnosisSeverityTone(severity)
661
+ : hasAdverseState
662
+ ? "warning"
663
+ : warnings.length > 0
664
+ ? "warning"
665
+ : "neutral";
666
+ const namespace = resource.metadata.namespace;
667
+ const identity = `${resource.apiVersion}:${resource.kind}:${namespace ?? ""}:${resource.metadata.name}`;
668
+ builder.observe(identity, "resource", source, {
669
+ tier,
670
+ relevance,
671
+ tone,
672
+ title: `${resource.kind} ${namespace ? `${namespace}/` : ""}${resource.metadata.name}`,
673
+ summary: resourceObservationSummary(
674
+ resource,
675
+ context,
676
+ warnings,
677
+ gitOpsDiagnosis,
678
+ hasDetailedCriticalIssue,
679
+ ),
680
+ data: {
681
+ type: "resource",
682
+ resource,
683
+ resourceContext: context,
684
+ warnings,
685
+ gitOpsDiagnosis,
686
+ },
687
+ });
688
+ addResourceContextLimitations(builder, source, context);
689
+ }
690
+ export function addIssueObservation(
691
+ builder: ProjectionBuilder,
692
+ source: InvestigationEvidenceSource,
693
+ value: Issue,
694
+ producerRelevance: InvestigationEvidenceRelevance = "broader",
695
+ pods?: string[],
696
+ ): void {
697
+ const matchesTarget = resourceMatchesTarget(builder.target, {
698
+ kind: value.kind,
699
+ group: value.group ?? "",
700
+ namespace: value.namespace,
701
+ name: value.name,
702
+ });
703
+ const relevance = matchesTarget ? "target" : producerRelevance;
704
+ builder.observe(`issue:${value.id}`, "issue", source, {
705
+ tier:
706
+ relevance === "broader"
707
+ ? "context"
708
+ : value.severity === "critical"
709
+ ? "key"
710
+ : "supporting",
711
+ tone: diagnosisSeverityTone(value.severity),
712
+ relevance,
713
+ title: value.reason,
714
+ summary: value.cause || value.message,
715
+ data: {
716
+ type: "issue",
717
+ issue: value,
718
+ relevance,
719
+ ...(pods && pods.length > 0 ? { pods } : {}),
720
+ },
721
+ });
722
+ addIssueLimitations(builder, source, value);
723
+ }
724
+ export function addEvents(
725
+ builder: ProjectionBuilder,
726
+ source: InvestigationEvidenceSource,
727
+ values: InvestigationEventEvidence[],
728
+ identity: string,
729
+ complete = true,
730
+ emptyIsAuthoritative = false,
731
+ relevance: InvestigationEvidenceRelevance = "broader",
732
+ emptyReceipt: { title: string; message?: string } = {
733
+ title: "No warning events",
734
+ },
735
+ ): void {
736
+ const scope = scopeFromArgs(source);
737
+ if (values.length === 0) {
738
+ if (!source.confirmedSuccess || !complete) return;
739
+ if (!emptyIsAuthoritative) {
740
+ builder.limit(
741
+ source,
742
+ "Events",
743
+ "No events were returned. This result does not establish that no events occurred.",
744
+ "unknown",
745
+ );
746
+ return;
747
+ }
748
+ builder.observe(identity, "receipt", source, {
749
+ tier: evidenceTierForRelevance("checked", relevance),
750
+ relevance,
751
+ tone: "neutral",
752
+ title: emptyReceipt.title,
753
+ summary: scope,
754
+ data: {
755
+ type: "receipt",
756
+ checked: "events",
757
+ scope,
758
+ message: emptyReceipt.message,
759
+ },
760
+ });
761
+ return;
762
+ }
763
+ const lead = leadEvent(values);
764
+ builder.observe(identity, "events", source, {
765
+ tier: evidenceTierForRelevance(
766
+ values.some((item) => item.type.toLowerCase() === "warning")
767
+ ? "supporting"
768
+ : "context",
769
+ relevance,
770
+ ),
771
+ relevance,
772
+ tone: values.some((item) => item.type.toLowerCase() === "warning")
773
+ ? "warning"
774
+ : "info",
775
+ title: "Kubernetes events",
776
+ summary: `${lead.reason}: ${lead.message}${
777
+ values.length > 1 ? ` · ${values.length} event groups` : ""
778
+ } · ${scope}`,
779
+ data: { type: "events", events: values, scope },
780
+ });
781
+ }
782
+ /**
783
+ * The newest warning, else the newest event. The card lead must be what
784
+ * happened, not how many groups the producer returned.
785
+ */
786
+ function leadEvent(
787
+ values: InvestigationEventEvidence[],
788
+ ): InvestigationEventEvidence {
789
+ const newest = (items: InvestigationEventEvidence[]) =>
790
+ items.reduce((best, item) =>
791
+ Date.parse(item.lastTimestamp) > Date.parse(best.lastTimestamp)
792
+ ? item
793
+ : best,
794
+ );
795
+ const warnings = values.filter(
796
+ (item) => item.type.toLowerCase() === "warning",
797
+ );
798
+ return newest(warnings.length > 0 ? warnings : values);
799
+ }
800
+ export function addChanges(
801
+ builder: ProjectionBuilder,
802
+ source: InvestigationEvidenceSource,
803
+ values: IssueRecentChange[],
804
+ identity: string,
805
+ changeContext?: DiagnosisChangeContext,
806
+ complete = true,
807
+ emptyIsAuthoritative = false,
808
+ relevance: InvestigationEvidenceRelevance = "broader",
809
+ subject?: { kind?: string; namespace?: string; name: string },
810
+ window?: string,
811
+ ): void {
812
+ const scope = scopeFromArgs(source);
813
+ if (values.length === 0 && !changeContext?.changed) {
814
+ if (!source.confirmedSuccess || !complete) return;
815
+ if (!emptyIsAuthoritative) {
816
+ builder.limit(
817
+ source,
818
+ "Recent changes",
819
+ `No changes were returned for ${scope}. This result does not establish a complete change history.`,
820
+ "unknown",
821
+ "history",
822
+ );
823
+ return;
824
+ }
825
+ builder.observe(identity, "receipt", source, {
826
+ tier: evidenceTierForRelevance("checked", relevance),
827
+ relevance,
828
+ tone: "neutral",
829
+ title: "No recorded changes in this window",
830
+ summary: scope,
831
+ data: {
832
+ type: "receipt",
833
+ checked: "changes",
834
+ scope,
835
+ },
836
+ });
837
+ return;
838
+ }
839
+ builder.observe(identity, "changes", source, {
840
+ // A producer-correlated change supports the diagnosis. A merely recent
841
+ // edit is chronology/context and must not imply causality by proximity.
842
+ tier: evidenceTierForRelevance(
843
+ changeContext?.changed ? "supporting" : "context",
844
+ relevance,
845
+ ),
846
+ relevance,
847
+ tone: "info",
848
+ title: `Recent changes${window ? ` · last ${window}` : ""}`,
849
+ summary: changeContext?.changed
850
+ ? changeContext.what === "The workload's Pod template changed" &&
851
+ changeContext.when
852
+ ? `Pod template changed; newest ReplicaSet created ${changeContext.when} ago`
853
+ : `${changeContext.what || "A workload change was observed"}${changeContext.when ? ` · ${changeContext.when} ago` : ""}`
854
+ : `${values.length} change${values.length === 1 ? "" : "s"} · ${scope}`,
855
+ data: {
856
+ type: "changes",
857
+ changes: values,
858
+ scope,
859
+ changeContext,
860
+ subject,
861
+ },
862
+ });
863
+ }
864
+ export function changesSubjectFromArgs(
865
+ source: InvestigationEvidenceSource,
866
+ ): { kind?: string; namespace?: string; name: string } | undefined {
867
+ const args = record(source.args ? parseJSON(source.args) : undefined);
868
+ if (!nonEmptyString(args?.name)) return undefined;
869
+ return {
870
+ ...(nonEmptyString(args.kind) ? { kind: args.kind } : {}),
871
+ ...(nonEmptyString(args.namespace) ? { namespace: args.namespace } : {}),
872
+ name: args.name,
873
+ };
874
+ }
875
+ export function addLogs(
876
+ builder: ProjectionBuilder,
877
+ source: InvestigationEvidenceSource,
878
+ value: DiagnosisPodLogEntry,
879
+ previous: boolean,
880
+ warnings: string[] = [],
881
+ relevance: InvestigationEvidenceRelevance = "broader",
882
+ namespace?: string,
883
+ ): void {
884
+ const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
885
+ const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
886
+ const normalizedError = value.error ? stripAnsi(value.error) : undefined;
887
+ if (lines.length === 0) {
888
+ builder.limit(
889
+ source,
890
+ `${value.pod} / ${value.container}`,
891
+ value.error ||
892
+ "No log lines were available. This does not mean the container is healthy.",
893
+ value.error ? "error" : "unknown",
894
+ );
895
+ return;
896
+ }
897
+ // A producer-filtered excerpt is a candidate, not proof that its contents are
898
+ // adverse. Query strings and routine request logs can contain words such as
899
+ // "warning" or "critical" and still be successful traffic. Promote only an
900
+ // explicit failure/error signature; keep benign excerpts available in Context.
901
+ const diagnosticSignal = [...lines, ...normalizedWarnings].some((line) =>
902
+ /(?:\b(?:error|exception|failed|failure|fatal|panic|crash|denied|refused|timeout|timed out|unhealthy|oomkill|back-?off)\b|\s5\d\d(?:\s|$))/i.test(
903
+ line,
904
+ ),
905
+ );
906
+ const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
907
+ const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
908
+ builder.observe(identity, "logs", source, {
909
+ // FilterLogs' raw-tail fallback is useful provenance, but the producer did
910
+ // not select it as diagnostic signal. Keep it in Context; only filtered
911
+ // excerpts are Supporting evidence.
912
+ tier: evidenceTierForRelevance(
913
+ selectedEvidence ? "supporting" : "context",
914
+ relevance,
915
+ ),
916
+ relevance,
917
+ tone: selectedEvidence || normalizedError ? "warning" : "neutral",
918
+ title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
919
+ summary:
920
+ lines.length > 0
921
+ ? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
922
+ : "No log lines available",
923
+ data: {
924
+ type: "logs",
925
+ pod: value.pod,
926
+ container: value.container,
927
+ namespace,
928
+ previous,
929
+ logs: value.logs ? { ...value.logs, lines } : undefined,
930
+ warnings: normalizedWarnings,
931
+ error: normalizedError,
932
+ },
933
+ });
934
+ if (normalizedError) {
935
+ builder.limit(
936
+ source,
937
+ `${value.pod} / ${value.container}`,
938
+ normalizedError,
939
+ "error",
940
+ );
941
+ }
942
+ }
943
+ export function invalidPayload(
944
+ builder: ProjectionBuilder,
945
+ source: InvestigationEvidenceSource,
946
+ section = investigationResultLabel(source),
947
+ ): void {
948
+ builder.limit(
949
+ source,
950
+ section,
951
+ "Radar couldn't summarize this investigation step. Review it in Activity.",
952
+ "unknown",
953
+ );
954
+ }