@skyhook-io/radar-app 1.13.4 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,145 @@
1
+ import { useLayoutEffect, useId, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ExternalLink, X } from 'lucide-react'
4
+ import type { WorkloadMetrics } from '../../api/workloadMetrics'
5
+ import type { PrometheusTimeRange } from '../../api/client'
6
+
7
+ interface Props {
8
+ kind: string
9
+ namespace: string
10
+ name: string
11
+ range: PrometheusTimeRange
12
+ data?: WorkloadMetrics
13
+ pending: boolean
14
+ error?: Error | null
15
+ onClose: () => void
16
+ }
17
+
18
+ export function WorkloadMetricsHelpDialog({ onClose, ...props }: Props) {
19
+ const dialog = useRef<HTMLDialogElement>(null)
20
+ const backdropPointerDown = useRef(false)
21
+ const titleId = useId()
22
+ useLayoutEffect(() => {
23
+ const element = dialog.current!
24
+ element.showModal()
25
+ return () => element.close()
26
+ }, [])
27
+
28
+ return createPortal(
29
+ <dialog
30
+ ref={dialog}
31
+ aria-labelledby={titleId}
32
+ className="dialog m-auto w-[800px] max-w-[calc(100vw-2rem)] max-h-[85dvh] p-0 text-theme-text-primary open:flex open:flex-col backdrop:bg-black/60 backdrop:backdrop-blur-sm"
33
+ onCancel={(event) => { event.preventDefault(); onClose() }}
34
+ onKeyDown={(event) => event.stopPropagation()}
35
+ onPointerDown={(event) => {
36
+ const bounds = event.currentTarget.getBoundingClientRect()
37
+ backdropPointerDown.current = event.target === event.currentTarget && (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom)
38
+ }}
39
+ onClick={(event) => {
40
+ if (!backdropPointerDown.current || event.target !== event.currentTarget) return
41
+ backdropPointerDown.current = false
42
+ const bounds = event.currentTarget.getBoundingClientRect()
43
+ if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) onClose()
44
+ }}
45
+ >
46
+ <header className="flex shrink-0 items-start justify-between gap-4 border-b border-theme-border px-6 py-4">
47
+ <div className="min-w-0">
48
+ <h2 id={titleId} className="text-lg font-semibold">Metrics sources &amp; coverage</h2>
49
+ <p className="mt-1 break-words text-sm text-theme-text-tertiary">{props.kind} · {props.namespace}/{props.name} · {props.range}</p>
50
+ </div>
51
+ <button type="button" autoFocus onClick={onClose} aria-label="Close metrics help" className="rounded-md p-1.5 text-theme-text-secondary hover:bg-theme-hover focus-visible:outline-accent">
52
+ <X aria-hidden="true" className="h-5 w-5" />
53
+ </button>
54
+ </header>
55
+ <div className="min-h-0 overflow-y-auto px-6 py-5">
56
+ <WorkloadMetricsHelpContent {...props} />
57
+ </div>
58
+ <footer className="shrink-0 border-t border-theme-border px-6 py-3">
59
+ <a className="inline-flex items-center gap-1.5 text-sm text-accent-text hover:underline" href="https://github.com/skyhook-io/radar/blob/main/docs/workload-metrics.md#what-each-chart-needs" target="_blank" rel="noopener noreferrer">
60
+ What each chart needs <ExternalLink aria-hidden="true" className="h-3.5 w-3.5" />
61
+ </a>
62
+ </footer>
63
+ </dialog>, document.body,
64
+ )
65
+ }
66
+
67
+ const evidenceLabels: Record<string, string> = {
68
+ cpu: 'CPU', memory: 'Memory', throttling: 'Throttling', beyla: 'Beyla · HTTP requests', istio: 'Istio · HTTP requests',
69
+ }
70
+
71
+ export function WorkloadMetricsHelpContent({ data, pending, error }: Pick<Props, 'data' | 'pending' | 'error'>) {
72
+ const evidence = Object.entries(data?.attribution ?? {}).filter(([key]) => key !== 'scope' && key !== 'history')
73
+ const resourceRows = new Map<string, string[]>()
74
+ for (const [key, reason] of evidence) {
75
+ if (!['cpu', 'memory', 'throttling'].includes(key)) continue
76
+ const labels = resourceRows.get(reason) ?? []
77
+ labels.push(evidenceLabels[key])
78
+ resourceRows.set(reason, labels)
79
+ }
80
+ const rows = [
81
+ ...Array.from(resourceRows, ([reason, labels]) => ({ label: labels.join(' / '), reason })),
82
+ ...evidence.filter(([key]) => !['cpu', 'memory', 'throttling'].includes(key)).map(([key, reason]) => ({ label: evidenceLabels[key] ?? key, reason })),
83
+ ]
84
+ const comparisonNotices = Array.from(new Set(Object.values(data?.comparison ?? {})
85
+ .filter((panel) => panel.state === 'detecting' || panel.state === 'error')
86
+ .map((panel) => panel.reason || (panel.state === 'detecting' ? 'Matching current-Pod metrics…' : 'Current-Pod comparison could not be loaded.'))))
87
+ const emptyReason = data && ['detecting', 'error', 'unavailable'].includes(data.state) ? data.reason : undefined
88
+ return <div className="space-y-6 text-sm leading-relaxed text-theme-text-secondary">
89
+ <section>
90
+ <h3 className="mb-1 font-semibold text-theme-text-primary">Source attribution details</h3>
91
+ <p className="text-theme-text-tertiary">Sources are checked independently. Identity matching does not mean every Pod has samples throughout the time window.</p>
92
+ {pending && <p role="status" className="mt-3">Checking the selected metrics…{data && ' Evidence below is from the previous response.'}</p>}
93
+ {error && <p role="status" className="mt-3 text-warning-text">Matching evidence could not be {data ? 'refreshed' : 'loaded'}: {error.message}{data && ' Evidence below is from the previous response.'}</p>}
94
+ {rows.length > 0 ? <table className="mt-3 w-full table-fixed text-left">
95
+ <thead className="text-xs text-theme-text-tertiary"><tr>
96
+ <th scope="col" className="w-1/3 border-b border-theme-border pb-2 pr-4 font-medium">Charts / source</th>
97
+ <th scope="col" className="border-b border-theme-border pb-2 font-medium">Current-Pod matching</th>
98
+ </tr></thead>
99
+ <tbody>{rows.map(({ label, reason }) => <tr key={label} className="border-b border-theme-border-subtle">
100
+ <th scope="row" className="py-3 pr-4 align-top font-medium text-theme-text-primary">{label}</th>
101
+ <td className="break-words py-3 align-top">{reason}</td>
102
+ </tr>)}</tbody>
103
+ </table> : !pending && !error && !data?.attribution?.scope && comparisonNotices.length === 0 && <p className="mt-3">{emptyReason || 'No current-Pod matching evidence is available yet.'}</p>}
104
+ {comparisonNotices.length > 0 && <div className="mt-3">
105
+ <h4 className="font-medium text-theme-text-primary">Current-Pod comparison</h4>
106
+ {comparisonNotices.map((reason) => <p role="status" key={reason}>{reason}</p>)}
107
+ </div>}
108
+ {data?.attribution?.scope && <div className="mt-3 border-l-2 border-theme-border pl-3">
109
+ <h4 className="font-medium text-theme-text-primary">Operator-asserted scope</h4>
110
+ <p>{data.attribution.scope}</p>
111
+ </div>}
112
+ {data?.attribution?.history && <div className="mt-3 border-l-2 border-theme-border pl-3">
113
+ <h4 className="font-medium text-theme-text-primary">Historical ownership</h4>
114
+ <p>{data.attribution.history}</p>
115
+ <p className="mt-1 text-theme-text-tertiary">Each chart’s history label reports whether that chart can use retained ownership.</p>
116
+ </div>}
117
+ </section>
118
+ <section className="border-t border-theme-border pt-5">
119
+ <h3 className="mb-3 font-semibold text-theme-text-primary">Reading the charts</h3>
120
+ <dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2">
121
+ <div><dt className="font-medium text-theme-text-primary">Workload history vs current Pods</dt><dd className="mt-1">History follows retained ownership at each timestamp, including previous replicas. “Current Pods only” excludes previous replicas. Recreation under the same workload name is included in history.</dd></div>
122
+ <div><dt className="font-medium text-theme-text-primary">Missing is not zero</dt><dd className="mt-1">Unmatched Pods are excluded. Missing ownership or samples produces gaps. An idle workload and an uninstrumented one can both have no HTTP observations.</dd></div>
123
+ <div><dt className="font-medium text-theme-text-primary">HTTP observations</dt><dd className="mt-1">Ports and processes are combined, including health checks and admin traffic. HTTP 5xx is not a gRPC error rate and excludes failures without an HTTP response. Latency is measured at the selected observer, not end to end.</dd></div>
124
+ <div><dt className="font-medium text-theme-text-primary">Resource usage</dt><dd className="mt-1">CPU and memory include reporting containers and sidecars. Their historical charts show the workload total and maximum Pod; current-Pod charts show individual Pods. Throttling measures CFS periods, not CPU time lost.</dd></div>
125
+ </dl>
126
+ </section>
127
+ <details className="border-t border-theme-border pt-4">
128
+ <summary className="cursor-pointer font-medium text-theme-text-primary">Troubleshooting identity matching</summary>
129
+ <div className="mt-3 space-y-3">
130
+ <p>Check the affected chart’s warning first. Scope overrides address cluster identity; they do not add missing metrics, instrumentation or ownership history.</p>
131
+ <p>If automatic matching cannot establish identity, an operator can explicitly assert the backend’s scope:</p>
132
+ <dl className="space-y-3">
133
+ <div><dt className="font-medium">Backend dedicated to this cluster</dt><dd><code className="break-all font-mono text-xs">--prometheus-single-cluster</code><p className="mt-1">Helm: <code className="break-all font-mono text-xs">traffic.prometheusSingleCluster: true</code></p></dd></div>
134
+ <div><dt className="font-medium">Shared backend with a cluster label</dt><dd><code className="break-all font-mono text-xs">--prometheus-cluster-label cluster=your-cluster</code><p className="mt-1">Helm: set the backend’s actual label and value in <code className="break-all font-mono text-xs">traffic.prometheusClusterLabels</code>.</p></dd></div>
135
+ </dl>
136
+ <p>Leave both unset for automatic matching. An override replaces automatic matching for workload requests, resources, history and Pod comparison only; it does not scope rightsizing or other metrics features.</p>
137
+ <h4 className="font-medium text-theme-text-primary">Operator configuration</h4>
138
+ <p><strong className="font-medium">Standalone CLI:</strong> supply the flag when starting Radar. Overrides are not saved; after changing the cluster, backend or credentials, verify the new scope before restarting with a fresh override.</p>
139
+ <p><strong className="font-medium">In-cluster OSS or Radar Cloud:</strong> ask the installation’s operator to update its Helm values or GitOps configuration. These are shared backend settings, not viewer preferences.</p>
140
+ <p><strong className="font-medium">Desktop:</strong> automatic matching works without flags. Desktop does not expose these overrides yet; if one is required, use the standalone CLI with a verified scope.</p>
141
+ <p><strong className="font-medium">Multiple Beyla observation jobs:</strong> workload charts currently honor <code className="font-mono text-xs">--beyla-job-selector</code> only alongside a verified scope override. Use one matcher, such as <code className="font-mono text-xs">{'\'job="primary-beyla"\''}</code>; Helm uses <code className="font-mono text-xs">traffic.beylaJobSelector</code>. A custom job name alone normally needs no override.</p>
142
+ </div>
143
+ </details>
144
+ </div>
145
+ }
@@ -0,0 +1,351 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { MemoryRouter } from 'react-router-dom'
4
+ import type { WorkloadMetrics, WorkloadMetricPanel } from '../../api/workloadMetrics'
5
+
6
+ let result: { data?: WorkloadMetrics; isLoading?: boolean; isPlaceholderData?: boolean; error?: Error } = {}
7
+ vi.mock('../../api/workloadMetrics', () => ({ useWorkloadMetrics: () => result }))
8
+ const { WorkloadMetricsSection } = await import('./WorkloadMetricsSection')
9
+ const panel = (value: number, unit = 'requests/s'): WorkloadMetricPanel => ({
10
+ state: 'available', unit,
11
+ series: [{ labels: { pod: 'checkout-0' }, dataPoints: [{ timestamp: 100, value }, { timestamp: 160, value }] }],
12
+ })
13
+ const render = () => renderToString(<MemoryRouter><WorkloadMetricsSection kind="Deployment" namespace="shop" name="checkout" range="1h" /></MemoryRouter>)
14
+
15
+ beforeEach(() => {
16
+ result = { data: {
17
+ state: 'available', source: 'beyla',
18
+ sources: [{ id: 'beyla', label: 'Beyla', state: 'available' }],
19
+ pods: 2, podsTotal: 2, start: 100, end: 160, stepSeconds: 60, rateWindowSeconds: 300,
20
+ history: { cpu: { mode: 'current-pods', reason: 'Missing retained ownership.' }, requests: { mode: 'current-pods' } },
21
+ comparison: { cpu: panel(0, 'cores') },
22
+ panels: { requests: panel(0), observedPods: panel(1, 'pods'), cpu: panel(0, 'cores') },
23
+ } }
24
+ })
25
+
26
+ describe('Workload metrics interpretation', () => {
27
+ it('shows matching evidence instead of claiming no observations when no observer is usable', () => {
28
+ result.data!.source = undefined
29
+ result.data!.sources = [{ id: 'beyla', label: 'Beyla', state: 'unavailable' }]
30
+ result.data!.attribution = { beyla: 'Metrics exist, but their cluster identity could not be established automatically.' }
31
+ result.data!.panels.requests = { state: 'unavailable', unit: 'requests/s', series: [], reason: 'Generic prerequisites' }
32
+ const html = render()
33
+ expect(html).toContain('No usable HTTP metrics in this window')
34
+ expect(html).toContain('Metrics exist, but their cluster identity')
35
+ expect(html).not.toContain('Generic prerequisites')
36
+ expect(html).not.toContain('No HTTP observations')
37
+ })
38
+ it('preserves a selected observer’s query reason rather than replacing it with matching evidence', () => {
39
+ result.data!.attribution = { beyla: 'Matched to current Pod UIDs' }
40
+ result.data!.panels.requests = { state: 'unavailable', unit: 'requests/s', series: [], reason: 'No samples from the selected observer' }
41
+ const html = render()
42
+ expect(html).toContain('No samples from the selected observer')
43
+ expect(html).not.toContain('Matched to current Pod UIDs')
44
+ })
45
+ it('separates historical aggregates from bounded current comparison', () => {
46
+ result.data!.podsTotal = 128
47
+ result.data!.pods = 100
48
+ result.data!.history = { cpu: { mode: 'workload-history' }, requests: { mode: 'workload-history' } }
49
+ result.data!.panels.cpu = { ...panel(128, 'cores'), series: [
50
+ { labels: { aggregation: 'Workload' }, dataPoints: [{ timestamp: 160, value: 128 }] },
51
+ { labels: { aggregation: 'Maximum Pod' }, dataPoints: [{ timestamp: 160, value: 1 }] },
52
+ ] }
53
+ const html = render().replaceAll('<!-- -->', '')
54
+ expect(html).toContain('CPU usage · workload')
55
+ expect(html).toContain('Maximum Pod')
56
+ expect(html).toContain('1 Pods reporting')
57
+ expect(html).not.toContain('of 128 current Pods reporting')
58
+ expect(html).toContain('checkout-0')
59
+ expect(html).toContain('Comparing 100 of 128 current Pods')
60
+ expect(html).toContain('when ownership disappears after Pod deletion')
61
+ })
62
+ it('names the current-only fallback rather than implying full history', () => {
63
+ const html = render()
64
+ expect(html).toContain('Current Pods only')
65
+ expect(html).toContain('Missing retained ownership')
66
+ })
67
+ it('keeps name-matched fallback charts separate from identity-checked metrics', () => {
68
+ result.data!.panels.cpu = { state: 'error', unit: 'cores', reason: 'History lookup failed', series: [] }
69
+ result.data!.panels.memory = panel(100, 'bytes')
70
+ const html = renderToString(<MemoryRouter><WorkloadMetricsSection kind="Deployment" namespace="shop" name="checkout" range="1h" nameMatchedCharts={{ cpu: <span>Existing CPU chart</span>, memory: <span>Existing memory chart</span> }} /></MemoryRouter>)
71
+ expect(html.replaceAll('<!-- -->', '')).toContain('Basic CPU · identity unverified')
72
+ expect(html).toContain('<details aria-label="Name-matched resource metrics">')
73
+ expect(html).toContain('matching names in a shared backend may include another cluster')
74
+ expect(html).toContain('Existing CPU chart')
75
+ expect(html).toContain('History lookup failed')
76
+ expect(html).not.toContain('Existing memory chart')
77
+ })
78
+ it('does not flash name-matched charts during the initial request', () => {
79
+ result = { isLoading: true }
80
+ const html = renderToString(<MemoryRouter><WorkloadMetricsSection kind="Deployment" namespace="shop" name="checkout" range="1h" nameMatchedCharts={{ cpu: <span>Existing CPU chart</span> }} /></MemoryRouter>)
81
+ expect(html).not.toContain('Existing CPU chart')
82
+ expect(html).toContain('Checking request metrics')
83
+ })
84
+ it('shows only supplied template values and qualifies actual Pod differences', () => {
85
+ const html = renderToString(<MemoryRouter><WorkloadMetricsSection kind="Deployment" namespace="shop" name="checkout" range="1h" cpuReferenceLines={[{ value: 0.1, label: 'request 100m', kind: 'request' }]} memoryReferenceLines={[]} /></MemoryRouter>).replaceAll('<!-- -->', '')
86
+ expect(html).toContain('Template per Pod · actual Pods may differ')
87
+ expect(html).toContain('Template request 100m')
88
+ expect(html).toContain('Actual Pods can differ after injection or rollout.')
89
+ expect(html).not.toContain(' · Memory:')
90
+ expect(html).not.toContain('unlimited')
91
+ expect(render()).not.toContain('Template per Pod')
92
+ })
93
+ it.each([true, false])('keeps absent HTTP compact without narrating resource availability: %s', (usable) => {
94
+ result.data!.panels.requests = { state: 'unavailable', unit: 'requests/s', series: [], reason: 'No observations' }
95
+ result.data!.panels.cpu = usable ? panel(0, 'cores') : { state: 'error', unit: 'cores', series: [], reason: 'Query failed' }
96
+ const html = render()
97
+ expect(html).toContain('No usable HTTP metrics in this window')
98
+ expect(html).not.toContain('Resource charts below remain available')
99
+ expect(html).not.toContain('--beyla-job-selector')
100
+ if (!usable) expect(html).not.toContain('CPU usage · per Pod')
101
+ })
102
+ it('does not call pending attribution a lack of HTTP traffic', () => {
103
+ result.data!.panels.requests = { state: 'detecting', unit: 'requests/s', series: [], reason: 'Matching identity' }
104
+ const html = render()
105
+ expect(html).toContain('Checking request metrics…')
106
+ expect(html).toContain('Matching identity')
107
+ expect(html).not.toContain('No usable HTTP metrics')
108
+ })
109
+ it('keeps empty resource titles neutral even with an all-null series', () => {
110
+ result.data!.panels.cpu!.series[0].dataPoints = [{ timestamp: 160, value: null }]
111
+ const html = render()
112
+ expect(html).toContain('CPU usage')
113
+ expect(html).not.toContain('CPU usage · per Pod')
114
+ })
115
+ it('discloses combined request ports and reporting sidecars', () => {
116
+ const html = render()
117
+ expect(html).toContain('Ports combined · includes health checks and admin traffic.')
118
+ expect(html).toContain('Resource totals include reporting containers and sidecars.')
119
+ })
120
+ it('describes throttling scope independently of a failed CPU history query', () => {
121
+ result.data!.history.cpu = { mode: 'unavailable' }
122
+ result.data!.history.throttling = { mode: 'workload-history' }
123
+ result.data!.panels.throttling = panel(5, 'percent')
124
+ const html = render().replaceAll('<!-- -->', '')
125
+ expect(html).toContain('Throttling is weighted by total periods')
126
+ expect(html).not.toContain('Examines 2 of 2 current Pods')
127
+ })
128
+ it('does not describe aggregate series in current-Pod help', () => {
129
+ result.data!.history.throttling = { mode: 'current-pods' }
130
+ result.data!.panels.throttling = panel(5, 'percent')
131
+ const html = render()
132
+ expect(html).not.toContain('Workload is the total across reporting Pods')
133
+ expect(html).not.toContain('Throttling is weighted by total periods')
134
+ })
135
+ it('explains a historical memory aggregate without implying historical throttling', () => {
136
+ result.data!.history.memory = { mode: 'workload-history' }
137
+ result.data!.panels.memory = panel(100, 'bytes')
138
+ result.data!.history.throttling = { mode: 'current-pods' }
139
+ result.data!.panels.throttling = panel(5, 'percent')
140
+ const html = render()
141
+ expect(html).toContain('In workload-history CPU and memory charts, Workload is the total')
142
+ expect(html).not.toContain('Throttling is weighted by total periods')
143
+ })
144
+ it('retains resource charts while loading another request observer', () => {
145
+ result.isPlaceholderData = true
146
+ const html = render()
147
+ expect(html).toContain('Loading selected request source')
148
+ expect(html).toContain('CPU usage · per Pod')
149
+ expect(html).toContain('Compare current Pods')
150
+ expect(html).not.toContain('1 of 2 current Pods reporting')
151
+ })
152
+ it('labels operator scope and discarded assertions visibly', () => {
153
+ result.data!.attribution = { scope: 'Operator-asserted cluster scope' }
154
+ result.data!.scopeNotice = 'Scope assertion discarded after reconnect'
155
+ const html = render()
156
+ expect(html).toContain('operator-asserted scope')
157
+ expect(html).toContain('Scope assertion discarded after reconnect')
158
+ expect(html).not.toContain('Automatic metrics attribution')
159
+ })
160
+ it('explains withheld populations instead of suggesting instrumentation is absent', () => {
161
+ result.data!.panels.requests = {state:'partial',unit:'requests/s',series:[],reason:'Multiple observation populations'}
162
+ const html = render()
163
+ expect(html).toContain('Request metrics withheld')
164
+ expect(html).not.toContain('No request observations')
165
+ expect(html).not.toContain('--beyla-job-selector')
166
+ })
167
+ it.each(['detecting', 'unavailable', 'error'] as const)('does not substitute unattributed resource charts while metrics are %s', (state) => {
168
+ result.data!.state = state
169
+ result.data!.panels = {}
170
+ const html = render()
171
+ expect(html).not.toContain('CPU usage · per Pod')
172
+ expect(html).not.toContain('Memory working set · per Pod')
173
+ })
174
+ it('groups CPU and memory with throttling before the full-width Pod comparison', () => {
175
+ result.data!.panels.throttling = panel(5, 'percent')
176
+ const html = render()
177
+ expect(html.indexOf('CPU usage · per Pod')).toBeLessThan(html.indexOf('CPU throttled periods'))
178
+ expect(html.indexOf('CPU throttled periods')).toBeLessThan(html.indexOf('Compare current Pods'))
179
+ expect(html).toContain('metrics-chart-wide')
180
+ expect(html).not.toContain('lg:grid-cols-3')
181
+ })
182
+ it('distinguishes reporting Pods from selected Pods and preserves measured zero', () => {
183
+ const html = render()
184
+ expect(html).toContain('1 of 2 current Pods reporting')
185
+ expect(html).toContain('tabular-nums text-theme-text-primary">0</span>')
186
+ expect(html).toContain('previous replicas are not reconstructed')
187
+ expect(html).toContain('/resources/pods?resource=shop%2Fcheckout-0')
188
+ expect(html).toContain('checkout-0')
189
+ })
190
+ it('does not substitute selected count when coverage is missing', () => {
191
+ delete result.data!.panels.observedPods
192
+ expect(render()).toContain('Reporting Pod count unavailable')
193
+ })
194
+ it('keeps operator overrides out of the chart surface', () => {
195
+ result.data!.attribution = { cpu: 'Metrics exist, but their cluster identity could not be established automatically.' }
196
+ const html = render()
197
+ expect(html).toContain('aria-haspopup="dialog"')
198
+ expect(html).not.toContain('--prometheus-single-cluster')
199
+ expect(html).toContain('Workload resource pressure')
200
+ expect(html).not.toContain('operator setup required')
201
+ })
202
+ it('shows detecting without claiming missing data or mandatory setup', () => {
203
+ result.data!.state = 'detecting'
204
+ result.data!.reason = 'Matching metrics to current Pods…'
205
+ result.data!.panels = {}
206
+ const html = render()
207
+ expect(html).toContain('Matching metrics to current Pods')
208
+ expect(html).not.toContain('No request observations')
209
+ expect(html).not.toContain('--prometheus-single-cluster')
210
+ })
211
+ it('discloses partial pressure coverage', () => {
212
+ result.data!.panels.cpu = { ...panel(1, 'cores'), state: 'partial', reason: 'Attributed to 1 of 2 current Pods; other Pods are excluded.' }
213
+ expect(render()).toContain('Attributed to 1 of 2 current Pods')
214
+ })
215
+ it('retains response-level population limitations outside help', () => {
216
+ result.data!.state = 'partial'
217
+ result.data!.reason = 'Current pod population was capped; values cover only the included pods.'
218
+ expect(render()).toContain('Current pod population was capped; values cover only the included pods.')
219
+ })
220
+ it('does not show setup instructions for a workload with no Pods', () => {
221
+ result.data!.state = 'unavailable'
222
+ result.data!.reason = 'No current Pods.'
223
+ expect(render()).not.toContain('operator setup required')
224
+ })
225
+ it('shows query failure rather than fabricating missing metrics', () => {
226
+ result = { error: new Error('Forbidden') }
227
+ expect(render().replaceAll('<!-- -->', '')).toContain('Workload metrics could not be loaded: Forbidden')
228
+ })
229
+ it('keeps observers explicitly separate when both are available', () => {
230
+ result.data!.sources.push({ id: 'istio', label: 'Istio', state: 'available' })
231
+ expect(render()).toContain('Request metrics source')
232
+ expect(render()).toContain('value="beyla" selected=""')
233
+ })
234
+ it('does not suggest a job matcher for a failed request query', () => {
235
+ result.data!.panels.requests = { state: 'error', unit: 'requests/s', series: [], reason: 'Query failed' }
236
+ const html = render()
237
+ expect(html).toContain('Request metrics query failed')
238
+ expect(html).not.toContain('--beyla-job-selector')
239
+ })
240
+ it('describes Istio at the selected destination-sidecar observer', () => {
241
+ result.data!.source = 'istio'
242
+ result.data!.sources = [{ id: 'istio', label: 'Istio · destination sidecars', state: 'available' }]
243
+ const html = render().replaceAll('<!-- -->', '')
244
+ expect(html.match(/Istio · destination sidecars/g)).toHaveLength(1)
245
+ expect(html).toContain('5-minute rates')
246
+ expect(html).not.toContain('HTTP server ·')
247
+ })
248
+ it('does not describe history for an empty Requests section', () => {
249
+ result.data!.panels.requests = { state: 'unavailable', unit: 'requests/s', series: [] }
250
+ result.data!.history.requests = { mode: 'workload-history', reason: 'Request-only history marker' }
251
+ const html = render()
252
+ expect(html).not.toContain('Request-only history marker')
253
+ expect(html).toContain('No usable HTTP metrics in this window')
254
+ })
255
+ it('keeps successful attribution evidence and definitions collapsed', () => {
256
+ result.data!.attribution = { cpu: 'Matched CPU identity' }
257
+ const html = render()
258
+ expect(html).toContain('About these metrics')
259
+ expect(html).not.toContain('Matched CPU identity')
260
+ expect(html).toContain('Resource totals include reporting containers and sidecars.')
261
+ expect(html).not.toMatch(/<details[^>]*\bopen[=> ]/)
262
+ })
263
+ it('deduplicates stale RED notices without losing individual reasons', () => {
264
+ for (const key of ['requests', 'errors', 'p50', 'p95'] as const) {
265
+ result.data!.panels[key] = { ...panel(1), state: 'stale', reason: `${key} history only` }
266
+ }
267
+ const html = render()
268
+ expect(html.match(/no recent samples/g)).toHaveLength(1)
269
+ for (const key of ['requests', 'errors', 'p50', 'p95']) expect(html).toContain(`${key} history only`)
270
+ })
271
+ it('shares identical partial request coverage while keeping the warning visible', () => {
272
+ for (const key of ['requests', 'errors', 'p50', 'p95'] as const) {
273
+ result.data!.panels[key] = { ...panel(1), state: 'partial', reason: 'Only one reporting Pod was identified.' }
274
+ }
275
+ const html = render().replaceAll('<!-- -->', '')
276
+ expect(html.match(/Only one reporting Pod was identified/g)).toHaveLength(1)
277
+ expect(html).toContain('Requests, HTTP 5xx and latency: Only one reporting Pod was identified.')
278
+ })
279
+ it.each([true, false])('shares partial resource coverage only with matching scopes: %s', (sameScope) => {
280
+ for (const key of ['cpu', 'memory', 'throttling'] as const) {
281
+ result.data!.history[key] = { mode: 'current-pods' }
282
+ result.data!.panels[key] = { ...panel(1), state: 'partial', reason: 'Attributed to 1 of 2 Pods.' }
283
+ }
284
+ if (!sameScope) result.data!.history.memory = { mode: 'workload-history' }
285
+ expect(render().match(/Attributed to 1 of 2 Pods/g)).toHaveLength(sameScope ? 1 : 3)
286
+ })
287
+ it('does not hide a withheld chart behind a shared partial warning', () => {
288
+ for (const key of ['requests', 'errors', 'p50', 'p95'] as const) {
289
+ result.data!.panels[key] = { ...panel(1), state: 'partial', reason: 'Incomplete observation population.' }
290
+ }
291
+ result.data!.panels.p95!.series = []
292
+ result.data!.panels.p50!.series = []
293
+ const html = render()
294
+ expect(html).not.toContain('Requests, HTTP 5xx and latency:')
295
+ expect(html.match(/Incomplete observation population/g)).toHaveLength(3)
296
+ })
297
+ it('keeps stale samples withheld by coverage checks local to their chart', () => {
298
+ for (const key of ['requests', 'errors', 'p50', 'p95'] as const) {
299
+ result.data!.panels[key] = { ...panel(1), state: 'stale', reason: 'Historical samples only' }
300
+ }
301
+ result.data!.panels.errors!.series[0].dataPoints = [{ timestamp: 100, value: null }]
302
+ result.data!.panels.errors!.reason = 'Historical samples only. HTTP status coverage is incomplete.'
303
+ const html = render()
304
+ expect(html).not.toContain('Requests, HTTP 5xx and latency:')
305
+ expect(html).toContain('HTTP status coverage is incomplete')
306
+ expect(html).toMatch(/<p role="status"[^>]*>Historical samples only\. HTTP status coverage is incomplete\.<\/p>/)
307
+ })
308
+ it('keeps stale and failed quantiles local when RED states differ', () => {
309
+ result.data!.panels.p95 = { ...panel(1, 'seconds'), state: 'stale', reason: 'Old latency' }
310
+ result.data!.panels.p50 = { state: 'error', unit: 'seconds', series: [], reason: 'Median query failed' }
311
+ const html = render()
312
+ expect(html).toContain('Historical samples only')
313
+ expect(html).toContain('Median query failed')
314
+ expect(html).not.toContain('Requests, HTTP 5xx and latency:')
315
+ })
316
+ it('shows an identical empty-quantile explanation once', () => {
317
+ result.data!.panels.p50 = { state: 'unavailable', unit: 'seconds', series: [], reason: 'No histogram samples' }
318
+ result.data!.panels.p95 = { state: 'unavailable', unit: 'seconds', series: [], reason: 'No histogram samples' }
319
+ expect(render().match(/No histogram samples/g)).toHaveLength(1)
320
+ })
321
+ it('does not repeat a chart error in its history details', () => {
322
+ result.data!.history.cpu = { mode: 'unavailable', reason: 'CPU history query failed' }
323
+ result.data!.panels.cpu = { state: 'error', unit: 'cores', series: [], reason: 'CPU history query failed' }
324
+ expect(render().match(/CPU history query failed/g)).toHaveLength(1)
325
+ })
326
+ it('shows comparison matching once, not as missing Pod samples', () => {
327
+ result.data!.comparison = {
328
+ cpu: { state: 'detecting', unit: 'cores', series: [], reason: 'Matching metrics' },
329
+ memory: { state: 'detecting', unit: 'bytes', series: [], reason: 'Matching metrics' },
330
+ }
331
+ const html = render().replaceAll('<!-- -->', '')
332
+ expect(html.match(/Matching CPU \/ Memory metrics to current Pods/g)).toHaveLength(1)
333
+ expect(html).not.toContain('No Pod samples available')
334
+ })
335
+ it('deduplicates identical comparison failures and retains affected metrics', () => {
336
+ result.data!.comparison = {
337
+ cpu: { state: 'error', unit: 'cores', series: [], reason: 'Comparison query failed' },
338
+ memory: { state: 'error', unit: 'bytes', series: [], reason: 'Comparison query failed' },
339
+ }
340
+ const html = render().replaceAll('<!-- -->', '')
341
+ expect(html.match(/Comparison query failed/g)).toHaveLength(1)
342
+ expect(html).toContain('CPU / Memory: Comparison query failed')
343
+ expect(html).not.toContain('No Pod samples available')
344
+ })
345
+ it('does not explain an empty resource chart in terms of HTTP traffic', () => {
346
+ result.data!.panels.cpu = { state: 'unavailable', unit: 'cores', series: [] }
347
+ const html = render()
348
+ expect(html).toContain('No usable samples in this window')
349
+ expect(html).not.toContain('Idle traffic has no defined error percentage or latency')
350
+ })
351
+ })