@skyhook-io/k8s-ui 1.14.4 → 1.14.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 +1 -1
- package/src/components/charts/AreaChart.test.tsx +160 -0
- package/src/components/charts/AreaChart.tsx +191 -39
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +193 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +32 -0
- package/src/components/charts/axis.ts +60 -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 +17 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/types.ts +12 -0
- package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/index.ts +7 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -57
- package/src/components/resources/renderers/WorkloadRenderer.tsx +4 -20
- 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/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/monaco-deep.d.ts +10 -0
- package/src/types/core.ts +23 -10
|
@@ -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,160 @@ 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;
|
|
73
160
|
// Selection (controlled by host)
|
|
74
|
-
category: PrometheusMetricCategory
|
|
75
|
-
onCategoryChange: (c: PrometheusMetricCategory) => void
|
|
76
|
-
range: PrometheusTimeRange
|
|
77
|
-
onRangeChange: (r: PrometheusTimeRange) => void
|
|
161
|
+
category: PrometheusMetricCategory;
|
|
162
|
+
onCategoryChange: (c: PrometheusMetricCategory) => void;
|
|
163
|
+
range: PrometheusTimeRange;
|
|
164
|
+
onRangeChange: (r: PrometheusTimeRange) => void;
|
|
78
165
|
// Metrics (host-fetched)
|
|
79
|
-
metrics?: PrometheusResourceMetricsResult
|
|
80
|
-
metricsLoading: boolean
|
|
81
|
-
metricsError?: Error | null
|
|
82
|
-
referenceLines?: ReferenceLine[]
|
|
166
|
+
metrics?: PrometheusResourceMetricsResult;
|
|
167
|
+
metricsLoading: boolean;
|
|
168
|
+
metricsError?: Error | null;
|
|
169
|
+
referenceLines?: ReferenceLine[];
|
|
83
170
|
}
|
|
84
171
|
|
|
85
172
|
export function PrometheusChartsView({
|
|
@@ -99,23 +186,26 @@ export function PrometheusChartsView({
|
|
|
99
186
|
metricsError,
|
|
100
187
|
referenceLines,
|
|
101
188
|
}: PrometheusChartsViewProps) {
|
|
102
|
-
const categories =
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
189
|
+
const categories =
|
|
190
|
+
kind === "Node" ? NODE_METRIC_CATEGORIES : WORKLOAD_METRIC_CATEGORIES;
|
|
191
|
+
const isSupported = SUPPORTED_KINDS.has(kind);
|
|
192
|
+
const activeCategoryDef =
|
|
193
|
+
categories.find((c) => c.key === category) || categories[0];
|
|
194
|
+
const series = metrics?.result?.series ?? [];
|
|
195
|
+
const coverageNote = metrics ? describePodCoverage(metrics) : undefined;
|
|
106
196
|
|
|
107
|
-
if (!isSupported) return null
|
|
197
|
+
if (!isSupported) return null;
|
|
108
198
|
|
|
109
199
|
if (statusLoading) {
|
|
110
|
-
if (!showEmptyState) return null
|
|
200
|
+
if (!showEmptyState) return null;
|
|
111
201
|
return (
|
|
112
202
|
<PaneLoader label="Checking Prometheus availability…" className="py-12" />
|
|
113
|
-
)
|
|
203
|
+
);
|
|
114
204
|
}
|
|
115
205
|
|
|
116
206
|
if (!showEmptyState) {
|
|
117
|
-
if (!isConnected) return null
|
|
118
|
-
if (!metricsLoading && !metricsError && !series.length) return null
|
|
207
|
+
if (!isConnected) return null;
|
|
208
|
+
if (!metricsLoading && !metricsError && !series.length) return null;
|
|
119
209
|
}
|
|
120
210
|
|
|
121
211
|
if (!isConnected) {
|
|
@@ -123,9 +213,12 @@ export function PrometheusChartsView({
|
|
|
123
213
|
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
124
214
|
<WifiOff className="h-10 w-10 text-theme-text-tertiary" />
|
|
125
215
|
<div className="text-center">
|
|
126
|
-
<p className="mb-1 text-sm text-theme-text-secondary">
|
|
216
|
+
<p className="mb-1 text-sm text-theme-text-secondary">
|
|
217
|
+
Prometheus not connected
|
|
218
|
+
</p>
|
|
127
219
|
<p className="mb-4 text-xs text-theme-text-tertiary">
|
|
128
|
-
{statusError ||
|
|
220
|
+
{statusError ||
|
|
221
|
+
"Connect to view historical CPU, memory, and network metrics"}
|
|
129
222
|
</p>
|
|
130
223
|
<button
|
|
131
224
|
type="button"
|
|
@@ -133,12 +226,16 @@ export function PrometheusChartsView({
|
|
|
133
226
|
disabled={connecting}
|
|
134
227
|
className="btn-brand inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
|
135
228
|
>
|
|
136
|
-
{connecting ?
|
|
229
|
+
{connecting ? (
|
|
230
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
231
|
+
) : (
|
|
232
|
+
<Wifi className="h-4 w-4" />
|
|
233
|
+
)}
|
|
137
234
|
Discover Prometheus
|
|
138
235
|
</button>
|
|
139
236
|
</div>
|
|
140
237
|
</div>
|
|
141
|
-
)
|
|
238
|
+
);
|
|
142
239
|
}
|
|
143
240
|
|
|
144
241
|
return (
|
|
@@ -153,10 +250,10 @@ export function PrometheusChartsView({
|
|
|
153
250
|
type="button"
|
|
154
251
|
onClick={() => onCategoryChange(cat.key)}
|
|
155
252
|
className={clsx(
|
|
156
|
-
|
|
253
|
+
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
|
157
254
|
category === cat.key
|
|
158
|
-
?
|
|
159
|
-
:
|
|
255
|
+
? "bg-theme-elevated text-theme-text-primary shadow-sm"
|
|
256
|
+
: "text-theme-text-tertiary hover:bg-theme-elevated/50 hover:text-theme-text-secondary",
|
|
160
257
|
)}
|
|
161
258
|
>
|
|
162
259
|
{cat.label}
|
|
@@ -186,7 +283,11 @@ export function PrometheusChartsView({
|
|
|
186
283
|
</div>
|
|
187
284
|
) : series.length ? (
|
|
188
285
|
<div className="flex h-full flex-col gap-4">
|
|
189
|
-
<MetricsSummary
|
|
286
|
+
<MetricsSummary
|
|
287
|
+
series={series}
|
|
288
|
+
unit={metrics!.unit}
|
|
289
|
+
currentColorClass={activeCategoryDef.color}
|
|
290
|
+
/>
|
|
190
291
|
<div className="min-h-0 flex-1">
|
|
191
292
|
<AreaChart
|
|
192
293
|
series={series}
|
|
@@ -196,15 +297,31 @@ export function PrometheusChartsView({
|
|
|
196
297
|
referenceLines={referenceLines}
|
|
197
298
|
/>
|
|
198
299
|
</div>
|
|
199
|
-
{series.length > 1 &&
|
|
300
|
+
{series.length > 1 && (
|
|
301
|
+
<SeriesLegend
|
|
302
|
+
series={series}
|
|
303
|
+
color={activeCategoryDef.chartColor}
|
|
304
|
+
/>
|
|
305
|
+
)}
|
|
306
|
+
{coverageNote && (
|
|
307
|
+
<p className="text-xs text-theme-text-tertiary">{coverageNote}</p>
|
|
308
|
+
)}
|
|
200
309
|
</div>
|
|
201
310
|
) : (
|
|
202
311
|
<div className="flex h-full flex-col items-center justify-center text-theme-text-tertiary">
|
|
203
312
|
<BarChart3 className="mb-2 h-8 w-8 opacity-40" />
|
|
204
313
|
<p className="text-sm">No data for this time range</p>
|
|
205
314
|
<p className="mt-1 text-xs text-theme-text-quaternary">
|
|
206
|
-
Try a different time range or check that metrics are being
|
|
315
|
+
Try a different time range or check that metrics are being
|
|
316
|
+
collected
|
|
207
317
|
</p>
|
|
318
|
+
{/* An empty chart is where the pod set matters most: it separates
|
|
319
|
+
"these pods reported nothing" from "we charted the wrong pods". */}
|
|
320
|
+
{coverageNote && (
|
|
321
|
+
<p className="mt-2 max-w-lg text-center text-xs text-theme-text-tertiary">
|
|
322
|
+
{coverageNote}
|
|
323
|
+
</p>
|
|
324
|
+
)}
|
|
208
325
|
{metrics?.hint && (
|
|
209
326
|
<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
327
|
{metrics.hint}
|
|
@@ -224,5 +341,5 @@ export function PrometheusChartsView({
|
|
|
224
341
|
)}
|
|
225
342
|
</div>
|
|
226
343
|
</div>
|
|
227
|
-
)
|
|
344
|
+
);
|
|
228
345
|
}
|
|
@@ -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,32 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
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): ChartLayout {
|
|
22
|
+
return compact
|
|
23
|
+
? { width: 400, height: 260, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 1, xIntervals: 1 }
|
|
24
|
+
: { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isCountUnit(unit: string): boolean {
|
|
28
|
+
return unit === 'count' || unit === 'restarts'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Top of an integer axis: the smallest 1/2/5×10ⁿ step that reaches `max`
|
|
33
|
+
* within `intervals` ticks, rounded up to a whole step. `max` 0 or 1 gives 1,
|
|
34
|
+
* 18 gives 20 (step 5), 3 gives 3 (step 1).
|
|
35
|
+
*/
|
|
36
|
+
export function integerAxisTop(max: number, intervals: number): number {
|
|
37
|
+
const target = Math.max(1, Math.ceil(max))
|
|
38
|
+
const steps = Math.max(1, intervals)
|
|
39
|
+
let magnitude = 1
|
|
40
|
+
for (;;) {
|
|
41
|
+
for (const base of [1, 2, 5]) {
|
|
42
|
+
const step = base * magnitude
|
|
43
|
+
if (step * steps >= target) return step * Math.ceil(target / step)
|
|
44
|
+
}
|
|
45
|
+
magnitude *= 10
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tick values from yMin to yMax; integer axes never produce fractional ticks. */
|
|
50
|
+
export function yAxisValues(yMin: number, yMax: number, intervals: number, integer: boolean): number[] {
|
|
51
|
+
if (integer && yMin === 0) {
|
|
52
|
+
const step = yMax / intervals
|
|
53
|
+
const wholeStep = Number.isInteger(step) ? step : Math.max(1, Math.ceil(step))
|
|
54
|
+
const values: number[] = []
|
|
55
|
+
for (let value = 0; value <= yMax; value += wholeStep) values.push(value)
|
|
56
|
+
if (values[values.length - 1] !== yMax) values.push(yMax)
|
|
57
|
+
return values
|
|
58
|
+
}
|
|
59
|
+
return Array.from({ length: intervals + 1 }, (_, i) => yMin + ((yMax - yMin) / intervals) * i)
|
|
60
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export function formatMetricValue(value: number, unit: string): string {
|
|
6
6
|
if (value === 0) return '0'
|
|
7
|
+
if (value < 0) return `-${formatMetricValue(-value, unit)}`
|
|
7
8
|
|
|
8
9
|
switch (unit) {
|
|
9
10
|
case 'cores': {
|
|
@@ -26,12 +27,27 @@ export function formatMetricValue(value: number, unit: string): string {
|
|
|
26
27
|
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
|
|
27
28
|
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
|
|
28
29
|
}
|
|
30
|
+
case 'count':
|
|
31
|
+
case 'restarts': {
|
|
32
|
+
if (value < 10000) return value.toFixed(0)
|
|
33
|
+
if (value < 1e6) return `${(value / 1e3).toFixed(1)}k`
|
|
34
|
+
return `${(value / 1e6).toFixed(1)}M`
|
|
35
|
+
}
|
|
36
|
+
case 'seconds': {
|
|
37
|
+
if (value < 0.001) return `${(value * 1_000_000).toFixed(0)}µs`
|
|
38
|
+
if (value < 1) return `${(value * 1000).toFixed(value < 0.01 ? 2 : 0)}ms`
|
|
39
|
+
if (value < 60) return `${value.toFixed(value < 10 ? 2 : 1)}s`
|
|
40
|
+
if (value < 3600) return `${(value / 60).toFixed(1)}m`
|
|
41
|
+
return `${(value / 3600).toFixed(1)}h`
|
|
42
|
+
}
|
|
29
43
|
default:
|
|
30
44
|
if (value < 0.01) return value.toExponential(1)
|
|
31
45
|
if (value < 1) return value.toFixed(3)
|
|
32
46
|
if (value < 100) return value.toFixed(2)
|
|
33
47
|
if (value < 10000) return value.toFixed(0)
|
|
34
|
-
return `${(value /
|
|
48
|
+
if (value < 1e6) return `${(value / 1e3).toFixed(1)}k`
|
|
49
|
+
if (value < 1e9) return `${(value / 1e6).toFixed(1)}M`
|
|
50
|
+
return `${(value / 1e9).toFixed(2)}G`
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
53
|
|