@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
@@ -2,6 +2,7 @@ import {
2
2
  createContext,
3
3
  useCallback,
4
4
  useContext,
5
+ useEffect,
5
6
  useId,
6
7
  useLayoutEffect,
7
8
  useRef,
@@ -9,25 +10,18 @@ import {
9
10
  type ReactNode,
10
11
  } from "react";
11
12
  import { clsx } from "clsx";
13
+ import {
14
+ EVIDENCE_KIND_TRAITS,
15
+ evidenceKindIsFocused,
16
+ } from "./investigationEvidenceKinds";
12
17
  import {
13
18
  Activity,
14
19
  AlertTriangle,
15
- BellRing,
16
- Boxes,
17
- Bug,
18
- CheckCircle2,
19
20
  CircleAlert,
20
- Clock3,
21
21
  FileClock,
22
22
  FileSearch,
23
23
  Info,
24
- KeyRound,
25
- ListTree,
26
- Network,
27
- Package,
28
- ScrollText,
29
24
  SearchCheck,
30
- ShieldAlert,
31
25
  SquareArrowOutUpRight,
32
26
  } from "lucide-react";
33
27
  import {
@@ -40,12 +34,27 @@ import {
40
34
  defaultConditionTone,
41
35
  displayKind,
42
36
  formatRelativeAgeTime,
37
+ HPADiagnosisSummary,
43
38
  mapHealthToTone,
44
39
  ResourceLink,
45
40
  stripAnsi,
46
41
  } from "@skyhook-io/k8s-ui";
42
+ import {
43
+ AreaChart,
44
+ SERIES_COLORS,
45
+ SeriesLegend,
46
+ formatMetricValue,
47
+ seriesDisplayLabels,
48
+ seriesFill,
49
+ type TimeSeries,
50
+ } from "@skyhook-io/k8s-ui/components/charts";
47
51
  import { apiVersionToGroup } from "../../utils/navigation";
48
52
  import { parseLogLine } from "../../utils/log-format";
53
+ import {
54
+ metricsChangeCoverage,
55
+ metricsDomain,
56
+ type MetricsChangeCoverage,
57
+ } from "./investigationMetrics";
49
58
  import {
50
59
  evidenceDisplaySnapshot,
51
60
  groupEvidenceCoverage,
@@ -55,6 +64,7 @@ import {
55
64
  import {
56
65
  investigationEvidenceSubjectRef,
57
66
  investigationEvidenceSourceDomId,
67
+ investigationSourceArgs,
58
68
  type InvestigationEvidenceData,
59
69
  type InvestigationEvidenceGroup,
60
70
  type InvestigationEvidenceObservation,
@@ -63,12 +73,23 @@ import {
63
73
  type InvestigationEvidenceSource,
64
74
  type InvestigationEvidenceTier,
65
75
  } from "./investigationEvidence";
66
- import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
76
+ import type {
77
+ DiagnosisResourceContext,
78
+ DiagnosisResourceRef,
79
+ DiagnosisScalerRef,
80
+ } from "./diagnoseEvidenceTypes";
67
81
  import { InvestigationResourceEvidence } from "./InvestigationResourceEvidence";
68
82
  import { investigationResourceEvidenceHasDetails } from "./investigationResourceEvidenceModel";
69
83
  import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
70
84
  import { evidenceSourceExcerpt } from "./investigationSourceFocus";
71
85
  import { Tooltip } from "../ui/Tooltip";
86
+ import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
87
+ import {
88
+ investigationCaseObservationKey,
89
+ type InvestigationCaseItem,
90
+ type InvestigationCaseResolution,
91
+ } from "./investigationCase";
92
+ import type { DiagnosisEvidenceRole } from "../../api/diagnose";
72
93
 
73
94
  import {
74
95
  investigationDisclosureSettleDelay,
@@ -96,12 +117,57 @@ const EvidenceNavigationContext = createContext<{
96
117
  expandedGroupIds?: ReadonlySet<string>;
97
118
  onGroupOpenChange?: (id: string, open: boolean) => void;
98
119
  citedOrderByGroup?: ReadonlyMap<string, number>;
120
+ /** Change markers for each metrics observation, keyed by its source id. */
121
+ metricsMarkersBySource?: ReadonlyMap<string, MetricsChangeCoverage>;
122
+ /** Card- and revision-placed agent items, keyed by group id. */
123
+ caseByGroup?: ReadonlyMap<string, InvestigationCaseItem[]>;
124
+ /** The hypothesis each `rules_out` item excludes, keyed by item. */
125
+ excludedByItem?: ReadonlyMap<string, string>;
99
126
  }>({});
100
127
 
128
+ /** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
129
+ export function investigationCaseItemKey(item: InvestigationCaseItem): string {
130
+ return `${item.source.id}\u0000${item.index}`;
131
+ }
132
+
133
+ const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
134
+ cause: 0,
135
+ symptom: 1,
136
+ // A ruled-out card keeps Radar's place in the list; the role only labels it.
137
+ rules_out: 2,
138
+ context: 3,
139
+ benign: 4,
140
+ demoted: 5,
141
+ };
142
+ const UNLABELLED_RANK = 2;
143
+
144
+ function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
145
+ const args = investigationSourceArgs(source);
146
+ return (
147
+ typeof args?.kind === "string" &&
148
+ args.kind !== "" &&
149
+ typeof args.name === "string" &&
150
+ args.name !== ""
151
+ );
152
+ }
153
+
154
+ export function investigationCaseByGroup(
155
+ investigationCase?: InvestigationCaseResolution,
156
+ ): Map<string, InvestigationCaseItem[]> {
157
+ const byGroup = new Map<string, InvestigationCaseItem[]>();
158
+ for (const item of investigationCase?.items ?? []) {
159
+ if (!item.groupId || item.placement === "source") continue;
160
+ const items = byGroup.get(item.groupId) ?? [];
161
+ items.push(item);
162
+ byGroup.set(item.groupId, items);
163
+ }
164
+ return byGroup;
165
+ }
166
+
101
167
  function evidenceTypePrefersFullRow(
102
168
  type: InvestigationEvidenceData["type"],
103
169
  ): boolean {
104
- return type === "logs" || type === "events" || type === "alerts";
170
+ return EVIDENCE_KIND_TRAITS[type].fullRow;
105
171
  }
106
172
 
107
173
  // Supporting evidence becomes a two-column grid when the pane is wide enough.
@@ -129,12 +195,31 @@ type EvidenceCollection = "main" | "workload" | "earlier";
129
195
  export function partitionInvestigationEvidence(
130
196
  groups: InvestigationEvidenceGroup[],
131
197
  resolution?: InvestigationRootCauseEvidenceResolution,
198
+ investigationCase?: InvestigationCaseResolution,
199
+ /**
200
+ * What a later turn cited, with the source that cited it. A follow-up adds
201
+ * to what the displayed assessment selected and never takes a selection
202
+ * away. The source travels with it because promoting a broader card needs
203
+ * one: an id alone selects the group but leaves the broader gate below
204
+ * unable to find who cited it, and the card stays withheld.
205
+ */
206
+ alsoSelected?: readonly {
207
+ groupId: string;
208
+ source: InvestigationEvidenceSource;
209
+ }[],
132
210
  ) {
133
- const selected = new Set(
134
- resolution?.status === "linked"
211
+ // Selection: legacy root-cause links plus every placed agent item, of any
212
+ // role (placement promotes a group into main the way a citation does; the
213
+ // role does not matter here). Ordering below is the only place roles act,
214
+ // and nothing the agent sends can remove a group from a collection.
215
+ const caseByGroup = investigationCaseByGroup(investigationCase);
216
+ const selected = new Set([
217
+ ...(resolution?.status === "linked"
135
218
  ? resolution.links.map((link) => link.originalGroupId)
136
- : [],
137
- );
219
+ : []),
220
+ ...caseByGroup.keys(),
221
+ ...(alsoSelected ?? []).map((entry) => entry.groupId),
222
+ ]);
138
223
  const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
139
224
  {
140
225
  main: [],
@@ -148,26 +233,45 @@ export function partitionInvestigationEvidence(
148
233
  // withheld unless cited, and the pane says how many were withheld so a
149
234
  // reader knows the agent looked at things it did not build its case on.
150
235
  let hiddenBroader = 0;
236
+ // Broader metrics are facts about something other than the target. They are
237
+ // withheld unless cited, and the pane says how many were withheld so a
238
+ // reader knows the agent ran queries it did not build its case on.
239
+ let hiddenMetrics = 0;
151
240
  for (const group of groups) {
152
241
  const broader = group.latest.relevance === "broader";
153
242
  // Citations select tool results, not individual rows in a broad search.
154
243
  // Only a focused, unambiguous fact can be promoted by a source citation.
155
- if (broader) {
156
- const link = resolution?.links.find(
157
- (item) => item.originalGroupId === group.id,
158
- );
159
- const focused = FOCUSED_EVIDENCE_TYPES.includes(group.latest.data.type);
160
- const sourceGroups = link
244
+ // An agent item names this observation when its subject does, or when the
245
+ // call it cites was itself scoped to one resource; a subject-less item on
246
+ // a broad query only proves the query returned one row and is gated like
247
+ // a citation of its source.
248
+ const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
249
+ (item) => item.subject || sourceNamesOneResource(item.source),
250
+ );
251
+ if (broader && !namedByAgent) {
252
+ const citingSource =
253
+ resolution?.links.find((item) => item.originalGroupId === group.id)
254
+ ?.source ??
255
+ caseByGroup.get(group.id)?.[0]?.source ??
256
+ alsoSelected?.find((entry) => entry.groupId === group.id)?.source;
257
+ const focused = evidenceKindIsFocused(group.latest.data.type);
258
+ const sourceGroups = citingSource
161
259
  ? groups.filter(
162
260
  (candidate) =>
163
- FOCUSED_EVIDENCE_TYPES.includes(candidate.latest.data.type) &&
261
+ evidenceKindIsFocused(candidate.latest.data.type) &&
164
262
  candidate.observations.some(
165
- (observation) => observation.source.id === link.source.id,
263
+ (observation) => observation.source.id === citingSource.id,
166
264
  ),
167
265
  )
168
266
  : [];
169
267
  if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
170
- if (!group.historical) hiddenBroader += 1;
268
+ // The two counts head separate lines in the pane, so they have to be
269
+ // disjoint: a withheld chart announced by both would read as two
270
+ // withheld results.
271
+ if (!group.historical) {
272
+ if (group.latest.data.type === "metrics") hiddenMetrics += 1;
273
+ else hiddenBroader += 1;
274
+ }
171
275
  continue;
172
276
  }
173
277
  }
@@ -185,28 +289,53 @@ export function partitionInvestigationEvidence(
185
289
  collections[collection].push(group);
186
290
  collectionByGroup.set(group.id, collection);
187
291
  }
188
- collections.main.sort(
189
- (left, right) =>
292
+ // A group takes its strongest role (lowest rank); within one role the
293
+ // agent's own item order decides. Unlabelled and ruled-out groups keep
294
+ // Radar's order among themselves.
295
+ const roleOrder = (group: InvestigationEvidenceGroup) => {
296
+ let rank: number | undefined;
297
+ let itemIndex = Number.POSITIVE_INFINITY;
298
+ for (const item of caseByGroup.get(group.id) ?? []) {
299
+ const itemRank = EVIDENCE_ROLE_RANK[item.role];
300
+ if (rank === undefined || itemRank < rank) {
301
+ rank = itemRank;
302
+ itemIndex = item.index;
303
+ } else if (itemRank === rank) {
304
+ itemIndex = Math.min(itemIndex, item.index);
305
+ }
306
+ }
307
+ if (rank === undefined || rank === UNLABELLED_RANK) {
308
+ return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
309
+ }
310
+ return { rank, itemIndex };
311
+ };
312
+ collections.main.sort((left, right) => {
313
+ const leftRole = roleOrder(left);
314
+ const rightRole = roleOrder(right);
315
+ const agentOrder =
316
+ Number.isFinite(leftRole.itemIndex) ||
317
+ Number.isFinite(rightRole.itemIndex)
318
+ ? leftRole.itemIndex - rightRole.itemIndex
319
+ : 0;
320
+ return (
321
+ leftRole.rank - rightRole.rank ||
322
+ agentOrder ||
190
323
  Number(right.latest.tier === "key") -
191
324
  Number(left.latest.tier === "key") ||
192
325
  Number(adverse(right)) - Number(adverse(left)) ||
193
- left.firstOrder - right.firstOrder,
326
+ left.firstOrder - right.firstOrder
327
+ );
328
+ });
329
+ // A healthy workload's collection opens with several "nothing here"
330
+ // receipts; the cards that carry facts (its vitals above all) come first,
331
+ // and the receipts keep their order among themselves.
332
+ collections.workload.sort(
333
+ (left, right) =>
334
+ Number(left.kind === "receipt") - Number(right.kind === "receipt"),
194
335
  );
195
- return { ...collections, collectionByGroup, hiddenBroader };
336
+ return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
196
337
  }
197
338
 
198
- // Kinds whose card is one unambiguous subject, so a citation of their source
199
- // can promote exactly that fact. A broad search (issues, inventory, events)
200
- // yields many rows per source; a citation cannot pick one of those.
201
- const FOCUSED_EVIDENCE_TYPES: readonly InvestigationEvidenceData["type"][] = [
202
- "resource",
203
- "logs",
204
- "crash",
205
- "helm",
206
- "alerts",
207
- "permissions",
208
- ];
209
-
210
339
  export function investigationEvidenceRevealCollection(
211
340
  projection: InvestigationEvidenceProjection,
212
341
  sourceId: string,
@@ -229,6 +358,8 @@ export function investigationEvidenceRevealCollection(
229
358
  export function InvestigationEvidencePane({
230
359
  projection,
231
360
  rootCauseEvidence,
361
+ alsoSelectedGroupIds,
362
+ investigationCase,
232
363
  collecting,
233
364
  animateGroupIds,
234
365
  onViewSource,
@@ -242,6 +373,13 @@ export function InvestigationEvidencePane({
242
373
  projection: InvestigationEvidenceProjection;
243
374
  /** Server-validated links for the current root cause; absent without one. */
244
375
  rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
376
+ /** What a later turn cited; adds to the assessment's selection. */
377
+ alsoSelectedGroupIds?: readonly {
378
+ groupId: string;
379
+ source: InvestigationEvidenceSource;
380
+ }[];
381
+ /** The current assessment's agent case, resolved against this projection. */
382
+ investigationCase?: InvestigationCaseResolution;
245
383
  collecting: boolean;
246
384
  animateGroupIds: ReadonlySet<string>;
247
385
  onViewSource: (
@@ -280,9 +418,57 @@ export function InvestigationEvidencePane({
280
418
  const partition = partitionInvestigationEvidence(
281
419
  projection.groups,
282
420
  rootCauseEvidence,
421
+ investigationCase,
422
+ alsoSelectedGroupIds,
283
423
  );
284
424
  const hasCurrentEvidence =
285
425
  partition.main.length + partition.workload.length > 0;
426
+ // A "Ruled out" link is an in-pane navigation to the placed observation. It
427
+ // reuses the Activity → Findings reveal path (open the card, open history
428
+ // when the observation is a superseded revision) and yields to a newer
429
+ // request from the parent.
430
+ const [caseReveal, setCaseReveal] = useState<
431
+ { sourceId: string; requestId: number } | undefined
432
+ >(undefined);
433
+ // A ruled-out reveal belongs to the case that produced it; a new parent
434
+ // request or a new assessment's case supersedes it.
435
+ useEffect(() => {
436
+ setCaseReveal(undefined);
437
+ }, [revealRequest?.requestId, investigationCase]);
438
+ const collectionByGroup = partition.collectionByGroup;
439
+ // A placed item can still sit on a withheld broader card; a link to it
440
+ // would have nowhere to go.
441
+ const visibleRuledOut = (investigationCase?.ruledOut ?? []).filter(
442
+ (entry) => entry.item.groupId && collectionByGroup.has(entry.item.groupId),
443
+ );
444
+ const revealCaseItem = useCallback(
445
+ (item: InvestigationCaseItem) => {
446
+ const { groupId, observation } = item;
447
+ if (!groupId || !observation) return;
448
+ // A placed group is normally in main, but a historical one lives in the
449
+ // nested "Previous observations" collection; open the way to it first.
450
+ const collection = collectionByGroup.get(groupId);
451
+ let settle = 0;
452
+ if (collection === "workload" || collection === "earlier") {
453
+ setWorkloadOpen(true);
454
+ settle = investigationDisclosureSettleDelay(prefersReducedMotion());
455
+ }
456
+ if (collection === "earlier") setEarlierOpen(true);
457
+ onGroupOpenChange(groupId, true);
458
+ setCaseReveal({ sourceId: observation.source.id, requestId: Date.now() });
459
+ window.setTimeout(() => {
460
+ window.requestAnimationFrame(() => {
461
+ const element = document.getElementById(groupId);
462
+ element?.scrollIntoView({
463
+ block: "start",
464
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
465
+ });
466
+ element?.focus({ preventScroll: true });
467
+ });
468
+ }, settle);
469
+ },
470
+ [onGroupOpenChange, collectionByGroup],
471
+ );
286
472
  const revealCollection = revealRequest
287
473
  ? investigationEvidenceRevealCollection(
288
474
  projection,
@@ -403,6 +589,13 @@ export function InvestigationEvidencePane({
403
589
  />
404
590
  ) : null}
405
591
 
592
+ {/* What the agent considered and rejected belongs with the conclusion
593
+ it argues for, not after the evidence list among the withheld
594
+ counts, where it read as bookkeeping. */}
595
+ {visibleRuledOut.length > 0 ? (
596
+ <RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
597
+ ) : null}
598
+
406
599
  <div className="grid items-start gap-2.5">
407
600
  {partition.main.map((group) => (
408
601
  <EvidenceCard
@@ -426,6 +619,17 @@ export function InvestigationEvidencePane({
426
619
  </p>
427
620
  ) : null}
428
621
 
622
+ {partition.hiddenMetrics > 0 ? (
623
+ <p
624
+ className="text-xs text-theme-text-tertiary"
625
+ data-testid="investigation-hidden-metrics"
626
+ >
627
+ {partition.hiddenMetrics === 1
628
+ ? "1 broader metric result is not shown; it appears here when the assessment cites it."
629
+ : `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
630
+ </p>
631
+ ) : null}
632
+
429
633
  {!hasCurrentEvidence ? (
430
634
  <EmptyCollection
431
635
  collecting={collecting}
@@ -467,8 +671,29 @@ export function InvestigationEvidencePane({
467
671
  onOpenTimeline,
468
672
  expandedGroupIds,
469
673
  onGroupOpenChange,
470
- revealSourceId: revealRequest?.sourceId,
471
- revealRequestId: revealRequest?.requestId,
674
+ revealSourceId: caseReveal?.sourceId ?? revealRequest?.sourceId,
675
+ revealRequestId: caseReveal?.requestId ?? revealRequest?.requestId,
676
+ caseByGroup: investigationCaseByGroup(investigationCase),
677
+ excludedByItem: new Map(
678
+ (investigationCase?.ruledOut ?? []).map((entry) => [
679
+ investigationCaseItemKey(entry.item),
680
+ entry.hypothesis,
681
+ ]),
682
+ ),
683
+ metricsMarkersBySource: new Map(
684
+ projection.groups.flatMap((group) =>
685
+ group.observations.flatMap((observation) =>
686
+ observation.data.type === "metrics"
687
+ ? [
688
+ [
689
+ observation.source.id,
690
+ metricsChangeCoverage(projection.groups, observation),
691
+ ] as const,
692
+ ]
693
+ : [],
694
+ ),
695
+ ),
696
+ ),
472
697
  citedOrderByGroup: new Map(
473
698
  rootCauseEvidence?.links.flatMap((link) =>
474
699
  link.originalGroupId
@@ -484,6 +709,63 @@ export function InvestigationEvidencePane({
484
709
  );
485
710
  }
486
711
 
712
+ /**
713
+ * Hypotheses the agent dropped, each pointing at the Radar observation whose
714
+ * result contradicted it. Entries whose item could not be placed on an
715
+ * observation are filtered out by the resolver and never rendered here.
716
+ */
717
+ function RuledOutBlock({
718
+ entries,
719
+ onReveal,
720
+ }: {
721
+ entries: InvestigationCaseResolution["ruledOut"];
722
+ onReveal: (item: InvestigationCaseItem) => void;
723
+ }) {
724
+ const headingId = useId();
725
+ return (
726
+ <section
727
+ aria-labelledby={headingId}
728
+ data-testid="investigation-ruled-out"
729
+ className="rounded-lg border border-theme-border/80 bg-theme-base/20 px-3 py-2.5"
730
+ >
731
+ <h3
732
+ id={headingId}
733
+ className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
734
+ >
735
+ Ruled out
736
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
737
+ Agent
738
+ </span>
739
+ </h3>
740
+ <ul className="mt-1.5 space-y-1.5">
741
+ {entries.map((entry, position) => {
742
+ const observation = entry.item.observation!;
743
+ return (
744
+ <li
745
+ key={position}
746
+ className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs"
747
+ >
748
+ <span className="min-w-0 text-theme-text-secondary [overflow-wrap:anywhere]">
749
+ {entry.hypothesis}
750
+ </span>
751
+ <button
752
+ type="button"
753
+ onClick={() => onReveal(entry.item)}
754
+ className="shrink-0 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
755
+ >
756
+ See {observation.title}
757
+ {entry.item.placement === "revision"
758
+ ? " (earlier observation)"
759
+ : ""}
760
+ </button>
761
+ </li>
762
+ );
763
+ })}
764
+ </ul>
765
+ </section>
766
+ );
767
+ }
768
+
487
769
  function AssessmentEvidenceQualification({
488
770
  resolution,
489
771
  onViewActivity,
@@ -925,14 +1207,25 @@ function CoverageGroupRow({
925
1207
  function previousDifferentObservations(
926
1208
  group: InvestigationEvidenceGroup,
927
1209
  citedOrder?: number,
1210
+ boundOrders: ReadonlySet<number> = new Set(),
928
1211
  ) {
929
1212
  const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
1213
+ const pinned = (observation: InvestigationEvidenceObservation) =>
1214
+ observation.source.order === citedOrder ||
1215
+ boundOrders.has(observation.source.order);
930
1216
  return [...group.observations].reverse().filter((observation) => {
1217
+ if (observation === group.latest) return false;
1218
+ // An observation bound to an agent item always keeps its own row: display
1219
+ // equivalence is a folding rule for unpinned rechecks, never a reason to
1220
+ // move a claim onto different content.
1221
+ if (boundOrders.has(observation.source.order)) return true;
1222
+ // A pinned observation wins over an unpinned twin with the same display
1223
+ // content.
931
1224
  if (
932
- observation.source.order !== citedOrder &&
1225
+ !pinned(observation) &&
933
1226
  group.observations.some(
934
1227
  (other) =>
935
- other.source.order === citedOrder &&
1228
+ pinned(other) &&
936
1229
  evidenceDisplaySnapshot(other) ===
937
1230
  evidenceDisplaySnapshot(observation),
938
1231
  )
@@ -948,6 +1241,8 @@ function previousDifferentObservations(
948
1241
  export function investigationEvidenceShouldRevealHistory(
949
1242
  group: InvestigationEvidenceGroup,
950
1243
  sourceId?: string,
1244
+ /** Sources whose observation carries an agent item and so always has a row. */
1245
+ boundSourceIds: ReadonlySet<string> = new Set(),
951
1246
  ): boolean {
952
1247
  return (
953
1248
  Boolean(sourceId) &&
@@ -955,8 +1250,9 @@ export function investigationEvidenceShouldRevealHistory(
955
1250
  group.observations.some(
956
1251
  (observation) =>
957
1252
  observation.source.id === sourceId &&
958
- evidenceDisplaySnapshot(observation) !==
959
- evidenceDisplaySnapshot(group.latest),
1253
+ (boundSourceIds.has(observation.source.id) ||
1254
+ evidenceDisplaySnapshot(observation) !==
1255
+ evidenceDisplaySnapshot(group.latest)),
960
1256
  )
961
1257
  );
962
1258
  }
@@ -989,6 +1285,9 @@ function EvidenceCard({
989
1285
  citedOrderByGroup,
990
1286
  expandedGroupIds,
991
1287
  onGroupOpenChange,
1288
+ metricsMarkersBySource,
1289
+ caseByGroup,
1290
+ excludedByItem,
992
1291
  } = useContext(EvidenceNavigationContext);
993
1292
  const open = expandedGroupIds?.has(group.id) ?? false;
994
1293
  const setOpen = useCallback(
@@ -1002,7 +1301,17 @@ function EvidenceCard({
1002
1301
  ? parseLogLine(observation.summary).content
1003
1302
  : observation.summary;
1004
1303
  const citedOrder = citedOrderByGroup?.get(group.id);
1005
- const previousObservations = previousDifferentObservations(group, citedOrder);
1304
+ const caseItems = caseByGroup?.get(group.id) ?? [];
1305
+ const cardItems = caseItems.filter((item) => item.placement === "card");
1306
+ const revisionItems = caseItems.filter(
1307
+ (item) => item.placement === "revision",
1308
+ );
1309
+ const agentCause = cardItems.some((item) => item.role === "cause");
1310
+ const previousObservations = previousDifferentObservations(
1311
+ group,
1312
+ citedOrder,
1313
+ new Set(revisionItems.map((item) => item.observation!.source.order)),
1314
+ );
1006
1315
  const meaningfulHistory = previousObservations.length > 0;
1007
1316
  const citedObservation = group.observations.find(
1008
1317
  (item) => item.source.order === citedOrder,
@@ -1020,6 +1329,7 @@ function EvidenceCard({
1020
1329
  const revealHistory = investigationEvidenceShouldRevealHistory(
1021
1330
  group,
1022
1331
  revealSourceId,
1332
+ new Set(revisionItems.map((item) => item.observation!.source.id)),
1023
1333
  );
1024
1334
  useLayoutEffect(() => {
1025
1335
  const destination = revealSourceId
@@ -1100,7 +1410,14 @@ function EvidenceCard({
1100
1410
  aria-label={`${observation.title} evidence`}
1101
1411
  className={clsx(
1102
1412
  "@container/card scroll-mt-14 overflow-hidden outline-none focus:ring-2 focus:ring-accent/50 data-[source-related]:ring-2 data-[source-related]:ring-accent/35",
1103
- "rounded-lg border bg-theme-surface",
1413
+ "rounded-lg border",
1414
+ // The card the agent calls the cause sorts first and is often the
1415
+ // calmest thing on screen: a cause is frequently a Secret or a
1416
+ // ConfigMap that Radar has no reason to colour, while every card
1417
+ // echoing the failure below it carries red. Lifting the surface marks
1418
+ // it without borrowing a severity hue, and without touching the left
1419
+ // rule (Radar's severity) or the ring (the source you are viewing).
1420
+ agentCause ? "bg-theme-elevated shadow-theme-sm" : "bg-theme-surface",
1104
1421
  toneBorder(observation.tone, observation.tier, prominence),
1105
1422
  wide && "@min-[760px]/evidence:col-span-2",
1106
1423
  animateArrival && "animate-transcript-enter",
@@ -1173,6 +1490,24 @@ function EvidenceCard({
1173
1490
  ) : null}
1174
1491
  </div>
1175
1492
  </div>
1493
+ {cardItems.some((item) => item.claim || item.role) ? (
1494
+ <div
1495
+ className={clsx(
1496
+ "space-y-1.5",
1497
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
1498
+ )}
1499
+ >
1500
+ {cardItems.map((item) => (
1501
+ <AgentClaimNote
1502
+ key={item.index}
1503
+ claim={item.claim}
1504
+ role={item.role}
1505
+ excludes={excludedByItem?.get(investigationCaseItemKey(item))}
1506
+ className="pt-1.5"
1507
+ />
1508
+ ))}
1509
+ </div>
1510
+ ) : null}
1176
1511
  {canExpand ? (
1177
1512
  <div id={bodyId}>
1178
1513
  <Collapse open={open}>
@@ -1186,12 +1521,16 @@ function EvidenceCard({
1186
1521
  <EvidenceBody
1187
1522
  data={observation.data}
1188
1523
  cardSummary={observation.summary}
1524
+ changeCoverage={metricsMarkersBySource?.get(
1525
+ observation.source.id,
1526
+ )}
1189
1527
  />
1190
1528
  ) : null}
1191
1529
  {meaningfulHistory ? (
1192
1530
  <RevisionHistory
1193
1531
  observations={previousObservations}
1194
1532
  citedOrder={citedOrder}
1533
+ caseItems={revisionItems}
1195
1534
  reveal={revealHistory}
1196
1535
  revealRequestId={revealRequestId}
1197
1536
  onViewSource={onViewSource}
@@ -1293,9 +1632,12 @@ function uniquePrimarySources(
1293
1632
  function EvidenceBody({
1294
1633
  data,
1295
1634
  cardSummary,
1635
+ changeCoverage,
1296
1636
  }: {
1297
1637
  data: InvestigationEvidenceData;
1298
1638
  cardSummary?: string;
1639
+ /** Change markers for a metrics chart; derived by the pane, never by data. */
1640
+ changeCoverage?: MetricsChangeCoverage;
1299
1641
  }) {
1300
1642
  switch (data.type) {
1301
1643
  case "issue":
@@ -1323,15 +1665,20 @@ function EvidenceBody({
1323
1665
  case "inventory":
1324
1666
  return <InventoryBody data={data} />;
1325
1667
  case "receipt":
1326
- return (
1668
+ // The title and the scope above it are the answer. A body appears only
1669
+ // when it adds the reason or the limit, so an absent one renders nothing
1670
+ // rather than an empty line.
1671
+ return data.message ? (
1327
1672
  <p className="text-xs text-theme-text-secondary">{data.message}</p>
1328
- );
1673
+ ) : null;
1329
1674
  case "alerts":
1330
1675
  return <AlertsBody data={data} />;
1331
1676
  case "helm":
1332
1677
  return <HelmBody data={data} />;
1333
1678
  case "permissions":
1334
1679
  return <PermissionsBody data={data} />;
1680
+ case "metrics":
1681
+ return <MetricsBody data={data} changeCoverage={changeCoverage} />;
1335
1682
  }
1336
1683
  }
1337
1684
 
@@ -1360,6 +1707,7 @@ function evidenceHasDetails(
1360
1707
  investigationResourceEvidenceHasDetails(data.resource) ||
1361
1708
  replicas?.desired !== undefined ||
1362
1709
  data.resourceContext?.statusSummary?.conditions?.length ||
1710
+ diagnosedScalers(data.resourceContext).length ||
1363
1711
  data.gitOpsDiagnosis ||
1364
1712
  data.warnings.length,
1365
1713
  );
@@ -1495,6 +1843,7 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1495
1843
  const ready = replicas ? (replicas.ready ?? 0) : undefined;
1496
1844
  const shortfall =
1497
1845
  desired !== undefined && ready !== undefined && ready < desired;
1846
+ const scalers = diagnosedScalers(data.resourceContext);
1498
1847
  return (
1499
1848
  <div className="space-y-3">
1500
1849
  <InvestigationResourceEvidence resource={data.resource} />
@@ -1550,6 +1899,12 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1550
1899
  </div>
1551
1900
  </div>
1552
1901
  ) : null}
1902
+ {scalers.length > 0 ? (
1903
+ <ScaledBySection
1904
+ scalers={scalers}
1905
+ allScalers={data.resourceContext?.scaledBy ?? []}
1906
+ />
1907
+ ) : null}
1553
1908
  {data.warnings.length > 0 ? (
1554
1909
  <ul className="space-y-1 text-xs text-theme-text-secondary">
1555
1910
  {data.warnings.map((warning) => (
@@ -1564,6 +1919,75 @@ function ResourceBody({ data }: { data: EvidenceDataOf<"resource"> }) {
1564
1919
  );
1565
1920
  }
1566
1921
 
1922
+ type DiagnosedScaler = DiagnosisScalerRef &
1923
+ Required<Pick<DiagnosisScalerRef, "hpaSummary">>;
1924
+
1925
+ function diagnosedScalers(
1926
+ context: DiagnosisResourceContext | undefined,
1927
+ ): DiagnosedScaler[] {
1928
+ return (context?.scaledBy ?? []).filter(
1929
+ (scaler): scaler is DiagnosedScaler => scaler.hpaSummary !== undefined,
1930
+ );
1931
+ }
1932
+
1933
+ function ScaledBySection({
1934
+ scalers,
1935
+ allScalers,
1936
+ }: {
1937
+ scalers: DiagnosedScaler[];
1938
+ allScalers: DiagnosisScalerRef[];
1939
+ }) {
1940
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
1941
+ // The ScaledObject is only a link when Radar listed it as a scaler too; a
1942
+ // name read off the HPA's owner reference alone is not a resource we hold.
1943
+ const listedScaler = (ref: DiagnosisResourceRef) =>
1944
+ allScalers.some(
1945
+ (scaler) =>
1946
+ scaler.kind === ref.kind &&
1947
+ scaler.name === ref.name &&
1948
+ (scaler.namespace ?? "") === (ref.namespace ?? ""),
1949
+ );
1950
+ return (
1951
+ <div>
1952
+ <div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1953
+ Scaled by
1954
+ </div>
1955
+ <div className="space-y-2">
1956
+ {scalers.map((scaler) => (
1957
+ <HPADiagnosisSummary
1958
+ key={`${scaler.namespace ?? ""}/${scaler.name}`}
1959
+ diagnosis={scaler.hpaSummary}
1960
+ variant="inline"
1961
+ header={
1962
+ <>
1963
+ <span className="font-medium text-theme-text-primary">
1964
+ {displayKind(scaler.kind)} {scaler.name}
1965
+ </span>
1966
+ {scaler.managedBy ? (
1967
+ <span className="text-theme-text-tertiary">
1968
+ managed by KEDA {displayKind(scaler.managedBy.kind)}{" "}
1969
+ <ResourceLink
1970
+ name={scaler.managedBy.name}
1971
+ kind={scaler.managedBy.kind}
1972
+ namespace={scaler.managedBy.namespace ?? ""}
1973
+ group={scaler.managedBy.group}
1974
+ onNavigate={
1975
+ onOpenResource && listedScaler(scaler.managedBy)
1976
+ ? (ref) => onOpenResource(ref)
1977
+ : undefined
1978
+ }
1979
+ />
1980
+ </span>
1981
+ ) : null}
1982
+ </>
1983
+ }
1984
+ />
1985
+ ))}
1986
+ </div>
1987
+ </div>
1988
+ );
1989
+ }
1990
+
1567
1991
  function GitOpsStatusBody({
1568
1992
  status,
1569
1993
  }: {
@@ -2114,6 +2538,222 @@ function TopologyStat({ label, value }: { label: string; value: number }) {
2114
2538
  );
2115
2539
  }
2116
2540
 
2541
+ const METRICS_AXIS_LABEL_MAX_CHARS = 72;
2542
+
2543
+ function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
2544
+ return series.dataPoints.filter((point) => typeof point.value === "number");
2545
+ }
2546
+
2547
+ function MetricsValueTable({
2548
+ series,
2549
+ labels,
2550
+ unit,
2551
+ withTime,
2552
+ }: {
2553
+ series: TimeSeries[];
2554
+ /** Display name per series, derived from the complete result. */
2555
+ labels: string[];
2556
+ unit: string;
2557
+ withTime: boolean;
2558
+ }) {
2559
+ return (
2560
+ <table className="w-full text-xs">
2561
+ <tbody>
2562
+ {series.map((item, index) => {
2563
+ const sample = finiteSamples(item).at(-1);
2564
+ return (
2565
+ <tr
2566
+ key={`${labels[index]}-${index}`}
2567
+ className="border-b border-theme-border/60 last:border-b-0"
2568
+ >
2569
+ <td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
2570
+ {labels[index]}
2571
+ </td>
2572
+ {withTime ? (
2573
+ <td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
2574
+ {sample
2575
+ ? new Date(sample.timestamp * 1000).toLocaleTimeString()
2576
+ : ""}
2577
+ </td>
2578
+ ) : null}
2579
+ <td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
2580
+ {sample && typeof sample.value === "number"
2581
+ ? formatMetricValue(sample.value, unit)
2582
+ : "no value"}
2583
+ </td>
2584
+ </tr>
2585
+ );
2586
+ })}
2587
+ </tbody>
2588
+ </table>
2589
+ );
2590
+ }
2591
+
2592
+ function MetricsBody({
2593
+ data,
2594
+ changeCoverage,
2595
+ }: {
2596
+ data: EvidenceDataOf<"metrics">;
2597
+ changeCoverage?: MetricsChangeCoverage;
2598
+ }) {
2599
+ const annotations = changeCoverage?.markers;
2600
+ const unit = data.unit ?? "";
2601
+ const axisLabel = data.label ?? data.query;
2602
+ const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
2603
+ const axisText = axisTruncated
2604
+ ? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
2605
+ : axisLabel;
2606
+ const domain = metricsDomain(data);
2607
+ const windowText = domain
2608
+ ? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
2609
+ : undefined;
2610
+ if (data.series.length === 0) {
2611
+ return (
2612
+ <div className="space-y-2">
2613
+ <p className="text-xs text-theme-text-secondary">
2614
+ No series matched this query
2615
+ {data.mode === "range" ? " in the window" : ""}.
2616
+ </p>
2617
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2618
+ {data.query}
2619
+ </pre>
2620
+ {data.note ? (
2621
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2622
+ ) : null}
2623
+ </div>
2624
+ );
2625
+ }
2626
+ if (data.mode === "instant") {
2627
+ return (
2628
+ <div className="space-y-2">
2629
+ <MetricsValueTable
2630
+ series={data.series}
2631
+ labels={seriesDisplayLabels(data.series)}
2632
+ unit={unit}
2633
+ withTime={false}
2634
+ />
2635
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2636
+ {data.query}
2637
+ </pre>
2638
+ {data.note ? (
2639
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2640
+ ) : null}
2641
+ </div>
2642
+ );
2643
+ }
2644
+ // Names come from the whole result so two series that differ only by a
2645
+ // label the chart would hide stay distinguishable wherever they are listed.
2646
+ const labels = seriesDisplayLabels(data.series);
2647
+ const indexed = data.series.map((item, index) => ({
2648
+ item,
2649
+ label: labels[index],
2650
+ finite: finiteSamples(item).length,
2651
+ }));
2652
+ // A series with one finite sample has no line to draw, and one with none
2653
+ // (Prometheus serializes NaN and infinities as gaps) has nothing to show.
2654
+ // Listing both keeps what was captured readable and states what was not.
2655
+ const charted = indexed.filter((entry) => entry.finite >= 2);
2656
+ const single = indexed.filter((entry) => entry.finite === 1);
2657
+ const empty = indexed.filter((entry) => entry.finite === 0);
2658
+ return (
2659
+ <div className="space-y-2">
2660
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
2661
+ <Tooltip
2662
+ content={axisLabel}
2663
+ delay={200}
2664
+ position="top"
2665
+ disabled={!axisTruncated}
2666
+ >
2667
+ <span
2668
+ className="min-w-0 truncate font-mono text-theme-text-secondary"
2669
+ data-testid="investigation-metrics-axis-label"
2670
+ >
2671
+ {axisText}
2672
+ </span>
2673
+ </Tooltip>
2674
+ {unit ? (
2675
+ <span className="text-theme-text-tertiary">({unit})</span>
2676
+ ) : null}
2677
+ {windowText ? (
2678
+ <span className="ml-auto text-theme-text-tertiary">{windowText}</span>
2679
+ ) : null}
2680
+ </div>
2681
+ {charted.length > 0 ? (
2682
+ <div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
2683
+ <AreaChart
2684
+ series={charted.map((entry) => entry.item)}
2685
+ seriesLabels={charted.map((entry) => entry.label)}
2686
+ color={SERIES_COLORS[0]}
2687
+ fillColor={seriesFill(0, SERIES_COLORS[0])}
2688
+ unit={unit}
2689
+ annotations={annotations}
2690
+ domain={domain}
2691
+ layout="auto"
2692
+ />
2693
+ {charted.length > 1 ? (
2694
+ <SeriesLegend
2695
+ series={charted.map((entry) => entry.item)}
2696
+ seriesLabels={charted.map((entry) => entry.label)}
2697
+ color={SERIES_COLORS[0]}
2698
+ />
2699
+ ) : null}
2700
+ </div>
2701
+ ) : null}
2702
+ {single.length > 0 ? (
2703
+ <div
2704
+ className="space-y-1"
2705
+ data-testid="investigation-metrics-sparse-series"
2706
+ >
2707
+ <p className="text-xs text-theme-text-tertiary">
2708
+ {single.length === 1
2709
+ ? "1 series has a single sample in this window, listed with its time:"
2710
+ : `${single.length} series have a single sample in this window, listed with their times:`}
2711
+ </p>
2712
+ <MetricsValueTable
2713
+ series={single.map((entry) => entry.item)}
2714
+ labels={single.map((entry) => entry.label)}
2715
+ unit={unit}
2716
+ withTime
2717
+ />
2718
+ </div>
2719
+ ) : null}
2720
+ {empty.length > 0 ? (
2721
+ <p
2722
+ className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
2723
+ data-testid="investigation-metrics-empty-series"
2724
+ >
2725
+ {empty.length === 1
2726
+ ? "1 series had no usable samples in this window: "
2727
+ : `${empty.length} series had no usable samples in this window: `}
2728
+ <span className="font-mono">
2729
+ {empty.map((entry) => entry.label).join("; ")}
2730
+ </span>
2731
+ </p>
2732
+ ) : null}
2733
+ {changeCoverage?.checked && charted.length > 0 ? (
2734
+ <p className="text-xs text-theme-text-tertiary">
2735
+ {annotations?.length === 1
2736
+ ? "1 change recorded in this window is marked on the chart."
2737
+ : annotations?.length
2738
+ ? `${annotations.length} changes recorded in this window are marked on the chart.`
2739
+ : // Only that the changes Radar read miss this window — not that
2740
+ // the window was fully covered. The change lookup has its own
2741
+ // window, which need not span the chart's.
2742
+ "None of the changes Radar read fall in this window."}
2743
+ </p>
2744
+ ) : null}
2745
+ {axisTruncated ? (
2746
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
2747
+ {data.query}
2748
+ </pre>
2749
+ ) : null}
2750
+ {data.note ? (
2751
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
2752
+ ) : null}
2753
+ </div>
2754
+ );
2755
+ }
2756
+
2117
2757
  function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
2118
2758
  return (
2119
2759
  <div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
@@ -2600,12 +3240,15 @@ function PermissionsBody({ data }: { data: EvidenceDataOf<"permissions"> }) {
2600
3240
  function RevisionHistory({
2601
3241
  observations,
2602
3242
  citedOrder,
3243
+ caseItems = [],
2603
3244
  reveal,
2604
3245
  revealRequestId,
2605
3246
  onViewSource,
2606
3247
  }: {
2607
3248
  observations: InvestigationEvidenceObservation[];
2608
3249
  citedOrder?: number;
3250
+ /** Agent items bound to a superseded observation of this group. */
3251
+ caseItems?: InvestigationCaseItem[];
2609
3252
  reveal: boolean;
2610
3253
  revealRequestId?: number;
2611
3254
  onViewSource: (
@@ -2615,6 +3258,7 @@ function RevisionHistory({
2615
3258
  }) {
2616
3259
  const [open, setOpen] = useState(false);
2617
3260
  const regionId = useId();
3261
+ const { metricsMarkersBySource } = useContext(EvidenceNavigationContext);
2618
3262
  const { elementRef, revealAfterToggle } =
2619
3263
  useDisclosureReveal<HTMLDivElement>();
2620
3264
  useLayoutEffect(() => {
@@ -2638,41 +3282,73 @@ function RevisionHistory({
2638
3282
  <div id={regionId} ref={elementRef}>
2639
3283
  <Collapse open={open}>
2640
3284
  <ol className="space-y-3 pt-2">
2641
- {observations.map((observation) => (
2642
- <li
2643
- key={`${observation.source.id}-${observation.revision}`}
2644
- className="flex min-w-0 items-start gap-2 text-xs"
2645
- >
2646
- <span className="mt-0.5 shrink-0 text-theme-text-tertiary">
2647
- <span className="font-medium text-theme-text-secondary">
2648
- {observation.source.order === citedOrder
2649
- ? "Used for assessment"
2650
- : phaseLabel(observation.source.phase)}
3285
+ {observations.map((observation) => {
3286
+ const key = investigationCaseObservationKey(observation);
3287
+ const rowItems = caseItems.filter(
3288
+ (item) =>
3289
+ item.observation &&
3290
+ investigationCaseObservationKey(item.observation) === key,
3291
+ );
3292
+ const rowRoles = [...new Set(rowItems.map((item) => item.role))];
3293
+ return (
3294
+ <li
3295
+ key={`${observation.source.id}-${observation.revision}`}
3296
+ data-case-observation={rowItems.length > 0 ? key : undefined}
3297
+ className="flex min-w-0 items-start gap-2 text-xs"
3298
+ >
3299
+ <span className="mt-0.5 shrink-0 text-theme-text-tertiary">
3300
+ <span className="font-medium text-theme-text-secondary">
3301
+ {observation.source.order === citedOrder
3302
+ ? "Used for assessment"
3303
+ : phaseLabel(observation.source.phase)}
3304
+ </span>
2651
3305
  </span>
2652
- </span>
2653
- <div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
2654
- <p>{observation.summary || observation.title}</p>
2655
- {evidenceHasDetails(
2656
- observation.data,
2657
- observation.summary,
2658
- ) && (
2659
- <EvidenceBody
2660
- data={observation.data}
2661
- cardSummary={observation.summary}
2662
- />
2663
- )}
2664
- </div>
2665
- <SourceButton
2666
- ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
2667
- onClick={() =>
2668
- onViewSource(
2669
- observation.source.id,
2670
- evidenceSourceExcerpt(observation.data),
2671
- )
2672
- }
2673
- />
2674
- </li>
2675
- ))}
3306
+ <div className="min-w-0 flex-1 space-y-1 text-theme-text-secondary">
3307
+ {rowRoles.length > 0 ? (
3308
+ <p className="flex flex-wrap items-center gap-1.5">
3309
+ <span>{observation.summary || observation.title}</span>
3310
+ {rowRoles.map((role) => (
3311
+ <AgentRoleChip key={role} role={role} />
3312
+ ))}
3313
+ </p>
3314
+ ) : (
3315
+ <p>{observation.summary || observation.title}</p>
3316
+ )}
3317
+ {rowItems
3318
+ .filter((item) => item.claim)
3319
+ .map((item) => (
3320
+ <AgentClaimNote
3321
+ key={item.index}
3322
+ claim={item.claim}
3323
+ role={rowRoles.length > 1 ? item.role : undefined}
3324
+ className="pt-1"
3325
+ />
3326
+ ))}
3327
+ {evidenceHasDetails(
3328
+ observation.data,
3329
+ observation.summary,
3330
+ ) && (
3331
+ <EvidenceBody
3332
+ data={observation.data}
3333
+ cardSummary={observation.summary}
3334
+ changeCoverage={metricsMarkersBySource?.get(
3335
+ observation.source.id,
3336
+ )}
3337
+ />
3338
+ )}
3339
+ </div>
3340
+ <SourceButton
3341
+ ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
3342
+ onClick={() =>
3343
+ onViewSource(
3344
+ observation.source.id,
3345
+ evidenceSourceExcerpt(observation.data),
3346
+ )
3347
+ }
3348
+ />
3349
+ </li>
3350
+ );
3351
+ })}
2676
3352
  </ol>
2677
3353
  </Collapse>
2678
3354
  </div>
@@ -2681,11 +3357,9 @@ function RevisionHistory({
2681
3357
  }
2682
3358
 
2683
3359
  function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
2684
- let text: string | undefined;
2685
- if (data.type === "events") {
2686
- text =
2687
- "Events support the timeline; proximity alone does not establish cause.";
2688
- } else if (data.type === "changes") {
3360
+ // Changes get a tooltip rather than a sentence because the wording depends
3361
+ // on whether the reported age was collected with the change.
3362
+ if (data.type === "changes") {
2689
3363
  return (
2690
3364
  <Tooltip
2691
3365
  content={`A change alone does not establish the cause.${data.changeContext?.when ? " The reported age is as of collection." : ""}`}
@@ -2702,16 +3376,8 @@ function EvidenceCaveat({ data }: { data: InvestigationEvidenceData }) {
2702
3376
  </button>
2703
3377
  </Tooltip>
2704
3378
  );
2705
- } else if (data.type === "relationships" || data.type === "topology") {
2706
- text =
2707
- "This shows direct relationships Radar found, not an inferred blast radius.";
2708
- } else if (data.type === "alerts") {
2709
- text =
2710
- "Instances are matched to this investigation by their Prometheus labels; a firing rule alone does not establish the cause.";
2711
- } else if (data.type === "permissions") {
2712
- text =
2713
- "This is what RBAC grants the subject, not what the workload has exercised.";
2714
3379
  }
3380
+ const text = EVIDENCE_KIND_TRAITS[data.type].caveat;
2715
3381
  if (!text) return null;
2716
3382
  return (
2717
3383
  <p className="flex items-start gap-1.5 text-xs leading-relaxed text-theme-text-tertiary">
@@ -2784,39 +3450,7 @@ function SourceButton({
2784
3450
  }
2785
3451
 
2786
3452
  function evidenceIcon(type: InvestigationEvidenceData["type"]) {
2787
- switch (type) {
2788
- case "issue":
2789
- return CircleAlert;
2790
- case "startup":
2791
- return ShieldAlert;
2792
- case "crash":
2793
- return Bug;
2794
- case "resource":
2795
- return Boxes;
2796
- case "logs":
2797
- return ScrollText;
2798
- case "events":
2799
- return Clock3;
2800
- case "changes":
2801
- return FileClock;
2802
- case "dns":
2803
- return Activity;
2804
- case "network":
2805
- return Network;
2806
- case "relationships":
2807
- case "topology":
2808
- return Network;
2809
- case "inventory":
2810
- return ListTree;
2811
- case "receipt":
2812
- return CheckCircle2;
2813
- case "alerts":
2814
- return BellRing;
2815
- case "helm":
2816
- return Package;
2817
- case "permissions":
2818
- return KeyRound;
2819
- }
3453
+ return EVIDENCE_KIND_TRAITS[type].icon;
2820
3454
  }
2821
3455
 
2822
3456
  function severityBadge(value: string) {
@@ -2850,6 +3484,13 @@ function toneBorder(
2850
3484
  prominence: "primary" | "supporting" | "secondary",
2851
3485
  ): string {
2852
3486
  if (prominence !== "primary") return "border-theme-border/70";
3487
+ // A supporting adverse card is what the healthy-conflict banner points at,
3488
+ // so it must be tellable from its neutral neighbours: a thin rule, lighter
3489
+ // than the Key tier's.
3490
+ if (tier === "supporting" && tone === "error")
3491
+ return "border-l-2 border-l-semantic-error border-theme-border";
3492
+ if (tier === "supporting" && (tone === "warning" || tone === "alert"))
3493
+ return "border-l-2 border-l-semantic-warning border-theme-border";
2853
3494
  if (tier !== "key") return "border-theme-border";
2854
3495
  if (tone === "error")
2855
3496
  return "border-l-[3px] border-l-red-500 border-theme-border";