@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.
- package/package.json +8 -8
- package/src/App.tsx +60 -24
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +321 -16
- package/src/api/diagnose.ts +1 -0
- package/src/api/drain-plan.test.ts +86 -0
- package/src/api/quotas.test.ts +26 -0
- package/src/api/quotas.ts +20 -0
- package/src/api/workloadMetrics.test.ts +40 -0
- package/src/api/workloadMetrics.ts +68 -0
- package/src/components/CloudConnectFlow.tsx +179 -42
- package/src/components/CloudFunnelButton.tsx +107 -36
- package/src/components/SyncProgressPanel.tsx +73 -0
- package/src/components/audit/AuditView.tsx +2 -0
- package/src/components/cloudConnectHandoff.test.ts +140 -0
- package/src/components/cloudConnectHandoff.ts +124 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
- package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
- package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
- package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
- package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
- package/src/components/diagnose/investigationEvidence/index.ts +55 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
- package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
- package/src/components/diagnose/investigationEvidence/types.ts +512 -0
- package/src/components/diagnose/target.test.ts +19 -0
- package/src/components/diagnose/target.ts +3 -2
- package/src/components/gitops/GitOpsView.tsx +7 -0
- package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
- package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
- package/src/components/resource/PrometheusCharts.tsx +4 -3
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
- package/src/components/resource/RightsizingStrip.test.ts +38 -0
- package/src/components/resource/RightsizingStrip.tsx +15 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
- package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
- package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
- package/src/components/resource/workloadMetricValues.test.ts +65 -0
- package/src/components/resource/workloadMetricValues.ts +30 -0
- package/src/components/resources/ResourcesView.tsx +44 -6
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
- package/src/components/settings/SettingsDialog.tsx +94 -14
- package/src/components/traffic/TrafficFlowList.tsx +229 -70
- package/src/components/traffic/TrafficGraph.tsx +6 -6
- package/src/components/traffic/TrafficView.tsx +10 -9
- package/src/components/traffic/trafficFilters.test.ts +28 -1
- package/src/components/traffic/trafficFilters.ts +31 -0
- package/src/components/workload/WorkloadView.tsx +19 -8
- package/src/context/ConnectionContext.tsx +48 -3
- package/src/context/DiagnoseCustomization.tsx +1 -0
- package/src/index.css +17 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/topology-namespace.test.ts +64 -0
- package/src/utils/topology-namespace.ts +18 -0
- package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
- package/src/components/diagnose/investigationEvidence.ts +0 -5572
- package/src/components/gitops/GitOpsActions.tsx +0 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
- package/src/components/gitops/ManagedResourcesList.tsx +0 -1
- package/src/components/gitops/SyncCountdown.tsx +0 -1
- package/src/components/gitops/index.ts +0 -4
- package/src/components/helm/ManifestDiffViewer.tsx +0 -69
- package/src/components/logs/JsonLogLine.tsx +0 -1
- package/src/components/logs/LogCore.tsx +0 -2
- package/src/components/logs/useLogBuffer.ts +0 -2
- package/src/components/logs/useLogSearch.ts +0 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
- package/src/components/resources/drawer-components.tsx +0 -1
- package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
- package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/EventRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/JobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/VPARenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
- package/src/components/resources/renderers/argo-cells.tsx +0 -1
- package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
- package/src/components/resources/renderers/eso-cells.tsx +0 -1
- package/src/components/resources/renderers/flux-cells.tsx +0 -1
- package/src/components/resources/renderers/index.ts +0 -94
- package/src/components/resources/renderers/istio-cells.tsx +0 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
- package/src/components/resources/renderers/keda-cells.tsx +0 -1
- package/src/components/resources/renderers/knative-cells.tsx +0 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
- package/src/components/resources/renderers/traefik-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-shared.tsx +0 -1
- package/src/components/resources/renderers/velero-cells.tsx +0 -1
- package/src/components/resources/resource-utils-argo.ts +0 -2
- package/src/components/resources/resource-utils-certmanager.ts +0 -2
- package/src/components/resources/resource-utils-eso.ts +0 -2
- package/src/components/resources/resource-utils-flux.ts +0 -2
- package/src/components/resources/resource-utils-istio.ts +0 -2
- package/src/components/resources/resource-utils-karpenter.ts +0 -2
- package/src/components/resources/resource-utils-knative.ts +0 -2
- package/src/components/resources/resource-utils-kyverno.ts +0 -2
- package/src/components/resources/resource-utils-prometheus.ts +0 -2
- package/src/components/resources/resource-utils-traefik.ts +0 -1
- package/src/components/resources/resource-utils-trivy.ts +0 -2
- package/src/components/resources/resource-utils-velero.ts +0 -2
- package/src/components/shared/EditableYamlView.tsx +0 -24
- package/src/components/timeline/DiffViewer.tsx +0 -1
- package/src/components/traffic/index.ts +0 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
- package/src/components/ui/MetricsChart.tsx +0 -1
- 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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
151
|
-
|
|
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
|
-
|
|
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="
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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-
|
|
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="
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
340
|
-
return
|
|
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 & 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
|
+
}
|