@skyhook-io/radar-app 1.13.3 → 1.13.5
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 +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +6 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
// The access state is what is under test, so the fetch is stubbed rather than
|
|
5
|
+
// run. isForbiddenError is deliberately NOT stubbed: a looser stand-in would
|
|
6
|
+
// pass while the real predicate sent a denial down the silent-hide path, which
|
|
7
|
+
// is the failure this component exists to prevent.
|
|
8
|
+
import { ApiError } from '../../api/client'
|
|
9
|
+
|
|
10
|
+
const forbidden = new ApiError('forbidden', 403)
|
|
11
|
+
let metricsResult: { data?: unknown; error?: unknown } = {}
|
|
12
|
+
let connected = true
|
|
13
|
+
|
|
14
|
+
vi.mock('../../api/client', async (importActual) => ({
|
|
15
|
+
...(await importActual<typeof import('../../api/client')>()),
|
|
16
|
+
usePrometheusHPAMetrics: () => metricsResult,
|
|
17
|
+
usePrometheusStatus: () => ({ data: { connected } }),
|
|
18
|
+
useAutoPromConnect: () => undefined,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const { HPACharts } = await import('./HPACharts')
|
|
22
|
+
|
|
23
|
+
const hpa = {
|
|
24
|
+
kind: 'HorizontalPodAutoscaler',
|
|
25
|
+
metadata: { namespace: 'shop', name: 'web' },
|
|
26
|
+
spec: { minReplicas: 2, maxReplicas: 10 },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const series = (values: number[]) => ({
|
|
30
|
+
resultType: 'matrix',
|
|
31
|
+
series: [{ labels: {}, dataPoints: values.map((value, i) => ({ timestamp: 1700000000 + i * 60, value })) }],
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A denied chart must not look like an HPA that never scaled: an empty
|
|
36
|
+
* "no history" and "you may not see the history" are different facts.
|
|
37
|
+
*/
|
|
38
|
+
describe('HPACharts — access state', () => {
|
|
39
|
+
it('says the caller has no access when the curated endpoint returns 403', () => {
|
|
40
|
+
connected = true
|
|
41
|
+
metricsResult = { error: forbidden }
|
|
42
|
+
const html = renderToString(<HPACharts data={hpa} />)
|
|
43
|
+
expect(html).toContain('have access to metrics for this resource')
|
|
44
|
+
expect(html).toContain('Activity (last 1h)')
|
|
45
|
+
expect(html).not.toContain('Replicas')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('plots the replica history when the endpoint answers', () => {
|
|
49
|
+
connected = true
|
|
50
|
+
metricsResult = { data: { current: series([2, 3, 4]), desired: series([4, 4, 4]) } }
|
|
51
|
+
const html = renderToString(<HPACharts data={hpa} />)
|
|
52
|
+
expect(html).toContain('Replicas')
|
|
53
|
+
expect(html).toContain('min 2')
|
|
54
|
+
expect(html).toContain('max 10')
|
|
55
|
+
expect(html).not.toContain('access to metrics')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays hidden when KSM reports nothing for the HPA', () => {
|
|
59
|
+
connected = true
|
|
60
|
+
metricsResult = { data: { current: { resultType: 'matrix', series: [] }, desired: { resultType: 'matrix', series: [] } } }
|
|
61
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// A fault is not a denial: the chart keeps its silent-hide behaviour and
|
|
65
|
+
// leaves the breadcrumb to the console.
|
|
66
|
+
it('stays hidden on a non-authorization failure', () => {
|
|
67
|
+
connected = true
|
|
68
|
+
metricsResult = { error: new Error('Prometheus query failed') }
|
|
69
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('stays hidden when Prometheus is not connected, even on a denial', () => {
|
|
73
|
+
connected = false
|
|
74
|
+
metricsResult = { error: forbidden }
|
|
75
|
+
expect(renderToString(<HPACharts data={hpa} />)).toBe('')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo } from 'react'
|
|
2
|
-
import { LineChart } from 'lucide-react'
|
|
3
|
-
import {
|
|
2
|
+
import { LineChart, Lock } from 'lucide-react'
|
|
3
|
+
import { AlertBanner } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { usePrometheusHPAMetrics, usePrometheusStatus, useAutoPromConnect, isForbiddenError, type PrometheusSeries } from '../../api/client'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* HPACharts — replicas-over-time chart for an HPA.
|
|
7
8
|
*
|
|
8
|
-
* Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas
|
|
9
|
-
*
|
|
9
|
+
* Sources from KSM `kube_horizontalpodautoscaler_status_{current,desired}_replicas`
|
|
10
|
+
* through the curated HPA metrics endpoint, which is gated on reading the HPA
|
|
11
|
+
* itself. Hidden silently when Prom isn't connected or KSM isn't reporting the
|
|
12
|
+
* series; a denial is shown, because an empty chart would read as "no history".
|
|
10
13
|
*
|
|
11
14
|
* Only the replicas series is plotted — KSM doesn't expose the observed metric
|
|
12
15
|
* the HPA target compares against, so an "observed vs target" chart would need
|
|
@@ -25,35 +28,43 @@ export function HPACharts({ data }: { data: any }) {
|
|
|
25
28
|
const min = spec.minReplicas ?? 1
|
|
26
29
|
const max = spec.maxReplicas
|
|
27
30
|
|
|
28
|
-
const currentQuery = useMemo(
|
|
29
|
-
() => `kube_horizontalpodautoscaler_status_current_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
30
|
-
[namespace, name],
|
|
31
|
-
)
|
|
32
|
-
const desiredQuery = useMemo(
|
|
33
|
-
() => `kube_horizontalpodautoscaler_status_desired_replicas{namespace="${escapeLabel(namespace)}",horizontalpodautoscaler="${escapeLabel(name)}"}`,
|
|
34
|
-
[namespace, name],
|
|
35
|
-
)
|
|
36
|
-
|
|
37
31
|
const enabled = isConnected && Boolean(namespace && name)
|
|
38
|
-
const { data:
|
|
39
|
-
const { data: desiredRes, error: desiredErr } = usePromQLRange(desiredQuery, '1h', enabled)
|
|
32
|
+
const { data: metrics, error } = usePrometheusHPAMetrics(namespace, name, '1h', enabled)
|
|
40
33
|
|
|
41
34
|
const replicasPoints = useMemo(() => combineSeries({
|
|
42
|
-
current:
|
|
43
|
-
desired:
|
|
44
|
-
}), [
|
|
35
|
+
current: metrics?.current?.series,
|
|
36
|
+
desired: metrics?.desired?.series,
|
|
37
|
+
}), [metrics])
|
|
45
38
|
|
|
46
39
|
// Surface Prom-side failures in the console so an operator debugging a
|
|
47
40
|
// missing HPA chart has a breadcrumb; the chart still hides silently when
|
|
48
41
|
// KSM isn't reporting (the common no-data case). Effect-gated so we log
|
|
49
42
|
// once per error change, not on every re-render.
|
|
50
43
|
useEffect(() => {
|
|
51
|
-
if (
|
|
52
|
-
console.warn('[HPACharts]
|
|
44
|
+
if (error && !isForbiddenError(error)) {
|
|
45
|
+
console.warn('[HPACharts] HPA metrics query failed', { error })
|
|
53
46
|
}
|
|
54
|
-
}, [
|
|
47
|
+
}, [error])
|
|
55
48
|
|
|
56
49
|
if (!isConnected) return null
|
|
50
|
+
|
|
51
|
+
if (isForbiddenError(error)) {
|
|
52
|
+
return (
|
|
53
|
+
<section className="mt-4 rounded-lg border border-theme-border bg-theme-surface/30 p-3">
|
|
54
|
+
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-theme-text-secondary">
|
|
55
|
+
<LineChart className="w-4 h-4 text-theme-text-tertiary" />
|
|
56
|
+
Activity (last 1h)
|
|
57
|
+
</div>
|
|
58
|
+
<AlertBanner
|
|
59
|
+
variant="info"
|
|
60
|
+
icon={Lock}
|
|
61
|
+
title="You don't have access to metrics for this resource"
|
|
62
|
+
message="Replica history is available to users who can read this HorizontalPodAutoscaler."
|
|
63
|
+
/>
|
|
64
|
+
</section>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
if (!replicasPoints) return null
|
|
58
69
|
|
|
59
70
|
return (
|
|
@@ -111,10 +122,6 @@ function combineSeries(args: { current?: PrometheusSeries[]; desired?: Prometheu
|
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
function escapeLabel(s: string): string {
|
|
115
|
-
return s.replace(/[\\"]/g, '\\$&')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
125
|
// ============================================================================
|
|
119
126
|
// DualLineChart — minimal two-line chart for HPA-style time series.
|
|
120
127
|
// Deliberately separate from PrometheusCharts.AreaChart: the chart shapes are
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { useMemo, useState } from
|
|
2
|
-
import { Loader2, Wifi, WifiOff } from
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Loader2, Wifi, WifiOff } from "lucide-react";
|
|
3
3
|
import {
|
|
4
4
|
AreaChart,
|
|
5
5
|
SeriesLegend,
|
|
6
6
|
computeSaturation,
|
|
7
|
+
describePodCoverage,
|
|
7
8
|
type ReferenceLine,
|
|
8
|
-
} from
|
|
9
|
-
import {
|
|
9
|
+
} from "@skyhook-io/k8s-ui/components/charts";
|
|
10
|
+
import {
|
|
11
|
+
SEVERITY_BADGE,
|
|
12
|
+
SEVERITY_TEXT,
|
|
13
|
+
type Severity,
|
|
14
|
+
} from "@skyhook-io/k8s-ui/utils/badge-colors";
|
|
10
15
|
import {
|
|
11
16
|
usePrometheusStatus,
|
|
12
17
|
usePrometheusConnect,
|
|
@@ -14,7 +19,7 @@ import {
|
|
|
14
19
|
useAutoPromConnect,
|
|
15
20
|
type PrometheusMetricCategory,
|
|
16
21
|
type PrometheusTimeRange,
|
|
17
|
-
} from
|
|
22
|
+
} from "../../api/client";
|
|
18
23
|
import {
|
|
19
24
|
MetricsSummary,
|
|
20
25
|
TIME_RANGES,
|
|
@@ -22,24 +27,31 @@ import {
|
|
|
22
27
|
NODE_CATEGORIES,
|
|
23
28
|
computeRequestLimitLines,
|
|
24
29
|
type CategoryDef,
|
|
25
|
-
} from
|
|
26
|
-
import { RestartEventLane } from
|
|
27
|
-
import { Tooltip } from
|
|
30
|
+
} from "./PrometheusCharts";
|
|
31
|
+
import { RestartEventLane } from "./RestartChart";
|
|
32
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
28
33
|
|
|
29
34
|
// Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
|
|
30
35
|
// uses the single-chart tabbed `PrometheusCharts` instead — drawer width
|
|
31
36
|
// can't fit the grid cleanly.
|
|
32
37
|
export interface PrometheusChartsGridProps {
|
|
33
|
-
kind: string
|
|
34
|
-
namespace: string
|
|
35
|
-
name: string
|
|
38
|
+
kind: string;
|
|
39
|
+
namespace: string;
|
|
40
|
+
name: string;
|
|
36
41
|
/** Optional full K8s resource for request/limit overlay derivation. */
|
|
37
|
-
resource?: any
|
|
42
|
+
resource?: any;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const SUPPORTED_KINDS = new Set([
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
"Pod",
|
|
47
|
+
"Deployment",
|
|
48
|
+
"StatefulSet",
|
|
49
|
+
"DaemonSet",
|
|
50
|
+
"ReplicaSet",
|
|
51
|
+
"Job",
|
|
52
|
+
"CronJob",
|
|
53
|
+
"Node",
|
|
54
|
+
]);
|
|
43
55
|
|
|
44
56
|
export function PrometheusChartsGrid({
|
|
45
57
|
kind,
|
|
@@ -47,29 +59,31 @@ export function PrometheusChartsGrid({
|
|
|
47
59
|
name,
|
|
48
60
|
resource,
|
|
49
61
|
}: PrometheusChartsGridProps) {
|
|
50
|
-
useAutoPromConnect()
|
|
51
|
-
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
52
|
-
const connectMutation = usePrometheusConnect()
|
|
53
|
-
const isConnected = status?.connected === true
|
|
54
|
-
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
55
|
-
const showRestartLane = isSupported && kind !==
|
|
62
|
+
useAutoPromConnect();
|
|
63
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus();
|
|
64
|
+
const connectMutation = usePrometheusConnect();
|
|
65
|
+
const isConnected = status?.connected === true;
|
|
66
|
+
const isSupported = SUPPORTED_KINDS.has(kind);
|
|
67
|
+
const showRestartLane = isSupported && kind !== "Node";
|
|
56
68
|
|
|
57
|
-
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>(
|
|
69
|
+
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>("1h");
|
|
58
70
|
|
|
59
|
-
const categories = kind ===
|
|
71
|
+
const categories = kind === "Node" ? NODE_CATEGORIES : WORKLOAD_CATEGORIES;
|
|
60
72
|
|
|
61
73
|
// CPU + memory get reference-line overlays when a resource is provided.
|
|
62
74
|
// Computed once at the parent so each panel can stay otherwise generic.
|
|
63
75
|
const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
64
|
-
() =>
|
|
76
|
+
() =>
|
|
77
|
+
resource ? computeRequestLimitLines(resource, kind, "cpu") : undefined,
|
|
65
78
|
[resource, kind],
|
|
66
|
-
)
|
|
79
|
+
);
|
|
67
80
|
const memRefLines = useMemo<ReferenceLine[] | undefined>(
|
|
68
|
-
() =>
|
|
81
|
+
() =>
|
|
82
|
+
resource ? computeRequestLimitLines(resource, kind, "memory") : undefined,
|
|
69
83
|
[resource, kind],
|
|
70
|
-
)
|
|
84
|
+
);
|
|
71
85
|
|
|
72
|
-
if (!isSupported) return null
|
|
86
|
+
if (!isSupported) return null;
|
|
73
87
|
|
|
74
88
|
if (statusLoading) {
|
|
75
89
|
return (
|
|
@@ -77,7 +91,7 @@ export function PrometheusChartsGrid({
|
|
|
77
91
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
78
92
|
Checking Prometheus availability...
|
|
79
93
|
</div>
|
|
80
|
-
)
|
|
94
|
+
);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
if (!isConnected) {
|
|
@@ -85,9 +99,12 @@ export function PrometheusChartsGrid({
|
|
|
85
99
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
86
100
|
<WifiOff className="w-10 h-10 text-theme-text-quaternary" />
|
|
87
101
|
<div className="text-center">
|
|
88
|
-
<p className="text-sm text-theme-text-secondary mb-1">
|
|
102
|
+
<p className="text-sm text-theme-text-secondary mb-1">
|
|
103
|
+
Prometheus not connected
|
|
104
|
+
</p>
|
|
89
105
|
<p className="text-xs text-theme-text-tertiary mb-4">
|
|
90
|
-
{status?.error ||
|
|
106
|
+
{status?.error ||
|
|
107
|
+
"Connect to view historical CPU, memory, and network metrics"}
|
|
91
108
|
</p>
|
|
92
109
|
<button
|
|
93
110
|
onClick={() => connectMutation.mutate()}
|
|
@@ -103,25 +120,26 @@ export function PrometheusChartsGrid({
|
|
|
103
120
|
</button>
|
|
104
121
|
</div>
|
|
105
122
|
</div>
|
|
106
|
-
)
|
|
123
|
+
);
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
const findCategory = (
|
|
110
|
-
|
|
126
|
+
const findCategory = (
|
|
127
|
+
key: PrometheusMetricCategory,
|
|
128
|
+
): CategoryDef | undefined => categories.find((c) => c.key === key);
|
|
111
129
|
|
|
112
|
-
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
|
|
113
|
-
const cpu = findCategory(
|
|
114
|
-
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
|
|
115
|
-
const mem = findCategory(
|
|
116
|
-
if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
|
|
117
|
-
if (kind !==
|
|
118
|
-
const rx = findCategory(
|
|
119
|
-
if (rx) primaryCats.push({ def: rx })
|
|
120
|
-
const tx = findCategory(
|
|
121
|
-
if (tx) primaryCats.push({ def: tx })
|
|
130
|
+
const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = [];
|
|
131
|
+
const cpu = findCategory("cpu");
|
|
132
|
+
if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines });
|
|
133
|
+
const mem = findCategory("memory");
|
|
134
|
+
if (mem) primaryCats.push({ def: mem, refLines: memRefLines });
|
|
135
|
+
if (kind !== "Node") {
|
|
136
|
+
const rx = findCategory("network_rx");
|
|
137
|
+
if (rx) primaryCats.push({ def: rx });
|
|
138
|
+
const tx = findCategory("network_tx");
|
|
139
|
+
if (tx) primaryCats.push({ def: tx });
|
|
122
140
|
}
|
|
123
|
-
const disk = findCategory(
|
|
124
|
-
const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
|
|
141
|
+
const disk = findCategory("filesystem");
|
|
142
|
+
const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats;
|
|
125
143
|
|
|
126
144
|
return (
|
|
127
145
|
<div className="flex flex-col h-full overflow-auto">
|
|
@@ -129,11 +147,13 @@ export function PrometheusChartsGrid({
|
|
|
129
147
|
<select
|
|
130
148
|
aria-label="Metrics time range"
|
|
131
149
|
value={timeRange}
|
|
132
|
-
onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
150
|
+
onChange={(e) => setTimeRange(e.target.value as PrometheusTimeRange)}
|
|
133
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"
|
|
134
152
|
>
|
|
135
|
-
{TIME_RANGES.map(tr => (
|
|
136
|
-
<option key={tr.value} value={tr.value}>
|
|
153
|
+
{TIME_RANGES.map((tr) => (
|
|
154
|
+
<option key={tr.value} value={tr.value}>
|
|
155
|
+
{tr.label}
|
|
156
|
+
</option>
|
|
137
157
|
))}
|
|
138
158
|
</select>
|
|
139
159
|
</div>
|
|
@@ -142,11 +162,16 @@ export function PrometheusChartsGrid({
|
|
|
142
162
|
the time axis of the charts below. */}
|
|
143
163
|
{showRestartLane && (
|
|
144
164
|
<div className="px-4 pt-3">
|
|
145
|
-
<RestartEventLane
|
|
165
|
+
<RestartEventLane
|
|
166
|
+
kind={kind}
|
|
167
|
+
namespace={namespace}
|
|
168
|
+
name={name}
|
|
169
|
+
range={timeRange}
|
|
170
|
+
/>
|
|
146
171
|
</div>
|
|
147
172
|
)}
|
|
148
173
|
|
|
149
|
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-3
|
|
174
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 pt-4">
|
|
150
175
|
{chartPanels.map(({ def, refLines }) => (
|
|
151
176
|
<MetricsPanel
|
|
152
177
|
key={def.key}
|
|
@@ -159,33 +184,85 @@ export function PrometheusChartsGrid({
|
|
|
159
184
|
/>
|
|
160
185
|
))}
|
|
161
186
|
</div>
|
|
187
|
+
|
|
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
|
+
<PodCoverageNote
|
|
191
|
+
kind={kind}
|
|
192
|
+
namespace={namespace}
|
|
193
|
+
name={name}
|
|
194
|
+
timeRange={timeRange}
|
|
195
|
+
/>
|
|
162
196
|
</div>
|
|
163
|
-
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function PodCoverageNote({
|
|
201
|
+
kind,
|
|
202
|
+
namespace,
|
|
203
|
+
name,
|
|
204
|
+
timeRange,
|
|
205
|
+
}: {
|
|
206
|
+
kind: string;
|
|
207
|
+
namespace: string;
|
|
208
|
+
name: string;
|
|
209
|
+
timeRange: PrometheusTimeRange;
|
|
210
|
+
}) {
|
|
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
|
+
const { data } = usePrometheusResourceMetrics(
|
|
214
|
+
kind,
|
|
215
|
+
namespace,
|
|
216
|
+
name,
|
|
217
|
+
"cpu",
|
|
218
|
+
timeRange,
|
|
219
|
+
true,
|
|
220
|
+
);
|
|
221
|
+
const note = data ? describePodCoverage(data) : undefined;
|
|
222
|
+
if (!note) return null;
|
|
223
|
+
return <p className="px-4 pb-4 text-xs text-theme-text-tertiary">{note}</p>;
|
|
164
224
|
}
|
|
165
225
|
|
|
166
226
|
interface MetricsPanelProps {
|
|
167
|
-
category: CategoryDef
|
|
168
|
-
kind: string
|
|
169
|
-
namespace: string
|
|
170
|
-
name: string
|
|
171
|
-
timeRange: PrometheusTimeRange
|
|
172
|
-
referenceLines?: ReferenceLine[]
|
|
227
|
+
category: CategoryDef;
|
|
228
|
+
kind: string;
|
|
229
|
+
namespace: string;
|
|
230
|
+
name: string;
|
|
231
|
+
timeRange: PrometheusTimeRange;
|
|
232
|
+
referenceLines?: ReferenceLine[];
|
|
173
233
|
}
|
|
174
234
|
|
|
175
|
-
function MetricsPanel({
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
235
|
+
function MetricsPanel({
|
|
236
|
+
category,
|
|
237
|
+
kind,
|
|
238
|
+
namespace,
|
|
239
|
+
name,
|
|
240
|
+
timeRange,
|
|
241
|
+
referenceLines,
|
|
242
|
+
}: MetricsPanelProps) {
|
|
243
|
+
const {
|
|
244
|
+
data: metrics,
|
|
245
|
+
isLoading,
|
|
246
|
+
error,
|
|
247
|
+
} = usePrometheusResourceMetrics(
|
|
248
|
+
kind,
|
|
249
|
+
namespace,
|
|
250
|
+
name,
|
|
251
|
+
category.key,
|
|
252
|
+
timeRange,
|
|
253
|
+
true,
|
|
254
|
+
);
|
|
179
255
|
|
|
180
|
-
const series = metrics?.result?.series
|
|
181
|
-
const hasData = (series?.length ?? 0) > 0
|
|
256
|
+
const series = metrics?.result?.series;
|
|
257
|
+
const hasData = (series?.length ?? 0) > 0;
|
|
182
258
|
|
|
183
259
|
// "% of limit / request" derived from current peak vs reference lines.
|
|
184
260
|
// Without this, a low-utilization workload with a high limit looks like
|
|
185
261
|
// an empty chart — the user can't tell healthy from starved at a glance.
|
|
186
|
-
const saturation =
|
|
187
|
-
|
|
188
|
-
|
|
262
|
+
const saturation =
|
|
263
|
+
hasData && series && referenceLines
|
|
264
|
+
? computeSaturation(series, referenceLines)
|
|
265
|
+
: undefined;
|
|
189
266
|
|
|
190
267
|
return (
|
|
191
268
|
<section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
|
|
@@ -197,7 +274,11 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
|
|
|
197
274
|
{saturation && <SaturationChip {...saturation} />}
|
|
198
275
|
</div>
|
|
199
276
|
{hasData && series && (
|
|
200
|
-
<MetricsSummary
|
|
277
|
+
<MetricsSummary
|
|
278
|
+
series={series}
|
|
279
|
+
category={category}
|
|
280
|
+
unit={metrics!.unit}
|
|
281
|
+
/>
|
|
201
282
|
)}
|
|
202
283
|
</header>
|
|
203
284
|
|
|
@@ -226,16 +307,31 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
|
|
|
226
307
|
)}
|
|
227
308
|
</div>
|
|
228
309
|
</section>
|
|
229
|
-
)
|
|
310
|
+
);
|
|
230
311
|
}
|
|
231
312
|
|
|
232
|
-
function SaturationChip({
|
|
313
|
+
function SaturationChip({
|
|
314
|
+
ratio,
|
|
315
|
+
against,
|
|
316
|
+
}: {
|
|
317
|
+
ratio: number;
|
|
318
|
+
against: "limit" | "request";
|
|
319
|
+
}) {
|
|
233
320
|
// Thresholds match the rightsizing tone vocabulary: amber from 75% (start
|
|
234
321
|
// watching), red at 90% (the same OOM-risk boundary the backend uses for
|
|
235
322
|
// memory in classifyRightsizing).
|
|
236
|
-
const tone: Severity =
|
|
237
|
-
|
|
238
|
-
|
|
323
|
+
const tone: Severity =
|
|
324
|
+
ratio >= 0.9
|
|
325
|
+
? "error"
|
|
326
|
+
: ratio >= 0.75
|
|
327
|
+
? "warning"
|
|
328
|
+
: ratio < 0.05
|
|
329
|
+
? "info"
|
|
330
|
+
: "neutral";
|
|
331
|
+
const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`;
|
|
332
|
+
return (
|
|
333
|
+
<span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
|
|
334
|
+
);
|
|
239
335
|
}
|
|
240
336
|
|
|
241
337
|
function PanelLoading() {
|
|
@@ -244,27 +340,33 @@ function PanelLoading() {
|
|
|
244
340
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
245
341
|
Loading…
|
|
246
342
|
</div>
|
|
247
|
-
)
|
|
343
|
+
);
|
|
248
344
|
}
|
|
249
345
|
|
|
250
346
|
function PanelError({ message }: { message: string }) {
|
|
251
347
|
return (
|
|
252
|
-
<div
|
|
348
|
+
<div
|
|
349
|
+
className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
|
|
350
|
+
>
|
|
253
351
|
Query failed
|
|
254
352
|
<Tooltip content={message} wrapperClassName="!block w-full">
|
|
255
|
-
|
|
353
|
+
<span className="text-theme-text-quaternary mt-0.5 line-clamp-2">
|
|
354
|
+
{message}
|
|
355
|
+
</span>
|
|
256
356
|
</Tooltip>
|
|
257
357
|
</div>
|
|
258
|
-
)
|
|
358
|
+
);
|
|
259
359
|
}
|
|
260
360
|
|
|
261
361
|
function PanelNoData({ hint }: { hint?: string }) {
|
|
262
362
|
return (
|
|
263
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">
|
|
264
364
|
No data
|
|
265
|
-
{hint &&
|
|
365
|
+
{hint && (
|
|
366
|
+
<span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>
|
|
367
|
+
)}
|
|
266
368
|
</div>
|
|
267
|
-
)
|
|
369
|
+
);
|
|
268
370
|
}
|
|
269
371
|
|
|
270
|
-
export { isPrometheusSupported } from
|
|
372
|
+
export { isPrometheusSupported } from "./PrometheusCharts";
|