@skyhook-io/radar-app 1.13.5 → 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 (201) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +321 -16
  6. package/src/api/diagnose.ts +1 -0
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +179 -42
  13. package/src/components/CloudFunnelButton.tsx +107 -36
  14. package/src/components/SyncProgressPanel.tsx +73 -0
  15. package/src/components/audit/AuditView.tsx +2 -0
  16. package/src/components/cloudConnectHandoff.test.ts +140 -0
  17. package/src/components/cloudConnectHandoff.ts +124 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  19. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  20. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  21. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  22. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  23. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  24. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  25. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  26. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  35. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  36. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  37. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  38. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  39. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  40. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  41. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  42. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  43. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  44. package/src/components/diagnose/target.test.ts +19 -0
  45. package/src/components/diagnose/target.ts +3 -2
  46. package/src/components/gitops/GitOpsView.tsx +7 -0
  47. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  48. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  49. package/src/components/resource/PrometheusCharts.tsx +4 -3
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  51. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  52. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  53. package/src/components/resource/RightsizingStrip.tsx +15 -3
  54. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  55. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  56. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  57. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  58. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  59. package/src/components/resource/workloadMetricValues.ts +30 -0
  60. package/src/components/resources/ResourcesView.tsx +44 -6
  61. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  63. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  64. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  65. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  66. package/src/components/settings/SettingsDialog.tsx +94 -14
  67. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  68. package/src/components/traffic/TrafficGraph.tsx +6 -6
  69. package/src/components/traffic/TrafficView.tsx +10 -9
  70. package/src/components/traffic/trafficFilters.test.ts +28 -1
  71. package/src/components/traffic/trafficFilters.ts +31 -0
  72. package/src/components/workload/WorkloadView.tsx +19 -8
  73. package/src/context/ConnectionContext.tsx +48 -3
  74. package/src/context/DiagnoseCustomization.tsx +1 -0
  75. package/src/index.css +17 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/topology-namespace.test.ts +64 -0
  78. package/src/utils/topology-namespace.ts +18 -0
  79. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  80. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  81. package/src/components/gitops/GitOpsActions.tsx +0 -1
  82. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  83. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  84. package/src/components/gitops/SyncCountdown.tsx +0 -1
  85. package/src/components/gitops/index.ts +0 -4
  86. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  87. package/src/components/logs/JsonLogLine.tsx +0 -1
  88. package/src/components/logs/LogCore.tsx +0 -2
  89. package/src/components/logs/useLogBuffer.ts +0 -2
  90. package/src/components/logs/useLogSearch.ts +0 -1
  91. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  92. package/src/components/resources/drawer-components.tsx +0 -1
  93. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  94. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  95. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  96. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  97. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  98. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  99. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  100. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  101. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  102. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  103. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  104. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  105. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  106. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  107. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  108. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  109. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  110. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  111. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  112. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  113. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  114. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  115. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  116. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  117. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  118. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  119. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  120. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  121. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  122. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  162. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  169. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  170. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  171. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  172. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  173. package/src/components/resources/renderers/index.ts +0 -94
  174. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  175. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  176. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  177. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  178. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  179. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  180. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  181. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  182. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  183. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  184. package/src/components/resources/resource-utils-argo.ts +0 -2
  185. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  186. package/src/components/resources/resource-utils-eso.ts +0 -2
  187. package/src/components/resources/resource-utils-flux.ts +0 -2
  188. package/src/components/resources/resource-utils-istio.ts +0 -2
  189. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  190. package/src/components/resources/resource-utils-knative.ts +0 -2
  191. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  192. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  193. package/src/components/resources/resource-utils-traefik.ts +0 -1
  194. package/src/components/resources/resource-utils-trivy.ts +0 -2
  195. package/src/components/resources/resource-utils-velero.ts +0 -2
  196. package/src/components/shared/EditableYamlView.tsx +0 -24
  197. package/src/components/timeline/DiffViewer.tsx +0 -1
  198. package/src/components/traffic/index.ts +0 -3
  199. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  200. package/src/components/ui/MetricsChart.tsx +0 -1
  201. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,99 @@
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 { NavCustomizationProvider } from '../../context/NavCustomization'
5
+ import type { WorkloadMetrics } from '../../api/workloadMetrics'
6
+
7
+ let connected = false
8
+ const ranges: string[] = []
9
+ const charts: { seriesLabels?: string[]; referenceLines?: unknown[] }[] = []
10
+ let workload: WorkloadMetrics
11
+
12
+ vi.mock('../../api/client', async (importActual) => ({
13
+ ...(await importActual<typeof import('../../api/client')>()),
14
+ useAutoPromConnect: () => undefined,
15
+ usePrometheusStatus: () => ({ data: { connected, error: 'Backend query failed' } }),
16
+ usePrometheusConnect: () => ({ mutate: vi.fn(), isPending: false }),
17
+ usePrometheusResourceMetrics: (_kind: string, _ns: string, _name: string, _category: string, range: string) => {
18
+ ranges.push(range)
19
+ return { data: { unit: 'cores', result: { series: [{ labels: { pod: 'web-0' }, dataPoints: [{ timestamp: 100, value: 1 }, { timestamp: 160, value: 2 }] }] } } }
20
+ },
21
+ }))
22
+ vi.mock('../../api/workloadMetrics', () => ({ useWorkloadMetrics: () => ({ data: workload }) }))
23
+ vi.mock('./RestartChart', () => ({ RestartEventLane: () => null }))
24
+ vi.mock('./RightsizingStrip', () => ({ RightsizingStrip: () => null }))
25
+ vi.mock('@skyhook-io/k8s-ui/components/charts', async (importActual) => ({
26
+ ...(await importActual<typeof import('@skyhook-io/k8s-ui/components/charts')>()),
27
+ AreaChart: (props: typeof charts[number]) => { charts.push(props); return <span>Chart</span> },
28
+ }))
29
+
30
+ const { PrometheusChartsGrid } = await import('./PrometheusChartsGrid')
31
+ const resource = { spec: { template: { spec: { containers: [{ resources: { requests: { cpu: '100m' }, limits: { cpu: '200m' } } }] } } } }
32
+ const render = (url = '/workloads/Deployment/demo/web?tab=metrics', embedded = false) => renderToString(
33
+ <MemoryRouter initialEntries={[url]}><NavCustomizationProvider value={embedded ? { embedded: true, rightExtras: null } : {}}>
34
+ <PrometheusChartsGrid kind="Deployment" namespace="demo" name="web" resource={resource} />
35
+ </NavCustomizationProvider></MemoryRouter>,
36
+ )
37
+
38
+ beforeEach(() => {
39
+ connected = false
40
+ ranges.length = 0
41
+ charts.length = 0
42
+ workload = { state: 'available', sources: [], pods: 1, podsTotal: 1, start: 100, end: 160, stepSeconds: 60, rateWindowSeconds: 300, history: {}, comparison: {}, panels: {} }
43
+ })
44
+
45
+ describe('Expanded metrics recovery and scope', () => {
46
+ it('offers standalone setup without losing the backend error or retry', () => {
47
+ const html = render()
48
+ expect(html).toContain('Backend query failed')
49
+ expect(html).toContain('Discover Prometheus')
50
+ expect(html).toContain('Configure metrics')
51
+ expect(html).toContain('workload-metrics.md#what-each-chart-needs')
52
+ })
53
+ it('uses operator guidance instead of standalone settings when embedded', () => {
54
+ const html = render(undefined, true)
55
+ expect(html).toContain('Ask your operator')
56
+ expect(html).not.toContain('Configure metrics')
57
+ expect(html).toContain('Discover Prometheus')
58
+ })
59
+ it.each([
60
+ ['/workloads/Deployment/demo/web?tab=metrics&metricsRange=3h', '3h'],
61
+ ['/applications?app=demo&workload=Deployment%2Fdemo%2Fweb&tab=metrics&metricsRange=6h', '6h'],
62
+ ['/applications?tab=metrics&metricsRange=invalid', '1h'],
63
+ ['/applications?tab=metrics', '1h'],
64
+ ])('reads and validates the URL range: %s', (url, expected) => {
65
+ connected = true
66
+ const html = render(url)
67
+ expect(html).toContain(`value="${expected}" selected=""`)
68
+ expect(ranges.length).toBeGreaterThan(0)
69
+ expect(ranges.every((range) => range === expected)).toBe(true)
70
+ })
71
+ it('leaves unverified charts accessible without template overlays or saturation', () => {
72
+ connected = true
73
+ const html = render()
74
+ expect(html).toContain('<details aria-label="Name-matched resource metrics">')
75
+ expect(html).toContain('Network and storage')
76
+ expect(html).not.toContain('% of limit')
77
+ expect(charts.length).toBeGreaterThan(0)
78
+ expect(charts.every((chart) => !chart.referenceLines?.length)).toBe(true)
79
+ })
80
+ it('labels unlabeled request totals without changing aggregate and quantile labels', () => {
81
+ connected = true
82
+ const series = [{ labels: {}, dataPoints: [{ timestamp: 160, value: 1 }] }]
83
+ workload.panels.requests = { state: 'available', unit: 'requests/s', series }
84
+ workload.panels.p50 = { state: 'available', unit: 'seconds', series }
85
+ workload.panels.p95 = { state: 'available', unit: 'seconds', series }
86
+ workload.panels.cpu = { state: 'available', unit: 'cores', series: [{ ...series[0], labels: { aggregation: 'Workload' } }, { ...series[0], labels: { aggregation: 'Maximum Pod' } }] }
87
+ render()
88
+ expect(charts.map((chart) => chart.seriesLabels)).toContainEqual(['Requests / sec'])
89
+ expect(charts.map((chart) => chart.seriesLabels)).toContainEqual(['p50', 'p95'])
90
+ expect(charts.map((chart) => chart.seriesLabels)).toContainEqual(['Workload', 'Maximum Pod'])
91
+ })
92
+ it('retains template references on verified current-Pod charts', () => {
93
+ connected = true
94
+ workload.history.cpu = { mode: 'current-pods' }
95
+ workload.panels.cpu = { state: 'available', unit: 'cores', series: [{ labels: { pod: 'web-0' }, dataPoints: [{ timestamp: 160, value: 0.1 }] }] }
96
+ render()
97
+ expect(charts.some((chart) => chart.referenceLines?.some((line) => (line as { label: string }).label === 'Template request 100m'))).toBe(true)
98
+ })
99
+ })
@@ -1,4 +1,5 @@
1
- import { useMemo, useState } from "react";
1
+ import { useMemo } from "react";
2
+ import { useSearchParams } from "react-router-dom";
2
3
  import { Loader2, Wifi, WifiOff } from "lucide-react";
3
4
  import {
4
5
  AreaChart,
@@ -30,6 +31,9 @@ import {
30
31
  } from "./PrometheusCharts";
31
32
  import { RestartEventLane } from "./RestartChart";
32
33
  import { Tooltip } from "../ui/Tooltip";
34
+ import { WorkloadMetricsSection } from "./WorkloadMetricsSection";
35
+ import { RightsizingStrip } from "./RightsizingStrip";
36
+ import { useNavCustomization } from "../../context/NavCustomization";
33
37
 
34
38
  // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
35
39
  // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
@@ -64,9 +68,12 @@ export function PrometheusChartsGrid({
64
68
  const connectMutation = usePrometheusConnect();
65
69
  const isConnected = status?.connected === true;
66
70
  const isSupported = SUPPORTED_KINDS.has(kind);
71
+ const isWorkload = ["Deployment", "StatefulSet", "DaemonSet"].includes(kind);
67
72
  const showRestartLane = isSupported && kind !== "Node";
68
73
 
69
- 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";
70
77
 
71
78
  const categories = kind === "Node" ? NODE_CATEGORIES : WORKLOAD_CATEGORIES;
72
79
 
@@ -94,6 +101,15 @@ export function PrometheusChartsGrid({
94
101
  );
95
102
  }
96
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
+ );
111
+ }
112
+
97
113
  if (!isConnected) {
98
114
  return (
99
115
  <div className="flex flex-col items-center justify-center py-12 gap-4">
@@ -106,6 +122,7 @@ export function PrometheusChartsGrid({
106
122
  {status?.error ||
107
123
  "Connect to view historical CPU, memory, and network metrics"}
108
124
  </p>
125
+ <div className="flex flex-wrap items-center justify-center gap-3">
109
126
  <button
110
127
  onClick={() => connectMutation.mutate()}
111
128
  disabled={connectMutation.isPending}
@@ -118,6 +135,14 @@ export function PrometheusChartsGrid({
118
135
  )}
119
136
  Discover Prometheus
120
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>}
121
146
  </div>
122
147
  </div>
123
148
  );
@@ -140,27 +165,42 @@ export function PrometheusChartsGrid({
140
165
  }
141
166
  const disk = findCategory("filesystem");
142
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
+ );
143
171
 
144
- return (
145
- <div className="flex flex-col h-full overflow-auto">
146
- <div className="flex shrink-0 justify-end px-4 pt-3">
147
- <select
172
+ const rangeControl = <select
148
173
  aria-label="Metrics time range"
149
174
  value={timeRange}
150
- onChange={(e) => setTimeRange(e.target.value as PrometheusTimeRange)}
151
- 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"
152
181
  >
153
182
  {TIME_RANGES.map((tr) => (
154
183
  <option key={tr.value} value={tr.value}>
155
184
  {tr.label}
156
185
  </option>
157
186
  ))}
158
- </select>
159
- </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
+ )}
160
200
 
161
201
  {/* Restart lane sits above the grid so its markers visually align with
162
202
  the time axis of the charts below. */}
163
- {showRestartLane && (
203
+ {showRestartLane && !isWorkload && (
164
204
  <div className="px-4 pt-3">
165
205
  <RestartEventLane
166
206
  kind={kind}
@@ -171,28 +211,27 @@ export function PrometheusChartsGrid({
171
211
  </div>
172
212
  )}
173
213
 
174
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 pt-4">
175
- {chartPanels.map(({ def, refLines }) => (
176
- <MetricsPanel
177
- key={def.key}
178
- category={def}
179
- kind={kind}
180
- namespace={namespace}
181
- name={name}
182
- timeRange={timeRange}
183
- referenceLines={refLines}
184
- />
185
- ))}
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>
186
223
  </div>
187
224
 
188
- {/* Every chart here covers the same pods, so which pods those are is
189
- stated once for the grid rather than repeated under each one. */}
190
225
  <PodCoverageNote
191
226
  kind={kind}
192
227
  namespace={namespace}
193
228
  name={name}
194
229
  timeRange={timeRange}
230
+ category={isWorkload ? "network_rx" : "cpu"}
195
231
  />
232
+ {["Deployment", "StatefulSet", "DaemonSet"].includes(kind) && (
233
+ <div className="px-4 pb-4"><RightsizingStrip kind={kind} namespace={namespace} name={name} /></div>
234
+ )}
196
235
  </div>
197
236
  );
198
237
  }
@@ -202,19 +241,19 @@ function PodCoverageNote({
202
241
  namespace,
203
242
  name,
204
243
  timeRange,
244
+ category,
205
245
  }: {
206
246
  kind: string;
207
247
  namespace: string;
208
248
  name: string;
209
249
  timeRange: PrometheusTimeRange;
250
+ category: PrometheusMetricCategory;
210
251
  }) {
211
- // Any category answers this: the pod scope is per workload, not per metric.
212
- // This is the same query key the CPU panel uses, so it is already cached.
213
252
  const { data } = usePrometheusResourceMetrics(
214
253
  kind,
215
254
  namespace,
216
255
  name,
217
- "cpu",
256
+ category,
218
257
  timeRange,
219
258
  true,
220
259
  );
@@ -265,9 +304,9 @@ function MetricsPanel({
265
304
  : undefined;
266
305
 
267
306
  return (
268
- <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
269
- <header className="flex items-center justify-between mb-2 gap-3">
270
- <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">
271
310
  <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
272
311
  {category.label}
273
312
  </h3>
@@ -282,7 +321,7 @@ function MetricsPanel({
282
321
  )}
283
322
  </header>
284
323
 
285
- <div className="flex-1 min-h-[200px]">
324
+ <div className="min-w-0">
286
325
  {isLoading ? (
287
326
  <PanelLoading />
288
327
  ) : error ? (
@@ -295,6 +334,7 @@ function MetricsPanel({
295
334
  fillColor={category.fillColor}
296
335
  unit={metrics!.unit}
297
336
  referenceLines={referenceLines}
337
+ layout="dashboard"
298
338
  />
299
339
  {series.length > 1 && (
300
340
  <div className="mt-1.5">
@@ -336,7 +376,7 @@ function SaturationChip({
336
376
 
337
377
  function PanelLoading() {
338
378
  return (
339
- <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">
340
380
  <Loader2 className="w-4 h-4 animate-spin mr-2" />
341
381
  Loading…
342
382
  </div>
@@ -346,7 +386,7 @@ function PanelLoading() {
346
386
  function PanelError({ message }: { message: string }) {
347
387
  return (
348
388
  <div
349
- className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
389
+ className={`flex flex-col items-center justify-center h-[240px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
350
390
  >
351
391
  Query failed
352
392
  <Tooltip content={message} wrapperClassName="!block w-full">
@@ -360,7 +400,7 @@ function PanelError({ message }: { message: string }) {
360
400
 
361
401
  function PanelNoData({ hint }: { hint?: string }) {
362
402
  return (
363
- <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">
364
404
  No data
365
405
  {hint && (
366
406
  <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>
@@ -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
+ })
@@ -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
+ }