@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.
@@ -1,10 +1,10 @@
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'
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 = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
17
- export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
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
- { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
29
- { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
30
- { key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
31
- { key: 'network_tx', label: 'Net TX', color: 'text-orange-400', chartColor: '#fb923c', fillColor: '#fb923c22' },
32
- { key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
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
- { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
37
- { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
38
- { key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
39
- ]
40
-
41
- export const METRIC_TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
42
- { value: '10m', label: '10m' },
43
- { value: '30m', label: '30m' },
44
- { value: '1h', label: '1h' },
45
- { value: '3h', label: '3h' },
46
- { value: '6h', label: '6h' },
47
- { value: '12h', label: '12h' },
48
- { value: '24h', label: '24h' },
49
- { value: '7d', label: '7d' },
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
- 'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
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 = kind === 'Node' ? NODE_METRIC_CATEGORIES : WORKLOAD_METRIC_CATEGORIES
103
- const isSupported = SUPPORTED_KINDS.has(kind)
104
- const activeCategoryDef = categories.find((c) => c.key === category) || categories[0]
105
- const series = metrics?.result?.series ?? []
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">Prometheus not connected</p>
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 || 'Connect to view historical CPU, memory, and network metrics'}
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
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
- 'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
253
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
157
254
  category === cat.key
158
- ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
159
- : 'text-theme-text-tertiary hover:bg-theme-elevated/50 hover:text-theme-text-secondary',
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 series={series} unit={metrics!.unit} currentColorClass={activeCategoryDef.color} />
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 && <SeriesLegend series={series} color={activeCategoryDef.chartColor} />}
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 collected
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 }: { series: TimeSeries[]; color: string }) {
7
- const labels = series.map((s, i) => s.labels.pod || s.labels.instance || `series-${i}`)
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 / 1000).toFixed(1)}k`
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