@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
@@ -1,12 +1,18 @@
1
- import { useMemo, useState } from 'react'
2
- import { Loader2, Wifi, WifiOff } from 'lucide-react'
1
+ import { useMemo } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import { Loader2, Wifi, WifiOff } from "lucide-react";
3
4
  import {
4
5
  AreaChart,
5
6
  SeriesLegend,
6
7
  computeSaturation,
8
+ describePodCoverage,
7
9
  type ReferenceLine,
8
- } from '@skyhook-io/k8s-ui/components/charts'
9
- import { SEVERITY_BADGE, SEVERITY_TEXT, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
10
+ } from "@skyhook-io/k8s-ui/components/charts";
11
+ import {
12
+ SEVERITY_BADGE,
13
+ SEVERITY_TEXT,
14
+ type Severity,
15
+ } from "@skyhook-io/k8s-ui/utils/badge-colors";
10
16
  import {
11
17
  usePrometheusStatus,
12
18
  usePrometheusConnect,
@@ -14,7 +20,7 @@ import {
14
20
  useAutoPromConnect,
15
21
  type PrometheusMetricCategory,
16
22
  type PrometheusTimeRange,
17
- } from '../../api/client'
23
+ } from "../../api/client";
18
24
  import {
19
25
  MetricsSummary,
20
26
  TIME_RANGES,
@@ -22,24 +28,34 @@ import {
22
28
  NODE_CATEGORIES,
23
29
  computeRequestLimitLines,
24
30
  type CategoryDef,
25
- } from './PrometheusCharts'
26
- import { RestartEventLane } from './RestartChart'
27
- import { Tooltip } from '../ui/Tooltip'
31
+ } from "./PrometheusCharts";
32
+ import { RestartEventLane } from "./RestartChart";
33
+ import { Tooltip } from "../ui/Tooltip";
34
+ import { WorkloadMetricsSection } from "./WorkloadMetricsSection";
35
+ import { RightsizingStrip } from "./RightsizingStrip";
36
+ import { useNavCustomization } from "../../context/NavCustomization";
28
37
 
29
38
  // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
30
39
  // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
31
40
  // can't fit the grid cleanly.
32
41
  export interface PrometheusChartsGridProps {
33
- kind: string
34
- namespace: string
35
- name: string
42
+ kind: string;
43
+ namespace: string;
44
+ name: string;
36
45
  /** Optional full K8s resource for request/limit overlay derivation. */
37
- resource?: any
46
+ resource?: any;
38
47
  }
39
48
 
40
49
  const SUPPORTED_KINDS = new Set([
41
- 'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
42
- ])
50
+ "Pod",
51
+ "Deployment",
52
+ "StatefulSet",
53
+ "DaemonSet",
54
+ "ReplicaSet",
55
+ "Job",
56
+ "CronJob",
57
+ "Node",
58
+ ]);
43
59
 
44
60
  export function PrometheusChartsGrid({
45
61
  kind,
@@ -47,29 +63,34 @@ export function PrometheusChartsGrid({
47
63
  name,
48
64
  resource,
49
65
  }: PrometheusChartsGridProps) {
50
- useAutoPromConnect()
51
- const { data: status, isLoading: statusLoading } = usePrometheusStatus()
52
- const connectMutation = usePrometheusConnect()
53
- const isConnected = status?.connected === true
54
- const isSupported = SUPPORTED_KINDS.has(kind)
55
- const showRestartLane = isSupported && kind !== 'Node'
66
+ useAutoPromConnect();
67
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus();
68
+ const connectMutation = usePrometheusConnect();
69
+ const isConnected = status?.connected === true;
70
+ const isSupported = SUPPORTED_KINDS.has(kind);
71
+ const isWorkload = ["Deployment", "StatefulSet", "DaemonSet"].includes(kind);
72
+ const showRestartLane = isSupported && kind !== "Node";
56
73
 
57
- const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
74
+ const settingsAvailable = !useNavCustomization().embedded;
75
+ const [searchParams, setSearchParams] = useSearchParams();
76
+ const timeRange = TIME_RANGES.find((range) => range.value === searchParams.get("metricsRange"))?.value ?? "1h";
58
77
 
59
- const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
78
+ const categories = kind === "Node" ? NODE_CATEGORIES : WORKLOAD_CATEGORIES;
60
79
 
61
80
  // CPU + memory get reference-line overlays when a resource is provided.
62
81
  // Computed once at the parent so each panel can stay otherwise generic.
63
82
  const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
64
- () => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
83
+ () =>
84
+ resource ? computeRequestLimitLines(resource, kind, "cpu") : undefined,
65
85
  [resource, kind],
66
- )
86
+ );
67
87
  const memRefLines = useMemo<ReferenceLine[] | undefined>(
68
- () => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
88
+ () =>
89
+ resource ? computeRequestLimitLines(resource, kind, "memory") : undefined,
69
90
  [resource, kind],
70
- )
91
+ );
71
92
 
72
- if (!isSupported) return null
93
+ if (!isSupported) return null;
73
94
 
74
95
  if (statusLoading) {
75
96
  return (
@@ -77,7 +98,16 @@ export function PrometheusChartsGrid({
77
98
  <Loader2 className="w-5 h-5 animate-spin mr-2" />
78
99
  Checking Prometheus availability...
79
100
  </div>
80
- )
101
+ );
102
+ }
103
+
104
+ if (!isConnected && status?.discovering) {
105
+ return (
106
+ <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
107
+ <Loader2 className="w-5 h-5 animate-spin mr-2" />
108
+ Discovering Prometheus…
109
+ </div>
110
+ );
81
111
  }
82
112
 
83
113
  if (!isConnected) {
@@ -85,10 +115,14 @@ export function PrometheusChartsGrid({
85
115
  <div className="flex flex-col items-center justify-center py-12 gap-4">
86
116
  <WifiOff className="w-10 h-10 text-theme-text-quaternary" />
87
117
  <div className="text-center">
88
- <p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
118
+ <p className="text-sm text-theme-text-secondary mb-1">
119
+ Prometheus not connected
120
+ </p>
89
121
  <p className="text-xs text-theme-text-tertiary mb-4">
90
- {status?.error || 'Connect to view historical CPU, memory, and network metrics'}
122
+ {status?.error ||
123
+ "Connect to view historical CPU, memory, and network metrics"}
91
124
  </p>
125
+ <div className="flex flex-wrap items-center justify-center gap-3">
92
126
  <button
93
127
  onClick={() => connectMutation.mutate()}
94
128
  disabled={connectMutation.isPending}
@@ -101,107 +135,193 @@ export function PrometheusChartsGrid({
101
135
  )}
102
136
  Discover Prometheus
103
137
  </button>
138
+ {settingsAvailable && <button
139
+ type="button"
140
+ className="text-sm text-accent hover:underline"
141
+ onClick={() => window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'prometheus' } }))}
142
+ >Configure metrics</button>}
143
+ </div>
144
+ {!settingsAvailable && <p className="mt-3 text-xs text-theme-text-tertiary">Ask your operator to configure the metrics connection for this cluster.</p>}
145
+ {isWorkload && <a className="mt-3 inline-block text-xs text-accent hover:underline" href="https://github.com/skyhook-io/radar/blob/main/docs/workload-metrics.md#what-each-chart-needs" target="_blank" rel="noopener noreferrer">What each chart needs</a>}
104
146
  </div>
105
147
  </div>
106
- )
148
+ );
107
149
  }
108
150
 
109
- const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
110
- categories.find(c => c.key === key)
151
+ const findCategory = (
152
+ key: PrometheusMetricCategory,
153
+ ): CategoryDef | undefined => categories.find((c) => c.key === key);
111
154
 
112
- const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
113
- const cpu = findCategory('cpu')
114
- if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
115
- const mem = findCategory('memory')
116
- if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
117
- if (kind !== 'Node') {
118
- const rx = findCategory('network_rx')
119
- if (rx) primaryCats.push({ def: rx })
120
- const tx = findCategory('network_tx')
121
- if (tx) primaryCats.push({ def: tx })
155
+ const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = [];
156
+ const cpu = findCategory("cpu");
157
+ if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines });
158
+ const mem = findCategory("memory");
159
+ if (mem) primaryCats.push({ def: mem, refLines: memRefLines });
160
+ if (kind !== "Node") {
161
+ const rx = findCategory("network_rx");
162
+ if (rx) primaryCats.push({ def: rx });
163
+ const tx = findCategory("network_tx");
164
+ if (tx) primaryCats.push({ def: tx });
122
165
  }
123
- const disk = findCategory('filesystem')
124
- const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
166
+ const disk = findCategory("filesystem");
167
+ const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats;
168
+ const renderPanel = ({ def, refLines }: { def: CategoryDef; refLines?: ReferenceLine[] }) => (
169
+ <MetricsPanel key={def.key} category={def} kind={kind} namespace={namespace} name={name} timeRange={timeRange} referenceLines={refLines} />
170
+ );
125
171
 
126
- return (
127
- <div className="flex flex-col h-full overflow-auto">
128
- <div className="flex shrink-0 justify-end px-4 pt-3">
129
- <select
172
+ const rangeControl = <select
130
173
  aria-label="Metrics time range"
131
174
  value={timeRange}
132
- onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
133
- className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
175
+ onChange={(e) => {
176
+ const next = new URLSearchParams(searchParams);
177
+ next.set("metricsRange", e.target.value);
178
+ setSearchParams(next, { replace: true });
179
+ }}
180
+ className="ml-auto rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
134
181
  >
135
- {TIME_RANGES.map(tr => (
136
- <option key={tr.value} value={tr.value}>{tr.label}</option>
182
+ {TIME_RANGES.map((tr) => (
183
+ <option key={tr.value} value={tr.value}>
184
+ {tr.label}
185
+ </option>
137
186
  ))}
138
- </select>
139
- </div>
187
+ </select>;
188
+
189
+ return (
190
+ <div className="flex flex-col min-w-0 w-full h-full overflow-auto">
191
+ {!isWorkload && <div className="flex shrink-0 items-center justify-between gap-3 px-4 pt-3">{rangeControl}</div>}
192
+
193
+ {isWorkload && (
194
+ <WorkloadMetricsSection key={`${kind}/${namespace}/${name}`} kind={kind} namespace={namespace} name={name} range={timeRange}
195
+ controls={rangeControl}
196
+ cpuReferenceLines={cpuRefLines} memoryReferenceLines={memRefLines}
197
+ nameMatchedCharts={{ cpu: cpu && renderPanel({ def: cpu }), memory: mem && renderPanel({ def: mem }) }}
198
+ restartLane={showRestartLane && <div className="mb-3"><RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} /></div>} />
199
+ )}
140
200
 
141
201
  {/* Restart lane sits above the grid so its markers visually align with
142
202
  the time axis of the charts below. */}
143
- {showRestartLane && (
203
+ {showRestartLane && !isWorkload && (
144
204
  <div className="px-4 pt-3">
145
- <RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
146
- </div>
147
- )}
148
-
149
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
150
- {chartPanels.map(({ def, refLines }) => (
151
- <MetricsPanel
152
- key={def.key}
153
- category={def}
205
+ <RestartEventLane
154
206
  kind={kind}
155
207
  namespace={namespace}
156
208
  name={name}
157
- timeRange={timeRange}
158
- referenceLines={refLines}
209
+ range={timeRange}
159
210
  />
160
- ))}
211
+ </div>
212
+ )}
213
+
214
+ <div className="metrics-layout min-w-0 px-4 pt-4">
215
+ {isWorkload && <h3 className="mb-2 text-sm font-semibold text-theme-text-primary">Network and storage</h3>}
216
+ {isWorkload && <details className="mb-2 text-xs text-theme-text-secondary">
217
+ <summary className="cursor-pointer">Pod-name matched · identity unverified</summary>
218
+ <p className="mt-2 max-w-2xl text-sm leading-relaxed">These charts match current Pod names, independently of the identity-checked charts above. Matching names in a shared backend may include another cluster.</p>
219
+ </details>}
220
+ <div className="metrics-chart-grid">
221
+ {chartPanels.filter(({ def }) => !isWorkload || (def.key !== "cpu" && def.key !== "memory")).map(renderPanel)}
222
+ </div>
161
223
  </div>
224
+
225
+ <PodCoverageNote
226
+ kind={kind}
227
+ namespace={namespace}
228
+ name={name}
229
+ timeRange={timeRange}
230
+ category={isWorkload ? "network_rx" : "cpu"}
231
+ />
232
+ {["Deployment", "StatefulSet", "DaemonSet"].includes(kind) && (
233
+ <div className="px-4 pb-4"><RightsizingStrip kind={kind} namespace={namespace} name={name} /></div>
234
+ )}
162
235
  </div>
163
- )
236
+ );
237
+ }
238
+
239
+ function PodCoverageNote({
240
+ kind,
241
+ namespace,
242
+ name,
243
+ timeRange,
244
+ category,
245
+ }: {
246
+ kind: string;
247
+ namespace: string;
248
+ name: string;
249
+ timeRange: PrometheusTimeRange;
250
+ category: PrometheusMetricCategory;
251
+ }) {
252
+ const { data } = usePrometheusResourceMetrics(
253
+ kind,
254
+ namespace,
255
+ name,
256
+ category,
257
+ timeRange,
258
+ true,
259
+ );
260
+ const note = data ? describePodCoverage(data) : undefined;
261
+ if (!note) return null;
262
+ return <p className="px-4 pb-4 text-xs text-theme-text-tertiary">{note}</p>;
164
263
  }
165
264
 
166
265
  interface MetricsPanelProps {
167
- category: CategoryDef
168
- kind: string
169
- namespace: string
170
- name: string
171
- timeRange: PrometheusTimeRange
172
- referenceLines?: ReferenceLine[]
266
+ category: CategoryDef;
267
+ kind: string;
268
+ namespace: string;
269
+ name: string;
270
+ timeRange: PrometheusTimeRange;
271
+ referenceLines?: ReferenceLine[];
173
272
  }
174
273
 
175
- function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
176
- const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
177
- kind, namespace, name, category.key, timeRange, true,
178
- )
274
+ function MetricsPanel({
275
+ category,
276
+ kind,
277
+ namespace,
278
+ name,
279
+ timeRange,
280
+ referenceLines,
281
+ }: MetricsPanelProps) {
282
+ const {
283
+ data: metrics,
284
+ isLoading,
285
+ error,
286
+ } = usePrometheusResourceMetrics(
287
+ kind,
288
+ namespace,
289
+ name,
290
+ category.key,
291
+ timeRange,
292
+ true,
293
+ );
179
294
 
180
- const series = metrics?.result?.series
181
- const hasData = (series?.length ?? 0) > 0
295
+ const series = metrics?.result?.series;
296
+ const hasData = (series?.length ?? 0) > 0;
182
297
 
183
298
  // "% of limit / request" derived from current peak vs reference lines.
184
299
  // Without this, a low-utilization workload with a high limit looks like
185
300
  // an empty chart — the user can't tell healthy from starved at a glance.
186
- const saturation = hasData && series && referenceLines
187
- ? computeSaturation(series, referenceLines)
188
- : undefined
301
+ const saturation =
302
+ hasData && series && referenceLines
303
+ ? computeSaturation(series, referenceLines)
304
+ : undefined;
189
305
 
190
306
  return (
191
- <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
192
- <header className="flex items-center justify-between mb-2 gap-3">
193
- <div className="flex items-center gap-2">
307
+ <section className="metrics-chart rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-w-0">
308
+ <header className="flex flex-wrap items-center justify-between mb-2 gap-3">
309
+ <div className="flex flex-wrap items-center gap-2">
194
310
  <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
195
311
  {category.label}
196
312
  </h3>
197
313
  {saturation && <SaturationChip {...saturation} />}
198
314
  </div>
199
315
  {hasData && series && (
200
- <MetricsSummary series={series} category={category} unit={metrics!.unit} />
316
+ <MetricsSummary
317
+ series={series}
318
+ category={category}
319
+ unit={metrics!.unit}
320
+ />
201
321
  )}
202
322
  </header>
203
323
 
204
- <div className="flex-1 min-h-[200px]">
324
+ <div className="min-w-0">
205
325
  {isLoading ? (
206
326
  <PanelLoading />
207
327
  ) : error ? (
@@ -214,6 +334,7 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
214
334
  fillColor={category.fillColor}
215
335
  unit={metrics!.unit}
216
336
  referenceLines={referenceLines}
337
+ layout="dashboard"
217
338
  />
218
339
  {series.length > 1 && (
219
340
  <div className="mt-1.5">
@@ -226,45 +347,66 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
226
347
  )}
227
348
  </div>
228
349
  </section>
229
- )
350
+ );
230
351
  }
231
352
 
232
- function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
353
+ function SaturationChip({
354
+ ratio,
355
+ against,
356
+ }: {
357
+ ratio: number;
358
+ against: "limit" | "request";
359
+ }) {
233
360
  // Thresholds match the rightsizing tone vocabulary: amber from 75% (start
234
361
  // watching), red at 90% (the same OOM-risk boundary the backend uses for
235
362
  // memory in classifyRightsizing).
236
- const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
237
- const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
238
- return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
363
+ const tone: Severity =
364
+ ratio >= 0.9
365
+ ? "error"
366
+ : ratio >= 0.75
367
+ ? "warning"
368
+ : ratio < 0.05
369
+ ? "info"
370
+ : "neutral";
371
+ const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`;
372
+ return (
373
+ <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
374
+ );
239
375
  }
240
376
 
241
377
  function PanelLoading() {
242
378
  return (
243
- <div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
379
+ <div className="flex items-center justify-center h-[240px] text-theme-text-tertiary text-xs">
244
380
  <Loader2 className="w-4 h-4 animate-spin mr-2" />
245
381
  Loading…
246
382
  </div>
247
- )
383
+ );
248
384
  }
249
385
 
250
386
  function PanelError({ message }: { message: string }) {
251
387
  return (
252
- <div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
388
+ <div
389
+ className={`flex flex-col items-center justify-center h-[240px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
390
+ >
253
391
  Query failed
254
392
  <Tooltip content={message} wrapperClassName="!block w-full">
255
- <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">{message}</span>
393
+ <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">
394
+ {message}
395
+ </span>
256
396
  </Tooltip>
257
397
  </div>
258
- )
398
+ );
259
399
  }
260
400
 
261
401
  function PanelNoData({ hint }: { hint?: string }) {
262
402
  return (
263
- <div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
403
+ <div className="flex flex-col items-center justify-center h-[240px] text-theme-text-tertiary text-xs px-3 text-center">
264
404
  No data
265
- {hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
405
+ {hint && (
406
+ <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>
407
+ )}
266
408
  </div>
267
- )
409
+ );
268
410
  }
269
411
 
270
- export { isPrometheusSupported } from './PrometheusCharts'
412
+ export { isPrometheusSupported } from "./PrometheusCharts";
@@ -107,3 +107,41 @@ describe('rightsizing presentation', () => {
107
107
  expect(RIGHTSIZING_DOCS_URL).toContain('/features/rightsizing')
108
108
  })
109
109
  })
110
+
111
+ describe('live pod inventory', () => {
112
+ it('qualifies a memory row when the pod list could not be read', () => {
113
+ const explanation = getRightsizingExplanation(
114
+ row({ resource: 'memory', liveInventoryUnavailable: true }),
115
+ )
116
+ expect(explanation).toContain('could not read')
117
+ expect(explanation).toContain('out-of-memory restarts')
118
+ })
119
+
120
+ it('adds the caveat to a suppression reason instead of replacing it', () => {
121
+ const explanation = getRightsizingExplanation(
122
+ row({ resource: 'memory', recommendationReason: 'oom_evidence', liveInventoryUnavailable: true }),
123
+ )
124
+ expect(explanation).toContain('OOM evidence exists')
125
+ expect(explanation).toContain('could not read')
126
+ })
127
+
128
+ it('keeps the caveat on a staged reduction, where an unchecked pod matters most', () => {
129
+ const explanation = getRightsizingExplanation(
130
+ row({
131
+ resource: 'memory',
132
+ liveInventoryUnavailable: true,
133
+ reductionLimited: true,
134
+ calculatedRequest: '256Mi',
135
+ recommendedRequest: '384Mi',
136
+ }),
137
+ )
138
+ expect(explanation).toContain('conservative next step')
139
+ expect(explanation).toContain('could not read')
140
+ })
141
+
142
+ it('says nothing when the pods were read', () => {
143
+ expect(getRightsizingExplanation(row({ resource: 'memory' })) ?? '').not.toContain(
144
+ 'could not read',
145
+ )
146
+ })
147
+ })
@@ -328,16 +328,28 @@ export function getRightsizingExplanation(row: RightsizingRow): string | undefin
328
328
  recommended_request_exceeds_limit:
329
329
  'The evidence-based request would exceed the current limit; review the limit first.',
330
330
  }
331
+ // A denied pod read leaves currentPodOOM unknown rather than false, so it
332
+ // qualifies whatever else is said instead of replacing it — including a
333
+ // staged reduction, which is exactly where an unchecked pod matters most.
334
+ const inventoryNote =
335
+ row.liveInventoryUnavailable && row.resource === 'memory'
336
+ ? "Radar could not read this workload's pods, so it could not check them for out-of-memory restarts."
337
+ : undefined
338
+ const qualified = (base: string) => (inventoryNote ? `${base} ${inventoryNote}` : base)
331
339
  if (row.reductionLimited && row.calculatedRequest && row.recommendedRequest) {
332
340
  const burstNote = row.bursty && row.peak ? ` CPU P99 reached ${row.peak.formatted}.` : ''
333
- return `Demand-based target: ${row.calculatedRequest}. Radar suggests ${row.recommendedRequest} as a conservative next step; observe another full window before reducing further.${burstNote}`
341
+ return qualified(
342
+ `Demand-based target: ${row.calculatedRequest}. Radar suggests ${row.recommendedRequest} as a conservative next step; observe another full window before reducing further.${burstNote}`,
343
+ )
334
344
  }
335
345
  const message = row.queryError
336
346
  ? `Partial result: ${row.queryError}.`
337
347
  : messages[row.recommendationReason ?? '']
338
348
  const throttleUnavailable = row.resource === 'cpu' && !row.throttleAvailable
339
- if (!message && !throttleUnavailable) return undefined
340
- return message ?? 'CPU throttling metrics are unavailable; no throttling conclusion was drawn.'
349
+ if (!message && !throttleUnavailable) return inventoryNote
350
+ return qualified(
351
+ message ?? 'CPU throttling metrics are unavailable; no throttling conclusion was drawn.',
352
+ )
341
353
  }
342
354
 
343
355
  function confidenceLabel(row: RightsizingRow) {
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { WorkloadMetricsHelpContent } from './WorkloadMetricsHelpDialog'
4
+ import type { WorkloadMetrics } from '../../api/workloadMetrics'
5
+
6
+ const data = (attribution: Record<string, string>): WorkloadMetrics => ({
7
+ attribution, state: 'available', sources: [], history: {}, comparison: {}, panels: {},
8
+ pods: 2, podsTotal: 2, start: 100, end: 160, stepSeconds: 60, rateWindowSeconds: 300,
9
+ })
10
+
11
+ describe('Metrics help', () => {
12
+ it('groups only identical resource evidence, not HTTP observers or different methods', () => {
13
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} data={data({
14
+ cpu: 'UID match', memory: 'UID match', throttling: 'Label match', beyla: 'UID match', istio: 'No observations',
15
+ })} />)
16
+ expect(html).toContain('CPU / Memory')
17
+ expect(html).not.toContain('CPU / Memory / Throttling')
18
+ expect(html).toContain('Beyla · HTTP requests')
19
+ expect(html).toContain('Istio · HTTP requests')
20
+ expect(html.match(/UID match/g)).toHaveLength(2)
21
+ expect(html).toContain('Label match')
22
+ expect(html).toContain('No observations')
23
+ })
24
+ it('separates asserted scope and history from current-Pod matching', () => {
25
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} data={data({ scope: 'Operator assertion', history: 'Retained ownership' })} />)
26
+ expect(html).toContain('Operator-asserted scope')
27
+ expect(html).toContain('Historical ownership')
28
+ expect(html).not.toContain('<table')
29
+ expect(html).not.toContain('No current-Pod matching evidence')
30
+ expect(html).toContain('Each chart’s history label')
31
+ })
32
+ it('keeps operator overrides collapsed and explains their limits', () => {
33
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} data={data({})} />)
34
+ expect(html).toContain('Troubleshooting identity matching')
35
+ expect(html).toContain('--prometheus-single-cluster')
36
+ expect(html).toContain('they do not add missing metrics')
37
+ expect(html).toContain('Leave both unset for automatic matching')
38
+ expect(html).toContain('it does not scope rightsizing')
39
+ expect(html).toContain('Standalone CLI:')
40
+ expect(html).toContain('when starting Radar')
41
+ expect(html).toContain('traffic.prometheusSingleCluster')
42
+ expect(html).toContain('traffic.prometheusClusterLabels')
43
+ expect(html).toContain('In-cluster OSS or Radar Cloud:')
44
+ expect(html).toContain('not viewer preferences')
45
+ expect(html).toContain('Desktop does not expose these overrides yet')
46
+ expect(html).toContain('only alongside a verified scope override')
47
+ expect(html).not.toMatch(/<details[^>]*\bopen[=> ]/)
48
+ })
49
+ it('distinguishes loading, failed refresh and absent evidence', () => {
50
+ const pending = renderToStaticMarkup(<WorkloadMetricsHelpContent pending />)
51
+ expect(pending).toContain('Checking the selected metrics')
52
+ expect(pending).not.toContain('No current-Pod matching evidence')
53
+ const failed = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} error={new Error('Query failed')} data={data({ cpu: 'Previous evidence' })} />)
54
+ expect(failed).toContain('Query failed')
55
+ expect(failed).toContain('Evidence below is from the previous response')
56
+ const empty = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} />)
57
+ expect(empty).toContain('No current-Pod matching evidence is available yet')
58
+ })
59
+ it('does not deny historical evidence when there are no current-Pod matches', () => {
60
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} data={data({ history: 'Retained ownership is available' })} />)
61
+ expect(html).toContain('No current-Pod matching evidence')
62
+ expect(html).toContain('Retained ownership is available')
63
+ })
64
+ it.each(['detecting', 'error'] as const)('shows %s comparison independently of usable history', (state) => {
65
+ const response = data({ history: 'Historical ownership is available' })
66
+ response.reason = 'History follows retained workload ownership'
67
+ response.comparison = { cpu: { state, reason: 'Current identity status', unit: 'cores', series: [] } }
68
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} data={response} />)
69
+ expect(html).toContain('Current identity status')
70
+ expect(html).toContain('Historical ownership is available')
71
+ expect(html).not.toContain(response.reason)
72
+ expect(html).not.toContain('No current-Pod matching evidence')
73
+ })
74
+ it('describes a first-load failure without implying previous evidence', () => {
75
+ const html = renderToStaticMarkup(<WorkloadMetricsHelpContent pending={false} error={new Error('Query failed')} />)
76
+ expect(html).toContain('could not be loaded')
77
+ expect(html).not.toContain('refreshed')
78
+ })
79
+ })