@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.
Files changed (85) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +173 -0
  5. package/src/components/charts/AreaChart.tsx +208 -53
  6. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  7. package/src/components/charts/PrometheusChartsView.tsx +205 -76
  8. package/src/components/charts/SeriesLegend.tsx +8 -3
  9. package/src/components/charts/annotations.ts +59 -0
  10. package/src/components/charts/axis.test.ts +48 -0
  11. package/src/components/charts/axis.ts +62 -0
  12. package/src/components/charts/colors.test.ts +27 -0
  13. package/src/components/charts/colors.ts +23 -0
  14. package/src/components/charts/format.test.ts +19 -0
  15. package/src/components/charts/format.ts +21 -1
  16. package/src/components/charts/index.ts +3 -1
  17. package/src/components/charts/nearestSample.test.ts +31 -0
  18. package/src/components/charts/nearestSample.ts +15 -0
  19. package/src/components/charts/types.ts +12 -0
  20. package/src/components/checks/ChecksView.tsx +20 -2
  21. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  22. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  23. package/src/components/gitops/health-provenance.test.ts +54 -0
  24. package/src/components/gitops/health-provenance.ts +24 -0
  25. package/src/components/gitops/index.ts +2 -0
  26. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  28. package/src/components/gitops/tree/merge.test.ts +34 -0
  29. package/src/components/gitops/tree/merge.ts +7 -0
  30. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  31. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  32. package/src/components/resources/ResourcesView.tsx +67 -2
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/index.ts +7 -0
  35. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  36. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  37. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  38. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  39. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  40. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  41. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  42. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  45. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  46. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  47. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  48. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  49. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  50. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
  52. package/src/components/resources/renderers/index.ts +3 -0
  53. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  54. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  55. package/src/components/resources/resource-utils.ts +18 -9
  56. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  57. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  58. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  59. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  60. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  61. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  62. package/src/components/shared/index.ts +4 -0
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/ui/Badge.tsx +5 -1
  66. package/src/components/ui/FetchResult.test.tsx +20 -2
  67. package/src/components/ui/FetchResult.tsx +33 -3
  68. package/src/components/ui/FreshnessControl.tsx +8 -2
  69. package/src/components/ui/index.ts +5 -0
  70. package/src/components/ui/monacoRuntime.ts +14 -0
  71. package/src/components/workload/WorkloadView.tsx +2 -2
  72. package/src/monaco-deep.d.ts +10 -0
  73. package/src/theme/components.css +16 -0
  74. package/src/types/core.ts +38 -11
  75. package/src/types/gitops-insights.ts +23 -0
  76. package/src/types/gitops-tree.ts +29 -0
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/navigation.test.ts +20 -1
  79. package/src/utils/navigation.ts +25 -0
  80. package/src/utils/network-policy.test.ts +42 -0
  81. package/src/utils/network-policy.ts +59 -0
  82. package/src/utils/reflector.ts +139 -0
  83. package/src/utils/yaml-file-import.test.ts +189 -0
  84. package/src/utils/yaml-file-import.ts +95 -0
  85. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -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,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 = '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;
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 = 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 ?? []
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">Prometheus not connected</p>
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 || 'Connect to view historical CPU, memory, and network metrics'}
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
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
- 'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
265
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
157
266
  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',
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 series={series} unit={metrics!.unit} currentColorClass={activeCategoryDef.color} />
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 && <SeriesLegend series={series} color={activeCategoryDef.chartColor} />}
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 collected
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 }: { 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,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
+ })