@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
@@ -16,11 +16,13 @@ import {
16
16
  investigationEvidenceInputsEqual,
17
17
  investigationEvidenceCoverageLimited,
18
18
  investigationEvidenceConflictsWithHealthy,
19
+ investigationHealthConflictExplainedBy,
19
20
  investigationEndedBeforeConclusion,
20
21
  investigationHistoryUnavailablePresentation,
21
22
  investigationInteractionsBlocked,
22
23
  investigationIsReadOnly,
23
24
  investigationPaneCenteredScrollTop,
25
+ investigationLiveCaseTurnIndex,
24
26
  } from "./investigationState";
25
27
 
26
28
  describe("investigation terminal presentation", () => {
@@ -289,6 +291,127 @@ describe("investigation evidence projection stability", () => {
289
291
  }),
290
292
  ).toBe(false);
291
293
  });
294
+ it("names the explained cards only when the agent addressed every conflict", () => {
295
+ const group = (id: string, title: string, tone = "warning") => ({
296
+ id,
297
+ identity: `issue:${id}`,
298
+ kind: "issue",
299
+ historical: false,
300
+ latest: {
301
+ tier: "supporting" as const,
302
+ relevance: "target" as const,
303
+ tone,
304
+ title,
305
+ },
306
+ });
307
+ const projection = {
308
+ groups: [group("g1", "CrashLoopBackOff"), group("g2", "OOMKilled")],
309
+ };
310
+ const note = (
311
+ groupId: string,
312
+ role: string,
313
+ placement: "card" | "revision" | "source" = "card",
314
+ claim = "the agent's reading of this card",
315
+ ) => ({ role, placement, claim, groupId });
316
+
317
+ expect(investigationHealthConflictExplainedBy(projection, undefined)).toBe(
318
+ null,
319
+ );
320
+ expect(
321
+ investigationHealthConflictExplainedBy(projection, [
322
+ note("g1", "benign"),
323
+ ]),
324
+ ).toBe(null);
325
+ expect(
326
+ investigationHealthConflictExplainedBy(projection, [
327
+ note("g1", "benign"),
328
+ note("g2", "cause"),
329
+ ]),
330
+ ).toBe(null);
331
+ expect(
332
+ investigationHealthConflictExplainedBy(projection, [
333
+ note("g1", "benign"),
334
+ note("g2", "benign", "source"),
335
+ ]),
336
+ ).toBe(null);
337
+ // A note pinned to a superseded read addressed the card as it was then,
338
+ // not the card the banner is qualifying now.
339
+ expect(
340
+ investigationHealthConflictExplainedBy(projection, [
341
+ note("g1", "benign"),
342
+ note("g2", "benign", "revision"),
343
+ ]),
344
+ ).toBe(null);
345
+ // An empty claim renders nothing, so the banner would be pointing at a
346
+ // note the reader cannot find.
347
+ expect(
348
+ investigationHealthConflictExplainedBy(projection, [
349
+ note("g1", "benign"),
350
+ note("g2", "benign", "card", " "),
351
+ ]),
352
+ ).toBe(null);
353
+ // "Excludes some hypothesis" and "is peripheral here" are both true of a
354
+ // problem that is still live, so neither reconciles a healthy verdict.
355
+ expect(
356
+ investigationHealthConflictExplainedBy(projection, [
357
+ note("g1", "benign"),
358
+ note("g2", "rules_out"),
359
+ ]),
360
+ ).toBe(null);
361
+ // A note on a superseded revision of the card is not an explanation of
362
+ // the card as it now stands.
363
+ expect(
364
+ investigationHealthConflictExplainedBy(projection, [
365
+ note("g1", "benign"),
366
+ note("g2", "demoted"),
367
+ ]),
368
+ ).toBe(null);
369
+ expect(
370
+ investigationHealthConflictExplainedBy(projection, [
371
+ note("g1", "benign"),
372
+ note("g2", "benign"),
373
+ ]),
374
+ ).toEqual(["CrashLoopBackOff", "OOMKilled"]);
375
+ // The agent contradicting itself on the same card is not an explanation.
376
+ expect(
377
+ investigationHealthConflictExplainedBy(projection, [
378
+ note("g1", "benign"),
379
+ note("g2", "benign"),
380
+ note("g2", "cause"),
381
+ ]),
382
+ ).toBe(null);
383
+ expect(
384
+ investigationHealthConflictExplainedBy({ groups: [] }, [
385
+ note("g1", "benign"),
386
+ ]),
387
+ ).toBe(null);
388
+
389
+ // One log stream read through two calls lands in two groups sharing an
390
+ // identity, so the partition must not decide whether the agent addressed
391
+ // it: a note on either twin counts for the conflict recorded on the other.
392
+ const stream = (id: string) => ({
393
+ id,
394
+ identity: "logs:previous:api-abc:api",
395
+ kind: "logs",
396
+ historical: false,
397
+ latest: {
398
+ tier: "supporting" as const,
399
+ relevance: "target" as const,
400
+ tone: "warning",
401
+ title: "Previous logs · api-abc / api",
402
+ },
403
+ });
404
+ expect(
405
+ investigationHealthConflictExplainedBy(
406
+ { groups: [stream("scope-a"), stream("scope-b")] },
407
+ [note("scope-b", "benign")],
408
+ ),
409
+ ).toEqual([
410
+ "Previous logs · api-abc / api",
411
+ "Previous logs · api-abc / api",
412
+ ]);
413
+ });
414
+
292
415
  it("ignores reasoning-only transcript updates while retaining completed tool identity", () => {
293
416
  const completedTool = {
294
417
  kind: "tool" as const,
@@ -693,3 +816,202 @@ describe("investigation action gating", () => {
693
816
  ).toBe(true);
694
817
  });
695
818
  });
819
+
820
+ describe("investigationLiveCaseTurnIndex", () => {
821
+ const linkedItem = {
822
+ status: "linked" as const,
823
+ ref: "ev_x",
824
+ role: "context" as const,
825
+ claim: "c",
826
+ };
827
+ const assessment = {
828
+ status: "done" as const,
829
+ diagnosis: { healthy: true, rootCause: "", report: "", remediation: [] },
830
+ };
831
+ it("keeps the assessment when no later turn cites evidence", () => {
832
+ const answer = {
833
+ status: "done" as const,
834
+ question: "why?",
835
+ diagnosis: { rootCause: "", report: "because", remediation: [] },
836
+ };
837
+ expect(investigationLiveCaseTurnIndex([assessment, answer], 0)).toBe(0);
838
+ });
839
+ it("moves to an answer turn that carries a bound case or linked root-cause refs", () => {
840
+ const cited = {
841
+ status: "done" as const,
842
+ question: "chart it and cite it",
843
+ diagnosis: {
844
+ rootCause: "",
845
+ report: "",
846
+ remediation: [],
847
+ evidence: [linkedItem],
848
+ },
849
+ };
850
+ expect(investigationLiveCaseTurnIndex([assessment, cited], 0)).toBe(1);
851
+ const legacy = {
852
+ status: "done" as const,
853
+ question: "what broke?",
854
+ diagnosis: {
855
+ rootCause: "x",
856
+ report: "",
857
+ remediation: [],
858
+ rootCauseEvidence: { status: "linked" as const, refs: ["ev_x"] },
859
+ },
860
+ };
861
+ expect(investigationLiveCaseTurnIndex([assessment, legacy], 0)).toBe(1);
862
+ const unlinkedOnly = {
863
+ ...cited,
864
+ diagnosis: {
865
+ ...cited.diagnosis,
866
+ evidence: [{ status: "unlinked" as const }],
867
+ },
868
+ };
869
+ expect(investigationLiveCaseTurnIndex([assessment, unlinkedOnly], 0)).toBe(
870
+ 0,
871
+ );
872
+ });
873
+ it("ignores apply, explanation, running, and superseded turns", () => {
874
+ const cited = {
875
+ status: "done" as const,
876
+ question: "cite",
877
+ diagnosis: {
878
+ rootCause: "",
879
+ report: "",
880
+ remediation: [],
881
+ evidence: [linkedItem],
882
+ },
883
+ };
884
+ expect(
885
+ investigationLiveCaseTurnIndex(
886
+ [
887
+ assessment,
888
+ { ...cited, apply: true },
889
+ { ...cited, explainAssessment: 2 },
890
+ { ...cited, status: "running" as const },
891
+ ],
892
+ 0,
893
+ ),
894
+ ).toBe(0);
895
+ // A newer assessment after the cited answer is the current one.
896
+ expect(
897
+ investigationLiveCaseTurnIndex([assessment, cited, assessment], 2),
898
+ ).toBe(2);
899
+ });
900
+ });
901
+
902
+ describe("adverse evidence and the healthy-conflict banner", () => {
903
+ const group = (kind: string, tone: string) => ({
904
+ id: `${kind}-1`,
905
+ kind,
906
+ historical: false,
907
+ latest: { tier: "supporting" as const, relevance: "target" as const, tone },
908
+ });
909
+
910
+ it("counts every kind Radar can capture a live problem in", () => {
911
+ // The round that added alerts and Helm cards forgot this rule, so a
912
+ // firing alert naming the workload sat under a green banner.
913
+ for (const kind of [
914
+ "issue",
915
+ "startup",
916
+ "crash",
917
+ "resource",
918
+ "logs",
919
+ "events",
920
+ "dns",
921
+ "network",
922
+ "alerts",
923
+ "helm",
924
+ ]) {
925
+ expect(
926
+ investigationEvidenceConflictsWithHealthy({
927
+ groups: [group(kind, "error")],
928
+ }),
929
+ kind,
930
+ ).toBe(true);
931
+ }
932
+ // A change, a receipt, a chart or a permission grant describe the world,
933
+ // not a problem in it.
934
+ for (const kind of [
935
+ "changes",
936
+ "receipt",
937
+ "metrics",
938
+ "permissions",
939
+ "relationships",
940
+ "topology",
941
+ "inventory",
942
+ ]) {
943
+ expect(
944
+ investigationEvidenceConflictsWithHealthy({
945
+ groups: [group(kind, "error")],
946
+ }),
947
+ kind,
948
+ ).toBe(false);
949
+ }
950
+ });
951
+
952
+ // `observe` splits one log stream read by two calls into two groups, so the
953
+ // explained check deliberately matches a note across groups sharing a kind
954
+ // and identity. That crossing must not reach forward in time: a later turn
955
+ // reading the same stream can report a different failure, and the
956
+ // assessment never saw it.
957
+ it("refuses a note as explaining a reading captured after the assessment", () => {
958
+ const logGroup = (id: string, turnIndex: number) => ({
959
+ id,
960
+ // Same stream, different calls: what the twin lookup exists for.
961
+ identity: "logs:current:api-7f6-a:api",
962
+ kind: "logs",
963
+ historical: false,
964
+ latest: {
965
+ tier: "supporting" as const,
966
+ relevance: "target" as const,
967
+ tone: "warning",
968
+ title: "Error logs",
969
+ source: { turnIndex },
970
+ },
971
+ });
972
+ const note = (groupId: string, turnIndex: number) => ({
973
+ role: "benign",
974
+ placement: "card" as const,
975
+ claim: "the warmup error clears once the cache fills",
976
+ groupId,
977
+ source: { turnIndex },
978
+ });
979
+
980
+ // Two calls in the assessment's own turn read the same stream. The note
981
+ // sits on one group and explains its twin as well.
982
+ expect(
983
+ investigationHealthConflictExplainedBy(
984
+ { groups: [logGroup("g-a", 0), logGroup("g-b", 0)] },
985
+ [note("g-a", 0)],
986
+ ),
987
+ ).toEqual(["Error logs", "Error logs"]);
988
+
989
+ // A later turn reads the same stream and reports a different failure.
990
+ // The turn-0 note was about something else and must not soften it.
991
+ expect(
992
+ investigationHealthConflictExplainedBy(
993
+ { groups: [logGroup("g-a", 0), logGroup("g-c", 1)] },
994
+ [note("g-a", 0)],
995
+ ),
996
+ ).toBe(null);
997
+ });
998
+
999
+ it("counts the intermediate alert tone, which carries every high severity", () => {
1000
+ for (const tone of ["warning", "alert", "error"]) {
1001
+ expect(
1002
+ investigationEvidenceConflictsWithHealthy({
1003
+ groups: [group("issue", tone)],
1004
+ }),
1005
+ tone,
1006
+ ).toBe(true);
1007
+ }
1008
+ for (const tone of ["neutral", "info", "success"]) {
1009
+ expect(
1010
+ investigationEvidenceConflictsWithHealthy({
1011
+ groups: [group("issue", tone)],
1012
+ }),
1013
+ tone,
1014
+ ).toBe(false);
1015
+ }
1016
+ });
1017
+ });
@@ -1,5 +1,6 @@
1
1
  // Pure presentation decisions over the durable transcript and evidence projection.
2
2
  // Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
3
+ import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
3
4
  import {
4
5
  DiagnoseError,
5
6
  type DiagnoseStreamEvent,
@@ -271,6 +272,35 @@ export function investigationEvidenceInputsEqual(
271
272
  return true;
272
273
  }
273
274
 
275
+ /**
276
+ * The turn whose agent case annotates the Evidence pane. A follow-up answer
277
+ * that cites evidence ("chart this and cite it") must reach Findings, so the
278
+ * newest completed non-apply, non-explanation turn carrying a bound case or
279
+ * linked root-cause refs wins; earlier turns keep their case read-only.
280
+ */
281
+ export function investigationLiveCaseTurnIndex(
282
+ turns: readonly Pick<
283
+ Turn,
284
+ "status" | "apply" | "explainAssessment" | "diagnosis"
285
+ >[],
286
+ currentAssessmentIdx: number,
287
+ ): number {
288
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
289
+ const turn = turns[index];
290
+ if (turn.status !== "done" || turn.apply || turn.explainAssessment)
291
+ continue;
292
+ const diagnosis = turn.diagnosis;
293
+ if (!diagnosis) continue;
294
+ if (
295
+ diagnosis.evidence?.some((item) => item.status === "linked") ||
296
+ (diagnosis.rootCause && diagnosis.rootCauseEvidence?.status === "linked")
297
+ ) {
298
+ return Math.max(index, currentAssessmentIdx);
299
+ }
300
+ }
301
+ return currentAssessmentIdx;
302
+ }
303
+
274
304
  export function investigationEvidenceCoverageLimited(
275
305
  projection: Pick<
276
306
  InvestigationEvidenceProjection,
@@ -306,44 +336,136 @@ export function investigationEvidenceCoverageLimited(
306
336
  );
307
337
  }
308
338
 
309
- const HEALTH_CONFLICT_EVIDENCE_KINDS = new Set([
310
- "issue",
311
- "startup",
312
- "crash",
313
- "resource",
314
- "logs",
315
- "events",
316
- "dns",
317
- "network",
318
- ]);
319
-
320
339
  /**
321
340
  * A model-authored all-clear must not overrule active adverse Radar evidence.
322
341
  * Context-only warnings (for example a Helm ownership advisory) and ordinary
323
342
  * recent changes are deliberately excluded: they are useful context, not proof
324
343
  * that the investigated resource is unhealthy.
325
344
  */
326
- export function investigationEvidenceConflictsWithHealthy(projection: {
327
- groups: readonly {
328
- historical: boolean;
329
- kind: string;
330
- latest: {
331
- relevance: "target" | "producer-related" | "broader";
332
- tier: "key" | "supporting" | "context" | "checked";
333
- tone: string;
334
- };
335
- }[];
336
- }): boolean {
337
- return projection.groups.some(
345
+ interface HealthConflictGroup {
346
+ id?: string;
347
+ /** Display identity; logs partition into several groups sharing one. */
348
+ identity?: string;
349
+ historical: boolean;
350
+ kind: string;
351
+ latest: {
352
+ relevance: "target" | "producer-related" | "broader";
353
+ tier: "key" | "supporting" | "context" | "checked";
354
+ tone: string;
355
+ title?: string;
356
+ /** Which turn captured this reading; a note cannot explain a later one. */
357
+ source?: { turnIndex: number };
358
+ };
359
+ }
360
+
361
+ export function investigationHealthConflictGroups<
362
+ G extends HealthConflictGroup,
363
+ >(projection: { groups: readonly G[] }): G[] {
364
+ return projection.groups.filter(
338
365
  (group) =>
339
366
  !group.historical &&
340
367
  group.latest.relevance !== "broader" &&
341
368
  (group.latest.tier === "key" || group.latest.tier === "supporting") &&
342
- (group.latest.tone === "warning" || group.latest.tone === "error") &&
343
- HEALTH_CONFLICT_EVIDENCE_KINDS.has(group.kind),
369
+ // Radar's adverse tones run warning → alert → error; "high" severity
370
+ // from the Go side lands on alert, so leaving it out silently excused
371
+ // every high-severity finding.
372
+ (group.latest.tone === "warning" ||
373
+ group.latest.tone === "alert" ||
374
+ group.latest.tone === "error") &&
375
+ evidenceKindIsAdverse(group.kind),
344
376
  );
345
377
  }
346
378
 
379
+ export function investigationEvidenceConflictsWithHealthy(projection: {
380
+ groups: readonly HealthConflictGroup[];
381
+ }): boolean {
382
+ return investigationHealthConflictGroups(projection).length > 0;
383
+ }
384
+
385
+ /**
386
+ * The banner over a healthy verdict points at adverse Radar evidence. When the
387
+ * agent placed a "not a problem" note on every such card, the evidence is
388
+ * still shown but the reader has the agent's reason next to it, so the banner
389
+ * can say that instead of accusing evidence the agent addressed.
390
+ * Returns the titles of the explained cards, or null when any conflict is
391
+ * unexplained (or there is no conflict).
392
+ */
393
+ export function investigationHealthConflictExplainedBy(
394
+ projection: { groups: readonly HealthConflictGroup[] },
395
+ caseItems:
396
+ | readonly {
397
+ role: string;
398
+ placement: "card" | "revision" | "source";
399
+ claim: string;
400
+ groupId?: string;
401
+ source?: { turnIndex: number };
402
+ }[]
403
+ | undefined,
404
+ ): string[] | null {
405
+ const conflicting = investigationHealthConflictGroups(projection);
406
+ if (conflicting.length === 0 || !caseItems) return null;
407
+ // `observe` keeps a log stream read through two different calls in separate
408
+ // groups, so a same-named pod from another workload's call cannot inherit
409
+ // the target's relevance. That partition must not also decide whether the
410
+ // agent addressed the stream: it explained one card, and the conflict is
411
+ // recorded on its twin. Group ids that share an identity are the same
412
+ // underlying observation for this question.
413
+ const twins = new Map<string, Set<string>>();
414
+ for (const group of projection.groups) {
415
+ if (!group.id || !group.identity) continue;
416
+ const key = `${group.kind}\u0000${group.identity}`;
417
+ const ids = twins.get(key) ?? new Set<string>();
418
+ ids.add(group.id);
419
+ twins.set(key, ids);
420
+ }
421
+ const titles: string[] = [];
422
+ for (const group of conflicting) {
423
+ const sameStream =
424
+ (group.identity && twins.get(`${group.kind}\u0000${group.identity}`)) ||
425
+ new Set<string>([group.id ?? ""]);
426
+ const onGroup = caseItems.filter(
427
+ (item) => item.groupId && sameStream.has(item.groupId),
428
+ );
429
+ // The agent contradicting itself is not an explanation. If it also called
430
+ // this card a cause or a symptom, it is asserting the problem, and the
431
+ // reader must see the unqualified warning.
432
+ if (
433
+ onGroup.some((item) => item.role === "cause" || item.role === "symptom")
434
+ )
435
+ return null;
436
+ const explained = onGroup.some(
437
+ (item) =>
438
+ // Only a note the reader can actually see on the card in front of
439
+ // them addresses it. A `source` item renders away from the card and a
440
+ // `revision` item addressed a superseded read of it, so neither
441
+ // speaks to the state the banner is qualifying; an empty claim shows
442
+ // nothing at all, and the banner would be citing a note that is not
443
+ // there. None of this judges whether the agent is right — that is why
444
+ // the banner stays a warning either way.
445
+ item.placement === "card" &&
446
+ item.claim.trim() !== "" &&
447
+ // Only `benign` says this evidence does not indicate a live problem.
448
+ // `rules_out` excludes some other hypothesis and `demoted` says the
449
+ // card is peripheral — both true of a still-active problem — so
450
+ // neither reconciles a healthy verdict with evidence contradicting it.
451
+ item.role === "benign" &&
452
+ // An assessment cannot have addressed a reading taken after it. The
453
+ // twin lookup above deliberately crosses calls, because one log
454
+ // stream read twice lands in two groups; without this, it would also
455
+ // let a note about an earlier failure explain a different one that
456
+ // the same stream reported in a later turn.
457
+ !(
458
+ group.latest.source !== undefined &&
459
+ item.source !== undefined &&
460
+ group.latest.source.turnIndex > item.source.turnIndex
461
+ ),
462
+ );
463
+ if (!explained) return null;
464
+ titles.push(group.latest.title ?? group.kind);
465
+ }
466
+ return titles;
467
+ }
468
+
347
469
  export function investigationEndedBeforeConclusion(
348
470
  status: RunSummary["status"],
349
471
  lastTurn: Pick<Turn, "status" | "apply" | "explainAssessment"> | undefined,