@skyhook-io/k8s-ui 1.14.4 → 1.14.6
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 +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +173 -0
- package/src/components/charts/AreaChart.tsx +208 -53
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +205 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +48 -0
- package/src/components/charts/axis.ts +62 -0
- package/src/components/charts/colors.test.ts +27 -0
- package/src/components/charts/colors.ts +23 -0
- package/src/components/charts/format.test.ts +19 -0
- package/src/components/charts/format.ts +21 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/charts/types.ts +12 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/index.ts +7 -0
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -57
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/topology/K8sResourceNode.test.ts +9 -0
- package/src/components/topology/K8sResourceNode.tsx +56 -5
- package/src/components/ui/Badge.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/monaco-deep.d.ts +10 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +38 -11
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { clsx } from
|
|
2
|
-
import { BarChart3, Wifi, WifiOff, Loader2 } from
|
|
3
|
-
import { AreaChart } from
|
|
4
|
-
import { PaneLoader } from
|
|
5
|
-
import { MetricsSummary } from
|
|
6
|
-
import { SeriesLegend } from
|
|
7
|
-
import type { TimeSeries, ReferenceLine } from
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { BarChart3, Wifi, WifiOff, Loader2 } from "lucide-react";
|
|
3
|
+
import { AreaChart } from "./AreaChart";
|
|
4
|
+
import { PaneLoader } from "../ui/PaneLoader";
|
|
5
|
+
import { MetricsSummary } from "./MetricsSummary";
|
|
6
|
+
import { SeriesLegend } from "./SeriesLegend";
|
|
7
|
+
import type { TimeSeries, ReferenceLine } from "./types";
|
|
8
8
|
|
|
9
9
|
// Pure presentational Prometheus charts view, shared between radar's WorkloadView
|
|
10
10
|
// and Radar Hub's Application metrics tab. It owns NO data fetching — the host
|
|
@@ -13,73 +13,162 @@ import type { TimeSeries, ReferenceLine } from './types'
|
|
|
13
13
|
// per-cluster tunnel fetches. Built on the AreaChart/MetricsSummary/SeriesLegend
|
|
14
14
|
// primitives already in this package.
|
|
15
15
|
|
|
16
|
-
export type PrometheusMetricCategory =
|
|
17
|
-
|
|
16
|
+
export type PrometheusMetricCategory =
|
|
17
|
+
"cpu" | "memory" | "network_rx" | "network_tx" | "filesystem" | "restarts";
|
|
18
|
+
export type PrometheusTimeRange =
|
|
19
|
+
"10m" | "30m" | "1h" | "3h" | "6h" | "12h" | "24h" | "48h" | "7d" | "14d";
|
|
18
20
|
|
|
19
21
|
export interface MetricCategoryDef {
|
|
20
|
-
key: PrometheusMetricCategory
|
|
21
|
-
label: string
|
|
22
|
-
color: string // tailwind text class for the summary's current value
|
|
23
|
-
chartColor: string // hex for the SVG line
|
|
24
|
-
fillColor: string // hex + alpha for the SVG fill
|
|
22
|
+
key: PrometheusMetricCategory;
|
|
23
|
+
label: string;
|
|
24
|
+
color: string; // tailwind text class for the summary's current value
|
|
25
|
+
chartColor: string; // hex for the SVG line
|
|
26
|
+
fillColor: string; // hex + alpha for the SVG fill
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export const WORKLOAD_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
{
|
|
31
|
+
key: "cpu",
|
|
32
|
+
label: "CPU",
|
|
33
|
+
color: "text-blue-400",
|
|
34
|
+
chartColor: "#60a5fa",
|
|
35
|
+
fillColor: "#60a5fa22",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "memory",
|
|
39
|
+
label: "Memory",
|
|
40
|
+
color: "text-purple-400",
|
|
41
|
+
chartColor: "#c084fc",
|
|
42
|
+
fillColor: "#c084fc22",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "network_rx",
|
|
46
|
+
label: "Net RX",
|
|
47
|
+
color: "text-emerald-400",
|
|
48
|
+
chartColor: "#34d399",
|
|
49
|
+
fillColor: "#34d39922",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: "network_tx",
|
|
53
|
+
label: "Net TX",
|
|
54
|
+
color: "text-orange-400",
|
|
55
|
+
chartColor: "#fb923c",
|
|
56
|
+
fillColor: "#fb923c22",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: "filesystem",
|
|
60
|
+
label: "Disk I/O",
|
|
61
|
+
color: "text-amber-400",
|
|
62
|
+
chartColor: "#fbbf24",
|
|
63
|
+
fillColor: "#fbbf2422",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
34
66
|
|
|
35
67
|
export const NODE_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
{
|
|
69
|
+
key: "cpu",
|
|
70
|
+
label: "CPU",
|
|
71
|
+
color: "text-blue-400",
|
|
72
|
+
chartColor: "#60a5fa",
|
|
73
|
+
fillColor: "#60a5fa22",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: "memory",
|
|
77
|
+
label: "Memory",
|
|
78
|
+
color: "text-purple-400",
|
|
79
|
+
chartColor: "#c084fc",
|
|
80
|
+
fillColor: "#c084fc22",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: "filesystem",
|
|
84
|
+
label: "Disk",
|
|
85
|
+
color: "text-amber-400",
|
|
86
|
+
chartColor: "#fbbf24",
|
|
87
|
+
fillColor: "#fbbf2422",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const METRIC_TIME_RANGES: {
|
|
92
|
+
value: PrometheusTimeRange;
|
|
93
|
+
label: string;
|
|
94
|
+
}[] = [
|
|
95
|
+
{ value: "10m", label: "10m" },
|
|
96
|
+
{ value: "30m", label: "30m" },
|
|
97
|
+
{ value: "1h", label: "1h" },
|
|
98
|
+
{ value: "3h", label: "3h" },
|
|
99
|
+
{ value: "6h", label: "6h" },
|
|
100
|
+
{ value: "12h", label: "12h" },
|
|
101
|
+
{ value: "24h", label: "24h" },
|
|
102
|
+
{ value: "7d", label: "7d" },
|
|
103
|
+
];
|
|
51
104
|
|
|
52
105
|
const SUPPORTED_KINDS = new Set([
|
|
53
|
-
|
|
54
|
-
|
|
106
|
+
"Pod",
|
|
107
|
+
"Deployment",
|
|
108
|
+
"StatefulSet",
|
|
109
|
+
"DaemonSet",
|
|
110
|
+
"ReplicaSet",
|
|
111
|
+
"Job",
|
|
112
|
+
"CronJob",
|
|
113
|
+
"Node",
|
|
114
|
+
]);
|
|
55
115
|
|
|
56
116
|
export interface PrometheusResourceMetricsResult {
|
|
57
|
-
unit: string
|
|
58
|
-
result?: { series?: TimeSeries[] }
|
|
59
|
-
query?: string
|
|
60
|
-
hint?: string
|
|
117
|
+
unit: string;
|
|
118
|
+
result?: { series?: TimeSeries[] };
|
|
119
|
+
query?: string;
|
|
120
|
+
hint?: string;
|
|
121
|
+
/**
|
|
122
|
+
* For workload kinds, the pods the chart covers: the ones the workload
|
|
123
|
+
* controls now, established by controller ownership. podsTotal is the
|
|
124
|
+
* workload's full set when the query's cap cut the list.
|
|
125
|
+
*/
|
|
126
|
+
pods?: number;
|
|
127
|
+
podsTotal?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One line under a workload chart saying which pods it covers. Membership is
|
|
132
|
+
* the population at load time, so a pod replaced earlier in the window is not
|
|
133
|
+
* in the chart — worth saying, because the line still spans the whole window.
|
|
134
|
+
*/
|
|
135
|
+
export function describePodCoverage(
|
|
136
|
+
metrics: PrometheusResourceMetricsResult,
|
|
137
|
+
): string | undefined {
|
|
138
|
+
const pods = metrics.pods;
|
|
139
|
+
if (pods === undefined) return undefined;
|
|
140
|
+
// Zero is a fact about the workload, not a failure to establish one: a
|
|
141
|
+
// workload Radar cannot resolve pods for reports an error instead of a
|
|
142
|
+
// count, so this line never stands for "Radar could not tell".
|
|
143
|
+
if (pods === 0)
|
|
144
|
+
return "This workload has no pods running, so there is nothing to chart";
|
|
145
|
+
const total = metrics.podsTotal ?? pods;
|
|
146
|
+
const count = total > pods ? `first ${pods} of ${total}` : `${pods}`;
|
|
147
|
+
return `${count} current pod${total === 1 ? "" : "s"}; pods replaced during the window are not included`;
|
|
61
148
|
}
|
|
62
149
|
|
|
63
150
|
export interface PrometheusChartsViewProps {
|
|
64
|
-
kind: string
|
|
151
|
+
kind: string;
|
|
65
152
|
/** When false (embedded in an Overview), hide entirely when not connected or no data. */
|
|
66
|
-
showEmptyState?: boolean
|
|
153
|
+
showEmptyState?: boolean;
|
|
67
154
|
// Connection status (host-provided)
|
|
68
|
-
statusLoading: boolean
|
|
69
|
-
isConnected: boolean
|
|
70
|
-
statusError?: string
|
|
71
|
-
onConnect: () => void
|
|
72
|
-
connecting: boolean
|
|
155
|
+
statusLoading: boolean;
|
|
156
|
+
isConnected: boolean;
|
|
157
|
+
statusError?: string;
|
|
158
|
+
onConnect: () => void;
|
|
159
|
+
connecting: boolean;
|
|
160
|
+
/** Discovery is running server-side (not triggered by this view); show progress, not the CTA. */
|
|
161
|
+
discovering?: boolean;
|
|
73
162
|
// Selection (controlled by host)
|
|
74
|
-
category: PrometheusMetricCategory
|
|
75
|
-
onCategoryChange: (c: PrometheusMetricCategory) => void
|
|
76
|
-
range: PrometheusTimeRange
|
|
77
|
-
onRangeChange: (r: PrometheusTimeRange) => void
|
|
163
|
+
category: PrometheusMetricCategory;
|
|
164
|
+
onCategoryChange: (c: PrometheusMetricCategory) => void;
|
|
165
|
+
range: PrometheusTimeRange;
|
|
166
|
+
onRangeChange: (r: PrometheusTimeRange) => void;
|
|
78
167
|
// Metrics (host-fetched)
|
|
79
|
-
metrics?: PrometheusResourceMetricsResult
|
|
80
|
-
metricsLoading: boolean
|
|
81
|
-
metricsError?: Error | null
|
|
82
|
-
referenceLines?: ReferenceLine[]
|
|
168
|
+
metrics?: PrometheusResourceMetricsResult;
|
|
169
|
+
metricsLoading: boolean;
|
|
170
|
+
metricsError?: Error | null;
|
|
171
|
+
referenceLines?: ReferenceLine[];
|
|
83
172
|
}
|
|
84
173
|
|
|
85
174
|
export function PrometheusChartsView({
|
|
@@ -90,6 +179,7 @@ export function PrometheusChartsView({
|
|
|
90
179
|
statusError,
|
|
91
180
|
onConnect,
|
|
92
181
|
connecting,
|
|
182
|
+
discovering = false,
|
|
93
183
|
category,
|
|
94
184
|
onCategoryChange,
|
|
95
185
|
range,
|
|
@@ -99,23 +189,35 @@ export function PrometheusChartsView({
|
|
|
99
189
|
metricsError,
|
|
100
190
|
referenceLines,
|
|
101
191
|
}: PrometheusChartsViewProps) {
|
|
102
|
-
const categories =
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
192
|
+
const categories =
|
|
193
|
+
kind === "Node" ? NODE_METRIC_CATEGORIES : WORKLOAD_METRIC_CATEGORIES;
|
|
194
|
+
const isSupported = SUPPORTED_KINDS.has(kind);
|
|
195
|
+
const activeCategoryDef =
|
|
196
|
+
categories.find((c) => c.key === category) || categories[0];
|
|
197
|
+
const series = metrics?.result?.series ?? [];
|
|
198
|
+
const coverageNote = metrics ? describePodCoverage(metrics) : undefined;
|
|
106
199
|
|
|
107
|
-
if (!isSupported) return null
|
|
200
|
+
if (!isSupported) return null;
|
|
108
201
|
|
|
109
202
|
if (statusLoading) {
|
|
110
|
-
if (!showEmptyState) return null
|
|
203
|
+
if (!showEmptyState) return null;
|
|
111
204
|
return (
|
|
112
205
|
<PaneLoader label="Checking Prometheus availability…" className="py-12" />
|
|
113
|
-
)
|
|
206
|
+
);
|
|
114
207
|
}
|
|
115
208
|
|
|
116
209
|
if (!showEmptyState) {
|
|
117
|
-
if (!isConnected) return null
|
|
118
|
-
if (!metricsLoading && !metricsError && !series.length) return null
|
|
210
|
+
if (!isConnected) return null;
|
|
211
|
+
if (!metricsLoading && !metricsError && !series.length) return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!isConnected && discovering) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
217
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
218
|
+
Discovering Prometheus…
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
119
221
|
}
|
|
120
222
|
|
|
121
223
|
if (!isConnected) {
|
|
@@ -123,9 +225,12 @@ export function PrometheusChartsView({
|
|
|
123
225
|
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
124
226
|
<WifiOff className="h-10 w-10 text-theme-text-tertiary" />
|
|
125
227
|
<div className="text-center">
|
|
126
|
-
<p className="mb-1 text-sm text-theme-text-secondary">
|
|
228
|
+
<p className="mb-1 text-sm text-theme-text-secondary">
|
|
229
|
+
Prometheus not connected
|
|
230
|
+
</p>
|
|
127
231
|
<p className="mb-4 text-xs text-theme-text-tertiary">
|
|
128
|
-
{statusError ||
|
|
232
|
+
{statusError ||
|
|
233
|
+
"Connect to view historical CPU, memory, and network metrics"}
|
|
129
234
|
</p>
|
|
130
235
|
<button
|
|
131
236
|
type="button"
|
|
@@ -133,12 +238,16 @@ export function PrometheusChartsView({
|
|
|
133
238
|
disabled={connecting}
|
|
134
239
|
className="btn-brand inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
|
135
240
|
>
|
|
136
|
-
{connecting ?
|
|
241
|
+
{connecting ? (
|
|
242
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
243
|
+
) : (
|
|
244
|
+
<Wifi className="h-4 w-4" />
|
|
245
|
+
)}
|
|
137
246
|
Discover Prometheus
|
|
138
247
|
</button>
|
|
139
248
|
</div>
|
|
140
249
|
</div>
|
|
141
|
-
)
|
|
250
|
+
);
|
|
142
251
|
}
|
|
143
252
|
|
|
144
253
|
return (
|
|
@@ -153,10 +262,10 @@ export function PrometheusChartsView({
|
|
|
153
262
|
type="button"
|
|
154
263
|
onClick={() => onCategoryChange(cat.key)}
|
|
155
264
|
className={clsx(
|
|
156
|
-
|
|
265
|
+
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
|
157
266
|
category === cat.key
|
|
158
|
-
?
|
|
159
|
-
:
|
|
267
|
+
? "bg-theme-elevated text-theme-text-primary shadow-sm"
|
|
268
|
+
: "text-theme-text-tertiary hover:bg-theme-elevated/50 hover:text-theme-text-secondary",
|
|
160
269
|
)}
|
|
161
270
|
>
|
|
162
271
|
{cat.label}
|
|
@@ -186,7 +295,11 @@ export function PrometheusChartsView({
|
|
|
186
295
|
</div>
|
|
187
296
|
) : series.length ? (
|
|
188
297
|
<div className="flex h-full flex-col gap-4">
|
|
189
|
-
<MetricsSummary
|
|
298
|
+
<MetricsSummary
|
|
299
|
+
series={series}
|
|
300
|
+
unit={metrics!.unit}
|
|
301
|
+
currentColorClass={activeCategoryDef.color}
|
|
302
|
+
/>
|
|
190
303
|
<div className="min-h-0 flex-1">
|
|
191
304
|
<AreaChart
|
|
192
305
|
series={series}
|
|
@@ -196,15 +309,31 @@ export function PrometheusChartsView({
|
|
|
196
309
|
referenceLines={referenceLines}
|
|
197
310
|
/>
|
|
198
311
|
</div>
|
|
199
|
-
{series.length > 1 &&
|
|
312
|
+
{series.length > 1 && (
|
|
313
|
+
<SeriesLegend
|
|
314
|
+
series={series}
|
|
315
|
+
color={activeCategoryDef.chartColor}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
{coverageNote && (
|
|
319
|
+
<p className="text-xs text-theme-text-tertiary">{coverageNote}</p>
|
|
320
|
+
)}
|
|
200
321
|
</div>
|
|
201
322
|
) : (
|
|
202
323
|
<div className="flex h-full flex-col items-center justify-center text-theme-text-tertiary">
|
|
203
324
|
<BarChart3 className="mb-2 h-8 w-8 opacity-40" />
|
|
204
325
|
<p className="text-sm">No data for this time range</p>
|
|
205
326
|
<p className="mt-1 text-xs text-theme-text-quaternary">
|
|
206
|
-
Try a different time range or check that metrics are being
|
|
327
|
+
Try a different time range or check that metrics are being
|
|
328
|
+
collected
|
|
207
329
|
</p>
|
|
330
|
+
{/* An empty chart is where the pod set matters most: it separates
|
|
331
|
+
"these pods reported nothing" from "we charted the wrong pods". */}
|
|
332
|
+
{coverageNote && (
|
|
333
|
+
<p className="mt-2 max-w-lg text-center text-xs text-theme-text-tertiary">
|
|
334
|
+
{coverageNote}
|
|
335
|
+
</p>
|
|
336
|
+
)}
|
|
208
337
|
{metrics?.hint && (
|
|
209
338
|
<p className="mt-3 w-full max-w-lg rounded border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-700 dark:text-yellow-400">
|
|
210
339
|
{metrics.hint}
|
|
@@ -224,5 +353,5 @@ export function PrometheusChartsView({
|
|
|
224
353
|
)}
|
|
225
354
|
</div>
|
|
226
355
|
</div>
|
|
227
|
-
)
|
|
356
|
+
);
|
|
228
357
|
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { seriesColor } from './colors'
|
|
1
|
+
import { seriesColor, seriesDisplayLabels } from './colors'
|
|
2
2
|
import type { TimeSeries } from './types'
|
|
3
3
|
|
|
4
4
|
// Caps visible entries to match AreaChart's SERIES_COLORS length; extras
|
|
5
5
|
// collapse to "+N more".
|
|
6
|
-
export function SeriesLegend({ series, color }: {
|
|
7
|
-
|
|
6
|
+
export function SeriesLegend({ series, color, seriesLabels }: {
|
|
7
|
+
series: TimeSeries[]
|
|
8
|
+
color: string
|
|
9
|
+
/** Display name per series, parallel to `series`; defaults to label-derived names. */
|
|
10
|
+
seriesLabels?: string[]
|
|
11
|
+
}) {
|
|
12
|
+
const labels = seriesLabels ?? seriesDisplayLabels(series)
|
|
8
13
|
return (
|
|
9
14
|
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
|
10
15
|
{series.slice(0, 10).map((_, i) => {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChartAnnotation } from './types'
|
|
2
|
+
|
|
3
|
+
export interface PlacedAnnotation {
|
|
4
|
+
annotation: ChartAnnotation
|
|
5
|
+
x: number
|
|
6
|
+
/** Left edge of the label pill, kept inside the plot. */
|
|
7
|
+
labelX: number
|
|
8
|
+
/** Stacking row for the label pill; 0 is the top row. */
|
|
9
|
+
row: number
|
|
10
|
+
labelText: string
|
|
11
|
+
labelWidth: number
|
|
12
|
+
/** True when the label could not find a free row and only the line renders. */
|
|
13
|
+
labelHidden: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ANNOTATION_LABEL_MAX_CHARS = 28
|
|
17
|
+
export const ANNOTATION_LABEL_ROWS = 3
|
|
18
|
+
const LABEL_GAP = 6
|
|
19
|
+
|
|
20
|
+
export function truncateAnnotationLabel(label: string): string {
|
|
21
|
+
return label.length > ANNOTATION_LABEL_MAX_CHARS
|
|
22
|
+
? `${label.slice(0, ANNOTATION_LABEL_MAX_CHARS - 1)}…`
|
|
23
|
+
: label
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Positions annotation labels so none overlap: markers are sorted by time and
|
|
28
|
+
* each label takes the first row whose previous label ends before it starts,
|
|
29
|
+
* shifting left only as far as the plot edge allows. A label that fits no row
|
|
30
|
+
* is hidden; its line still renders and its text remains in the hover tooltip.
|
|
31
|
+
*/
|
|
32
|
+
export function layoutAnnotations(
|
|
33
|
+
annotations: readonly ChartAnnotation[],
|
|
34
|
+
toX: (timestamp: number) => number,
|
|
35
|
+
domain: { minTs: number; maxTs: number },
|
|
36
|
+
plot: { left: number; right: number },
|
|
37
|
+
): PlacedAnnotation[] {
|
|
38
|
+
const inWindow = annotations
|
|
39
|
+
.filter(a => Number.isFinite(a.timestamp) && a.timestamp >= domain.minTs && a.timestamp <= domain.maxTs)
|
|
40
|
+
.map(a => ({ annotation: a, x: toX(a.timestamp) }))
|
|
41
|
+
.sort((a, b) => a.x - b.x)
|
|
42
|
+
const rowEnds: number[] = []
|
|
43
|
+
return inWindow.map(({ annotation, x }) => {
|
|
44
|
+
const labelText = truncateAnnotationLabel(annotation.label)
|
|
45
|
+
const labelWidth = labelText.length * 6.5 + 10
|
|
46
|
+
const start = Math.max(plot.left, Math.min(plot.right - labelWidth, x + 4))
|
|
47
|
+
let row = rowEnds.findIndex(end => end + LABEL_GAP <= start)
|
|
48
|
+
if (row === -1) {
|
|
49
|
+
if (rowEnds.length >= ANNOTATION_LABEL_ROWS) {
|
|
50
|
+
return { annotation, x, labelX: start, row: -1, labelText, labelWidth, labelHidden: true }
|
|
51
|
+
}
|
|
52
|
+
row = rowEnds.length
|
|
53
|
+
rowEnds.push(start + labelWidth)
|
|
54
|
+
} else {
|
|
55
|
+
rowEnds[row] = start + labelWidth
|
|
56
|
+
}
|
|
57
|
+
return { annotation, x, labelX: start, row, labelText, labelWidth, labelHidden: false }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { chartLayout, integerAxisTop, yAxisValues } from './axis'
|
|
3
|
+
|
|
4
|
+
describe('integer axes', () => {
|
|
5
|
+
it('ends a count axis on a whole step that reaches the maximum', () => {
|
|
6
|
+
expect(integerAxisTop(0, 4)).toBe(1)
|
|
7
|
+
expect(integerAxisTop(1, 4)).toBe(1)
|
|
8
|
+
expect(integerAxisTop(3, 4)).toBe(3)
|
|
9
|
+
expect(integerAxisTop(18, 4)).toBe(20)
|
|
10
|
+
expect(integerAxisTop(250, 4)).toBe(300)
|
|
11
|
+
expect(integerAxisTop(1.1, 4)).toBe(2)
|
|
12
|
+
expect(integerAxisTop(18, 1)).toBe(20)
|
|
13
|
+
expect(integerAxisTop(7, 1)).toBe(10)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('never yields fractional ticks on an integer axis', () => {
|
|
17
|
+
expect(yAxisValues(0, 1, 4, true)).toEqual([0, 1])
|
|
18
|
+
expect(yAxisValues(0, 3, 4, true)).toEqual([0, 1, 2, 3])
|
|
19
|
+
expect(yAxisValues(0, 20, 4, true)).toEqual([0, 5, 10, 15, 20])
|
|
20
|
+
expect(yAxisValues(0, 20, 1, true)).toEqual([0, 20])
|
|
21
|
+
expect(yAxisValues(0, 1.1, 4, false)).toEqual([0, 0.275, 0.55, 0.8250000000000001, 1.1])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('has a compact layout with two ticks per axis and larger text', () => {
|
|
25
|
+
const full = chartLayout(false)
|
|
26
|
+
const compact = chartLayout(true)
|
|
27
|
+
expect([full.yIntervals, full.xIntervals, full.fontSize]).toEqual([4, 6, 11])
|
|
28
|
+
expect([compact.yIntervals, compact.xIntervals]).toEqual([1, 1])
|
|
29
|
+
expect(compact.fontSize).toBeGreaterThan(full.fontSize)
|
|
30
|
+
expect(compact.height / compact.width).toBeGreaterThan(full.height / full.width)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('keeps dashboard axes readable without changing existing layouts', () => {
|
|
34
|
+
const dashboard = chartLayout(false, true)
|
|
35
|
+
expect([dashboard.width, dashboard.height, dashboard.fontSize]).toEqual([600, 240, 14])
|
|
36
|
+
expect(chartLayout(true, true)).toEqual(chartLayout(true))
|
|
37
|
+
expect(chartLayout(false).width).toBe(1000)
|
|
38
|
+
})
|
|
39
|
+
it('fits dashboard plots to their container without scaling text or height', () => {
|
|
40
|
+
for (const width of [280, 380, 550, 1050]) {
|
|
41
|
+
const layout = chartLayout(width < 420, true, width)
|
|
42
|
+
expect(layout.width).toBe(width)
|
|
43
|
+
expect(layout.height).toBe(240)
|
|
44
|
+
expect(layout.fontSize).toBe(12)
|
|
45
|
+
}
|
|
46
|
+
expect(chartLayout(false, false, 500)).toEqual(chartLayout(false))
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Axis helpers shared by the chart's full and compact layouts. Pure so the
|
|
2
|
+
// tick choices can be tested without rendering.
|
|
3
|
+
|
|
4
|
+
export interface ChartLayout {
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
marginLeft: number
|
|
8
|
+
marginRight: number
|
|
9
|
+
marginTop: number
|
|
10
|
+
marginBottom: number
|
|
11
|
+
fontSize: number
|
|
12
|
+
/** Number of Y intervals between the lowest and highest tick. */
|
|
13
|
+
yIntervals: number
|
|
14
|
+
/** Number of X intervals between the first and last tick. */
|
|
15
|
+
xIntervals: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The compact layout trades tick density for legible text: a 400-unit
|
|
19
|
+
// viewBox scaled into a ~280px pane keeps 14-unit text near 10px, where the
|
|
20
|
+
// full layout's 11-unit text in a 1000-unit viewBox would be 3px.
|
|
21
|
+
export function chartLayout(compact: boolean, dashboard = false, renderedWidth = 0): ChartLayout {
|
|
22
|
+
if (dashboard && renderedWidth > 0) return { width: renderedWidth, height: 240, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 12, yIntervals: compact ? 1 : 2, xIntervals: compact ? 1 : renderedWidth >= 760 ? 4 : 2 }
|
|
23
|
+
if (dashboard && !compact) return { width: 600, height: 240, marginLeft: 90, marginRight: 24, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 2, xIntervals: 2 }
|
|
24
|
+
return compact
|
|
25
|
+
? { width: 400, height: 260, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 1, xIntervals: 1 }
|
|
26
|
+
: { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isCountUnit(unit: string): boolean {
|
|
30
|
+
return unit === 'count' || unit === 'restarts'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Top of an integer axis: the smallest 1/2/5×10ⁿ step that reaches `max`
|
|
35
|
+
* within `intervals` ticks, rounded up to a whole step. `max` 0 or 1 gives 1,
|
|
36
|
+
* 18 gives 20 (step 5), 3 gives 3 (step 1).
|
|
37
|
+
*/
|
|
38
|
+
export function integerAxisTop(max: number, intervals: number): number {
|
|
39
|
+
const target = Math.max(1, Math.ceil(max))
|
|
40
|
+
const steps = Math.max(1, intervals)
|
|
41
|
+
let magnitude = 1
|
|
42
|
+
for (;;) {
|
|
43
|
+
for (const base of [1, 2, 5]) {
|
|
44
|
+
const step = base * magnitude
|
|
45
|
+
if (step * steps >= target) return step * Math.ceil(target / step)
|
|
46
|
+
}
|
|
47
|
+
magnitude *= 10
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Tick values from yMin to yMax; integer axes never produce fractional ticks. */
|
|
52
|
+
export function yAxisValues(yMin: number, yMax: number, intervals: number, integer: boolean): number[] {
|
|
53
|
+
if (integer && yMin === 0) {
|
|
54
|
+
const step = yMax / intervals
|
|
55
|
+
const wholeStep = Number.isInteger(step) ? step : Math.max(1, Math.ceil(step))
|
|
56
|
+
const values: number[] = []
|
|
57
|
+
for (let value = 0; value <= yMax; value += wholeStep) values.push(value)
|
|
58
|
+
if (values[values.length - 1] !== yMax) values.push(yMax)
|
|
59
|
+
return values
|
|
60
|
+
}
|
|
61
|
+
return Array.from({ length: intervals + 1 }, (_, i) => yMin + ((yMax - yMin) / intervals) * i)
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { seriesDisplayLabels } from './colors'
|
|
3
|
+
|
|
4
|
+
describe('seriesDisplayLabels', () => {
|
|
5
|
+
it('prefers pod, instance or node labels when they are distinct', () => {
|
|
6
|
+
expect(seriesDisplayLabels([
|
|
7
|
+
{ labels: { pod: 'api-a', container: 'api' } },
|
|
8
|
+
{ labels: { instance: '10.0.0.2:9100' } },
|
|
9
|
+
{ labels: { node: 'n1' } },
|
|
10
|
+
])).toEqual(['api-a', '10.0.0.2:9100', 'n1'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('shows every label when the preferred label is missing or shared', () => {
|
|
14
|
+
expect(seriesDisplayLabels([
|
|
15
|
+
{ labels: { container: 'api' } },
|
|
16
|
+
{ labels: { container: 'envoy' } },
|
|
17
|
+
])).toEqual(['container=api', 'container=envoy'])
|
|
18
|
+
expect(seriesDisplayLabels([
|
|
19
|
+
{ labels: { pod: 'api-a', container: 'api' } },
|
|
20
|
+
{ labels: { pod: 'api-a', container: 'envoy' } },
|
|
21
|
+
])).toEqual(['pod=api-a, container=api', 'pod=api-a, container=envoy'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to a positional name for an unlabelled series', () => {
|
|
25
|
+
expect(seriesDisplayLabels([{ labels: {} }, { labels: { pod: 'a' } }])).toEqual(['series-0', 'a'])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -22,6 +22,29 @@ export function seriesFill(index: number, fallback: string): string {
|
|
|
22
22
|
return (SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback) + '22'
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const PREFERRED_SERIES_LABELS = ['pod', 'instance', 'node'] as const
|
|
26
|
+
|
|
27
|
+
function allLabels(labels: Record<string, string>): string {
|
|
28
|
+
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(', ')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One display name per series. A pod, instance or node label is the name
|
|
33
|
+
* operators recognise, but an aggregation may drop it or two series may
|
|
34
|
+
* share it (one per container); then every label is shown so no two series
|
|
35
|
+
* become indistinguishable.
|
|
36
|
+
*/
|
|
37
|
+
export function seriesDisplayLabels(series: readonly { labels: Record<string, string> }[]): string[] {
|
|
38
|
+
const preferred = series.map((s, i) => {
|
|
39
|
+
for (const key of PREFERRED_SERIES_LABELS) {
|
|
40
|
+
if (s.labels[key]) return s.labels[key]
|
|
41
|
+
}
|
|
42
|
+
return Object.keys(s.labels).length > 0 ? allLabels(s.labels) : `series-${i}`
|
|
43
|
+
})
|
|
44
|
+
if (new Set(preferred).size === preferred.length) return preferred
|
|
45
|
+
return series.map((s, i) => (Object.keys(s.labels).length > 0 ? allLabels(s.labels) : `series-${i}`))
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
/**
|
|
26
49
|
* Strip the shared prefix from a set of labels so the differentiating suffix
|
|
27
50
|
* is what's shown. Example:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatMetricValue } from './format'
|
|
3
|
+
|
|
4
|
+
describe('formatMetricValue', () => {
|
|
5
|
+
it('scales unitless values with SI prefixes instead of a runaway k', () => {
|
|
6
|
+
expect(formatMetricValue(19_027_100, '')).toBe('19.0M')
|
|
7
|
+
expect(formatMetricValue(38_054_300, '')).toBe('38.1M')
|
|
8
|
+
expect(formatMetricValue(57_081, '')).toBe('57.1k')
|
|
9
|
+
expect(formatMetricValue(4_200_000_000, '')).toBe('4.20G')
|
|
10
|
+
expect(formatMetricValue(812, '')).toBe('812')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('formats seconds and negative values', () => {
|
|
14
|
+
expect(formatMetricValue(0.25, 'seconds')).toBe('250ms')
|
|
15
|
+
expect(formatMetricValue(90, 'seconds')).toBe('1.5m')
|
|
16
|
+
expect(formatMetricValue(-3.5, '')).toBe('-3.50')
|
|
17
|
+
expect(formatMetricValue(-2048, 'bytes')).toBe('-2.0 KiB')
|
|
18
|
+
})
|
|
19
|
+
})
|