@skyhook-io/k8s-ui 1.18.2 → 1.20.0

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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.18.2",
3
+ "version": "1.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -20,6 +20,13 @@
20
20
  "./src/components/resources/*.tsx"
21
21
  ],
22
22
  "./components/resources/KueueAdmissionSection": "./src/components/resources/KueueAdmissionSection.tsx",
23
+ "./components/kueue/AdmissionPath": "./src/components/kueue/AdmissionPath.tsx",
24
+ "./components/kueue/QueueEvidence": "./src/components/kueue/QueueEvidence.tsx",
25
+ "./components/kueue/*": [
26
+ "./src/components/kueue/*.ts",
27
+ "./src/components/kueue/*.tsx"
28
+ ],
29
+ "./components/resources/KueuePendingPosition": "./src/components/resources/KueuePendingPosition.tsx",
23
30
  "./components/resources/renderers/*": [
24
31
  "./src/components/resources/renderers/*.tsx",
25
32
  "./src/components/resources/renderers/*.ts"
@@ -89,7 +96,7 @@
89
96
  "yaml": ">=2.0.0"
90
97
  },
91
98
  "devDependencies": {
92
- "@types/node": "^26.6.2",
99
+ "@types/node": "^26.6.6",
93
100
  "@types/react": "^19.3.0",
94
101
  "@types/react-dom": "^19.3.0",
95
102
  "@xterm/addon-fit": "^0.11.0",
@@ -1833,6 +1833,8 @@ function batchOverviewStats(activity: AppBatchActivity[]) {
1833
1833
  );
1834
1834
  const failed = activity.reduce((n, item) => n + item.failedRuns, 0);
1835
1835
  const active = activity.reduce((n, item) => n + item.activeRuns, 0);
1836
+ const rootActivity = activity.filter(item => item.workload.kind === 'JobSet' && item.workload.group === 'jobset.x-k8s.io')
1837
+ const rootStates = [...new Set(rootActivity.map(item => item.latestRunPhase || 'Unknown'))].join(', ')
1836
1838
 
1837
1839
  return {
1838
1840
  latestValue: latest?.latestRunPhase || latest?.label || "None",
@@ -1841,9 +1843,9 @@ function batchOverviewStats(activity: AppBatchActivity[]) {
1841
1843
  : "No retained runs",
1842
1844
  retainedValue:
1843
1845
  retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
1844
- activeValue: active > 0 ? `${active} running` : "None",
1846
+ activeValue: active > 0 ? `${active} running` : rootStates || "None",
1845
1847
  activeDetail:
1846
- active > 0 ? activeWorkloadNames(activity) : "No active batch work",
1848
+ active > 0 ? activeWorkloadNames(activity) : rootStates ? "JobSet root lifecycle; running Pods are not inferred." : "No active batch work",
1847
1849
  scheduleValue:
1848
1850
  schedules.length === 0
1849
1851
  ? "None"
@@ -2068,6 +2070,9 @@ function workloadRuntimeStatus(workload: AppWorkload): {
2068
2070
  health: rolloutDisplayHealth(workload.rollout, workload.health),
2069
2071
  }
2070
2072
  }
2073
+ if (workload.kind === 'JobSet' && workload.group === 'jobset.x-k8s.io') {
2074
+ return { label: workload.batch?.latestRunPhase || 'Unknown', health: healthOf(workload.health) }
2075
+ }
2071
2076
  const batch = workload.batch;
2072
2077
  if (!batch) {
2073
2078
  const health = healthOf(workload.health);
@@ -2085,6 +2090,7 @@ function workloadRuntimeStatus(workload: AppWorkload): {
2085
2090
 
2086
2091
  function workloadRuntimeDetail(workload: AppWorkload): string {
2087
2092
  if (workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled')) return workload.rollout.detail || `${workload.ready}/${workload.desired} ready`
2093
+ if (workload.kind === 'JobSet' && workload.group === 'jobset.x-k8s.io') return workload.batch?.message || `Root lifecycle: ${workload.batch?.latestRunPhase || 'Unknown'}`
2088
2094
  const batch = workload.batch;
2089
2095
  if (!batch) return `${workload.ready}/${workload.desired} ready`;
2090
2096
  if ((batch.activeRuns ?? 0) > 0)
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { AreaChart } from './AreaChart'
4
4
  import { layoutAnnotations, ANNOTATION_LABEL_ROWS } from './annotations'
5
- import { formatTimestamp } from './format'
5
+ import { alignedTimeAxisTicks, formatTimestamp } from './format'
6
6
  import type { ChartAnnotation, TimeSeries } from './types'
7
7
 
8
8
  const t0 = 1_788_679_000
@@ -82,6 +82,29 @@ describe('AreaChart domain and axes', () => {
82
82
  expect(Math.max(...xs)).toBeCloseTo(960, 0)
83
83
  })
84
84
 
85
+ it('labels a multi-day X axis on local midnights', () => {
86
+ const week = 7 * 86_400
87
+ const html = render({ series: [series([[0, 1], [week, 2]])], domain: { start: t0, end: t0 + week } })
88
+ const ticks = alignedTimeAxisTicks(t0, t0 + week, 7)!
89
+ expect(ticks.length).toBeGreaterThanOrEqual(2)
90
+ for (const { label } of ticks) expect(html).toContain(`>${label}<`)
91
+ expect(html).not.toContain(`>${formatTimestamp(t0)}<`)
92
+ })
93
+
94
+ it('keeps every X label inside the viewBox on a multi-day axis', () => {
95
+ for (const days of [1.5, 2, 3, 7, 11, 270]) {
96
+ const span = days * 86_400
97
+ const html = render({ series: [series([[0, 1], [span, 2]])], domain: { start: t0, end: t0 + span } })
98
+ const xLabels = [...html.matchAll(/<text x="([^"]+)" y="296" text-anchor="middle"[^>]*>([^<]*)<\/text>/g)]
99
+ expect(xLabels.length).toBeGreaterThanOrEqual(2)
100
+ for (const [, x, label] of xLabels) {
101
+ const half = (label.length * 11 * 0.6) / 2
102
+ expect(Number(x) - half).toBeGreaterThanOrEqual(0)
103
+ expect(Number(x) + half).toBeLessThanOrEqual(1000)
104
+ }
105
+ }
106
+ })
107
+
85
108
  it('extends the Y axis below zero and draws a zero line for negative values', () => {
86
109
  const html = render({ series: [series([[0, -4], [600, 2], [1200, -1]])] })
87
110
  expect(html).toContain('data-chart-zero-line')
@@ -196,3 +219,18 @@ describe('AreaChart gaps and selection', () => {
196
219
  expect(html).toContain('cursor:crosshair')
197
220
  })
198
221
  })
222
+
223
+ describe('AreaChart dashboard height', () => {
224
+ const data = [series([[0, 1], [600, 2]])]
225
+
226
+ it('renders the dashboard layout at the height asked for, and 240 by default', () => {
227
+ const short = render({ series: data, layout: 'dashboard', height: 140 })
228
+ expect(short).toContain('height:140px')
229
+ expect(attrs(short, 'svg', 'viewBox')[0]).toMatch(/ 140$/)
230
+ expect(render({ series: data, layout: 'dashboard' })).toContain('height:240px')
231
+ })
232
+
233
+ it('ignores the height outside the dashboard layout', () => {
234
+ expect(render({ series: data, layout: 'auto', height: 140 })).not.toContain('height:140px')
235
+ })
236
+ })
@@ -1,9 +1,9 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import type * as React from 'react'
3
3
  import { seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
4
- import { formatMetricValue, formatTimestamp } from './format'
4
+ import { alignedTimeAxisTicks, datesNeedYear, formatFullTimestamp, formatMetricValue, formatTimeAxisLabels, labelWidthEm, type TimeAxisTick } from './format'
5
5
  import { layoutAnnotations } from './annotations'
6
- import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
6
+ import { chartLayout, integerAxisTop, isCountUnit, xLabelMaxWidthEm, yAxisValues } from './axis'
7
7
  import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
8
8
  import { nearestSample } from './nearestSample'
9
9
 
@@ -19,7 +19,7 @@ export interface ChartTimeRange {
19
19
  end: number
20
20
  }
21
21
 
22
- export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full', shadedRanges, selection, onSelectRange }: {
22
+ export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full', height: dashboardHeight = 240, shadedRanges, selection, onSelectRange }: {
23
23
  series: TimeSeries[]
24
24
  color: string
25
25
  fillColor: string
@@ -45,6 +45,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
45
45
  * keeps text and plot height stable as its container resizes.
46
46
  */
47
47
  layout?: 'full' | 'auto' | 'compact' | 'dashboard'
48
+ /** Rendered height in px of the 'dashboard' layout; ignored by the others. */
49
+ height?: number
48
50
  /** Ranges drawn hatched, e.g. where no sample exists; `label` says why and shows on hover. */
49
51
  shadedRanges?: (ChartTimeRange & { label?: string })[]
50
52
  /** A selected range, drawn as a band. */
@@ -116,7 +118,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
116
118
  // Layout constants. marginLeft sized for the widest expected Y-tick label
117
119
  // ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
118
120
  // need extra viewBox-space to survive the down-scale.
119
- const base = chartLayout(compact, layout === 'dashboard', renderedWidth)
121
+ const measured = chartLayout(compact, layout === 'dashboard', renderedWidth)
122
+ const base = layout === 'dashboard' ? { ...measured, height: dashboardHeight } : measured
120
123
  const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
121
124
  const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
122
125
  const yLabels = yValues.map(val => formatMetricValue(val, unit))
@@ -149,14 +152,30 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
149
152
  const xTicks = useMemo(() => {
150
153
  if (!chartData) return []
151
154
  const { minTs, maxTs } = chartData
152
- return Array.from({ length: xIntervals + 1 }, (_, i) => {
153
- const ts = minTs + ((maxTs - minTs) / xIntervals) * i
154
- // In the compact layout the two labels sit at the plot edges and would
155
- // otherwise spill past the viewBox.
156
- const anchor: 'start' | 'middle' | 'end' = !(compact || layout === 'dashboard') ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
157
- return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
155
+ // A window longer than a day gets ticks on local midnights (and clock
156
+ // hours between) when at least two fit, positioned where they fall; the
157
+ // centred labels are nudged inside the viewBox so an end one isn't clipped.
158
+ const place = (ticks: TimeAxisTick[]) => ticks.map(({ ts, label }) => {
159
+ const halfWidth = (labelWidthEm(label) * fontSize) / 2
160
+ return { ts, x: Math.min(Math.max(toX(ts), halfWidth), width - halfWidth), halfWidth, label, anchor: 'middle' as const }
158
161
  })
159
- }, [chartData, xIntervals, compact, layout, marginLeft, plotWidth])
162
+ const separated = (ticks: TimeAxisTick[]) => place(ticks).every((tick, i, all) =>
163
+ i === 0 || tick.x - tick.halfWidth - (all[i - 1].x + all[i - 1].halfWidth) >= fontSize)
164
+ const aligned = xIntervals >= 2 ? alignedTimeAxisTicks(minTs, maxTs, xIntervals + 1, separated) : null
165
+ if (aligned) return place(aligned)
166
+ // In the compact layout the two labels sit at the plot edges and would
167
+ // otherwise spill past the viewBox.
168
+ const edgeAnchored = compact || layout === 'dashboard'
169
+ const timestamps = Array.from({ length: xIntervals + 1 }, (_, i) => minTs + ((maxTs - minTs) / xIntervals) * i)
170
+ const labels = formatTimeAxisLabels(timestamps, xLabelMaxWidthEm(plotWidth, xIntervals, fontSize, edgeAnchored))
171
+ const multiDay = maxTs - minTs > 24 * 60 * 60
172
+ return timestamps.map((ts, i) => {
173
+ const anchor: 'start' | 'middle' | 'end' = !edgeAnchored ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
174
+ const halfWidth = (labelWidthEm(labels[i]) * fontSize) / 2
175
+ const x = multiDay && !edgeAnchored ? Math.min(Math.max(toX(ts), halfWidth), width - halfWidth) : toX(ts)
176
+ return { ts, x, label: labels[i], anchor }
177
+ })
178
+ }, [chartData, xIntervals, compact, layout, marginLeft, plotWidth, fontSize, width])
160
179
 
161
180
  const paths = useMemo(() => {
162
181
  if (!chartData) return []
@@ -368,7 +387,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
368
387
  ref={svgRef}
369
388
  viewBox={`0 0 ${width} ${height}`}
370
389
  className="w-full h-full"
371
- style={layout === 'dashboard' ? { height: 240 } : undefined}
390
+ style={layout === 'dashboard' ? { height: dashboardHeight } : undefined}
372
391
  preserveAspectRatio="xMidYMid meet"
373
392
  data-chart-layout={compact ? 'compact' : 'full'}
374
393
  >
@@ -653,7 +672,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
653
672
  >
654
673
  <div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
655
674
  <div className="text-theme-text-tertiary mb-1.5 font-mono">
656
- {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
675
+ {formatFullTimestamp(hoverData.ts, datesNeedYear(chartData.maxTs - chartData.minTs))}
657
676
  </div>
658
677
  {hoverData.nearbyAnnotations.map((placed, i) => (
659
678
  <div key={`annotation-${i}`} className="flex items-center gap-2 py-0.5">
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { chartLayout, integerAxisTop, yAxisValues } from './axis'
2
+ import { chartLayout, integerAxisTop, xLabelMaxWidthEm, yAxisValues } from './axis'
3
3
 
4
4
  describe('integer axes', () => {
5
5
  it('ends a count axis on a whole step that reaches the maximum', () => {
@@ -46,3 +46,19 @@ describe('integer axes', () => {
46
46
  expect(chartLayout(false, false, 500)).toEqual(chartLayout(false))
47
47
  })
48
48
  })
49
+
50
+ describe('xLabelMaxWidthEm', () => {
51
+ // "Oct 21 02:48 PM" is 15 characters, 9em.
52
+ const dateAndTime = 9
53
+ const room = (layout: ReturnType<typeof chartLayout>, edgeAnchored: boolean) =>
54
+ xLabelMaxWidthEm(layout.width - layout.marginLeft - layout.marginRight, layout.xIntervals, layout.fontSize, edgeAnchored)
55
+
56
+ it('fits a date-and-time label on the full layout and the compact edges', () => {
57
+ expect(room(chartLayout(false), false)).toBeGreaterThanOrEqual(dateAndTime)
58
+ expect(room(chartLayout(true), true)).toBeGreaterThanOrEqual(dateAndTime)
59
+ })
60
+
61
+ it('leaves too little room for a date and time with three ticks at 400px', () => {
62
+ expect(room(chartLayout(false, true, 400), true)).toBeLessThan(dateAndTime)
63
+ })
64
+ })
@@ -26,6 +26,19 @@ export function chartLayout(compact: boolean, dashboard = false, renderedWidth =
26
26
  : { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
27
27
  }
28
28
 
29
+ /**
30
+ * Widest X label, in ems, that leaves a gap of about two characters between
31
+ * neighbours. A centred label shares the spacing with half of each neighbour;
32
+ * an edge-anchored end label sits wholly on its side, so with two ticks both
33
+ * labels share the plot and with more the end label and half its neighbour
34
+ * share one spacing.
35
+ */
36
+ export function xLabelMaxWidthEm(plotWidth: number, intervals: number, fontSize: number, edgeAnchored: boolean): number {
37
+ const spacing = plotWidth / Math.max(1, intervals) / fontSize
38
+ const share = !edgeAnchored ? 1 : intervals <= 1 ? 2 : 1.5
39
+ return (spacing - 1.2) / share
40
+ }
41
+
29
42
  export function isCountUnit(unit: string): boolean {
30
43
  return unit === 'count' || unit === 'restarts'
31
44
  }
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { formatMetricValue } from './format'
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
2
+ import { alignedTimeAxisTicks, datesNeedYear, formatFullTimestamp, formatMetricValue, formatTimeAxisLabels, formatTimestamp, labelWidthEm } from './format'
3
3
 
4
4
  describe('formatMetricValue', () => {
5
5
  it('scales unitless values with SI prefixes instead of a runaway k', () => {
@@ -17,3 +17,160 @@ describe('formatMetricValue', () => {
17
17
  expect(formatMetricValue(-2048, 'bytes')).toBe('-2.0 KiB')
18
18
  })
19
19
  })
20
+
21
+ describe('time axis labels', () => {
22
+ // Day boundaries depend on the local zone; pin one with a DST change. The
23
+ // expected strings come from the same Intl calls, so any locale passes.
24
+ let previousTZ: string | undefined
25
+ beforeAll(() => {
26
+ previousTZ = process.env.TZ
27
+ process.env.TZ = 'America/New_York'
28
+ })
29
+ afterAll(() => {
30
+ if (previousTZ === undefined) delete process.env.TZ
31
+ else process.env.TZ = previousTZ
32
+ })
33
+
34
+ const at = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime() / 1000
35
+ const day = (unix: number) => new Date(unix * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })
36
+ const ticks = (start: number, end: number, intervals: number) =>
37
+ Array.from({ length: intervals + 1 }, (_, i) => start + ((end - start) / intervals) * i)
38
+
39
+ describe('formatTimeAxisLabels (evenly spaced ticks)', () => {
40
+ it('keeps time-only labels for a window of a day or less, even across midnight', () => {
41
+ const sameDay = ticks(at(10, 21, 8), at(10, 21, 14), 6)
42
+ expect(formatTimeAxisLabels(sameDay)).toEqual(sameDay.map(formatTimestamp))
43
+ const overMidnight = ticks(at(10, 21, 20), at(10, 22, 2), 6)
44
+ expect(formatTimeAxisLabels(overMidnight)).toEqual(overMidnight.map(formatTimestamp))
45
+ const fullDay = ticks(at(10, 21, 14, 48), at(10, 22, 14, 48), 6)
46
+ expect(formatTimeAxisLabels(fullDay, 1)).toEqual(fullDay.map(formatTimestamp))
47
+ })
48
+
49
+ it('names the day where a new one starts and the time elsewhere', () => {
50
+ const twoDays = ticks(at(10, 21, 14, 48), at(10, 23, 14, 48), 6)
51
+ const labels = formatTimeAxisLabels(twoDays)
52
+ expect(labels[0]).toBe(`${day(twoDays[0])} ${formatTimestamp(twoDays[0])}`)
53
+ expect(labels[1]).toBe(formatTimestamp(twoDays[1]))
54
+ expect(labels[2]).toBe(`${day(twoDays[2])} ${formatTimestamp(twoDays[2])}`)
55
+ expect(labels.filter((label) => label.includes(day(twoDays[0])))).toHaveLength(1)
56
+ })
57
+
58
+ it('keeps the time on two end ticks just over a day apart', () => {
59
+ const edges = [at(10, 21, 14, 48), at(10, 22, 14, 49)]
60
+ expect(formatTimeAxisLabels(edges)).toEqual(edges.map((ts) => `${day(ts)} ${formatTimestamp(ts)}`))
61
+ })
62
+
63
+ it('falls back to the day at day starts when date and time would not fit', () => {
64
+ const twoDays = ticks(at(10, 21, 14, 48), at(10, 23, 14, 48), 4)
65
+ expect(formatTimeAxisLabels(twoDays, 5)).toEqual([day(twoDays[0]), day(twoDays[1]), '', day(twoDays[3]), ''])
66
+ })
67
+
68
+ it('never repeats a bare day across a DST fall-back', () => {
69
+ // 24h steps from 00:30 land on 23:30 after clocks go back on Nov 1.
70
+ const window = ticks(at(10, 31, 0, 30), at(10, 31, 0, 30) + 3 * 86_400, 3)
71
+ expect(formatTimeAxisLabels(window)).toEqual([
72
+ `${day(window[0])} ${formatTimestamp(window[0])}`,
73
+ `${day(window[1])} ${formatTimestamp(window[1])}`,
74
+ formatTimestamp(window[2]),
75
+ `${day(window[3])} ${formatTimestamp(window[3])}`,
76
+ ])
77
+ expect(day(window[1])).toBe(day(window[2]))
78
+ })
79
+ })
80
+
81
+ describe('alignedTimeAxisTicks', () => {
82
+ const local = (ts: number) => {
83
+ const d = new Date(ts * 1000)
84
+ return [d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()]
85
+ }
86
+
87
+ it('leaves a window of a day or less to evenly spaced ticks', () => {
88
+ expect(alignedTimeAxisTicks(at(10, 21, 14, 48), at(10, 22, 14, 48), 7)).toBeNull()
89
+ })
90
+
91
+ it('puts a 7-day window on consecutive local midnights, labelled with the day', () => {
92
+ const result = alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7)!
93
+ expect(result.map(({ ts }) => local(ts))).toEqual([22, 23, 24, 25, 26, 27, 28].map((d) => [10, d, 0, 0]))
94
+ expect(result.map(({ label }) => label)).toEqual(result.map(({ ts }) => day(ts)))
95
+ })
96
+
97
+ it('steps by whole days when daily ticks would not fit, and keeps them as the window slides', () => {
98
+ const eleven = alignedTimeAxisTicks(at(10, 17, 1, 48), at(10, 28, 1, 48), 7)!
99
+ expect(eleven.length).toBeLessThanOrEqual(7)
100
+ expect(eleven.every(({ ts }) => local(ts)[2] === 0 && local(ts)[3] === 0)).toBe(true)
101
+ const slid = alignedTimeAxisTicks(at(10, 17, 7, 48), at(10, 28, 7, 48), 7)!
102
+ const shared = (list: { ts: number }[]) => list.map(({ ts }) => ts).filter((ts) => ts >= at(10, 17, 7, 48) && ts <= at(10, 28, 1, 48))
103
+ expect(shared(slid)).toEqual(shared(eleven))
104
+ })
105
+
106
+ it('adds clock-hour ticks with time labels on a short multi-day window', () => {
107
+ const result = alignedTimeAxisTicks(at(10, 21, 14, 48), at(10, 23, 2, 48), 7)!
108
+ expect(result.map(({ ts }) => local(ts))).toEqual([
109
+ [10, 21, 18, 0],
110
+ [10, 22, 0, 0],
111
+ [10, 22, 6, 0],
112
+ [10, 22, 12, 0],
113
+ [10, 22, 18, 0],
114
+ [10, 23, 0, 0],
115
+ ])
116
+ expect(result.map(({ label }) => label)).toEqual([
117
+ formatTimestamp(result[0].ts),
118
+ day(result[1].ts),
119
+ formatTimestamp(result[2].ts),
120
+ formatTimestamp(result[3].ts),
121
+ formatTimestamp(result[4].ts),
122
+ day(result[5].ts),
123
+ ])
124
+ })
125
+
126
+ it('keeps local midnights across a DST change', () => {
127
+ const result = alignedTimeAxisTicks(at(10, 30, 12), at(11, 3, 12), 7)!
128
+ expect(result.map(({ ts }) => local(ts))).toEqual([31, 1, 2, 3].map((d, i) => [i === 0 ? 10 : 11, d, 0, 0]))
129
+ expect(result[2].ts - result[1].ts).toBe(25 * 3600)
130
+ })
131
+
132
+ it('keeps later midnights where DST skips one', () => {
133
+ // Chile springs forward at midnight on Sep 6 2026; that day starts at 01:00.
134
+ process.env.TZ = 'America/Santiago'
135
+ try {
136
+ const result = alignedTimeAxisTicks(at(9, 4, 12), at(9, 11, 0, 30), 7)!
137
+ expect(result.map(({ ts }) => local(ts))).toEqual([
138
+ [9, 5, 0, 0], [9, 6, 1, 0], [9, 7, 0, 0], [9, 8, 0, 0], [9, 9, 0, 0], [9, 10, 0, 0], [9, 11, 0, 0],
139
+ ])
140
+ expect(result[1].label).toBe(day(result[1].ts))
141
+ } finally {
142
+ process.env.TZ = 'America/New_York'
143
+ }
144
+ })
145
+
146
+ it('takes a coarser step when the caller says labels collide, and gives up below two ticks', () => {
147
+ const daily = alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7, (ticks) => ticks.length <= 4)!
148
+ expect(daily.length).toBeLessThanOrEqual(4)
149
+ expect(alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7, () => false)).toBeNull()
150
+ expect(alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 22, 12), 1)).toBeNull()
151
+ })
152
+ })
153
+
154
+ it('names the year on axes and tooltips spanning most of a year or more', () => {
155
+ const twoYears = ticks(at(10, 21, 12), at(10, 21, 12) + 730 * 86_400, 6)
156
+ const withYear = (ts: number) => new Date(ts * 1000).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' })
157
+ expect(formatTimeAxisLabels(twoYears, 6)).toEqual(twoYears.map(withYear))
158
+ expect(new Set(formatTimeAxisLabels(twoYears, 6)).size).toBe(twoYears.length)
159
+ expect(datesNeedYear(730 * 86_400)).toBe(true)
160
+ expect(datesNeedYear(30 * 86_400)).toBe(false)
161
+ expect(formatFullTimestamp(twoYears[0], true)).toContain(String(new Date(twoYears[0] * 1000).toLocaleDateString([], { year: 'numeric' })))
162
+ expect(formatTimeAxisLabels(ticks(at(10, 21, 2, 48), at(10, 28, 2, 48), 6), 6).some((label) => label.includes('2026'))).toBe(false)
163
+ })
164
+
165
+ it('estimates wide characters at a full em', () => {
166
+ expect(labelWidthEm('Oct 21')).toBeCloseTo(3.6)
167
+ expect(labelWidthEm('10月21日')).toBeCloseTo(4 * 0.6 + 2 * 1)
168
+ })
169
+
170
+ it('formats tooltips with the full date and time', () => {
171
+ const ts = at(10, 21, 14, 5)
172
+ const label = formatFullTimestamp(ts)
173
+ expect(label).toContain(day(ts))
174
+ expect(label).toContain(new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
175
+ })
176
+ })
@@ -59,3 +59,121 @@ export function formatTimestamp(unix: number): string {
59
59
  const d = new Date(unix * 1000)
60
60
  return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
61
61
  }
62
+
63
+ const DAY_SECONDS = 24 * 60 * 60
64
+
65
+ // Past this span two instants can share a month and day, so dates name the year.
66
+ const YEAR_SPAN_SECONDS = 300 * DAY_SECONDS
67
+
68
+ /** Whether dates on a chart spanning this many seconds should name the year. */
69
+ export function datesNeedYear(spanSeconds: number): boolean {
70
+ return spanSeconds > YEAR_SPAN_SECONDS
71
+ }
72
+
73
+ function formatDay(unix: number, withYear = false): string {
74
+ return new Date(unix * 1000).toLocaleDateString([], { ...(withYear && { year: 'numeric' }), month: 'short', day: 'numeric' })
75
+ }
76
+
77
+ /** Full local date and time of one instant, for tooltips; `withYear` for charts spanning most of a year or more. */
78
+ export function formatFullTimestamp(unix: number, withYear = false): string {
79
+ return new Date(unix * 1000).toLocaleString([], {
80
+ ...(withYear && { year: 'numeric' }),
81
+ month: 'short',
82
+ day: 'numeric',
83
+ hour: '2-digit',
84
+ minute: '2-digit',
85
+ second: '2-digit',
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Labels for evenly spaced time-axis ticks given in ascending unix seconds.
91
+ * A span of a day or less shows the time only (`formatTimestamp`). A longer
92
+ * span names the day (and the year past about ten months): a tick that starts a new local day
93
+ * reads "Oct 21 02:00 PM" and the rest show the time; when that is wider than
94
+ * `maxWidth` (in ems, see `labelWidthEm`), ticks that start a day show the day
95
+ * alone and the rest stay blank, so neighbouring labels never collide.
96
+ */
97
+ export function formatTimeAxisLabels(ticks: number[], maxWidth = Infinity): string[] {
98
+ if (ticks.length === 0) return []
99
+ const span = ticks[ticks.length - 1] - ticks[0]
100
+ if (span <= DAY_SECONDS) return ticks.map(formatTimestamp)
101
+
102
+ const withYear = datesNeedYear(span)
103
+ const days = ticks.map((ts) => formatDay(ts, withYear))
104
+ const startsDay = days.map((day, i) => i === 0 || day !== days[i - 1])
105
+ const mixed = ticks.map((ts, i) => (startsDay[i] ? `${days[i]} ${formatTimestamp(ts)}` : formatTimestamp(ts)))
106
+ if (Math.max(...mixed.map(labelWidthEm)) <= maxWidth) return mixed
107
+ return days.map((day, i) => (startsDay[i] ? day : ''))
108
+ }
109
+
110
+ /**
111
+ * Estimated rendered width of a monospace label in ems: about 0.6em per
112
+ * character, a full em for wide (CJK, fullwidth) characters.
113
+ */
114
+ export function labelWidthEm(label: string): number {
115
+ let width = 0
116
+ for (const ch of label) width += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 1 : 0.6
117
+ return width
118
+ }
119
+
120
+ export interface TimeAxisTick {
121
+ ts: number
122
+ label: string
123
+ }
124
+
125
+ const HOUR_SECONDS = 60 * 60
126
+ // Sub-day steps align to local clock hours; day steps to local midnights.
127
+ const ALIGNED_STEPS_HOURS = [6, 12, 24, 48, 72, 168, 336, 672]
128
+
129
+ /**
130
+ * Calendar-aligned ticks for a window longer than a day: local midnights
131
+ * labelled with the day ("Oct 21"), and, when the window is short enough for
132
+ * 6- or 12-hour steps, the hours between labelled with the time. Uses the
133
+ * smallest step that keeps the tick count within `maxTicks` and passes `fits`
134
+ * (the caller's check that neighbouring labels don't collide). Returns null
135
+ * for a window of a day or less, or when fewer than two ticks would fit, so
136
+ * the caller keeps evenly spaced ticks.
137
+ */
138
+ export function alignedTimeAxisTicks(
139
+ minTs: number,
140
+ maxTs: number,
141
+ maxTicks: number,
142
+ fits: (ticks: TimeAxisTick[]) => boolean = () => true,
143
+ ): TimeAxisTick[] | null {
144
+ if (maxTs - minTs <= DAY_SECONDS || maxTicks < 2) return null
145
+ for (const stepHours of ALIGNED_STEPS_HOURS) {
146
+ if ((maxTs - minTs) / HOUR_SECONDS / stepHours > maxTicks + 1) continue
147
+ const ticks = alignedTicks(minTs, maxTs, stepHours)
148
+ if (ticks.length > maxTicks) continue
149
+ if (ticks.length < 2) return null
150
+ const labelled = ticks.map(({ ts, startsDay }) => ({ ts, label: startsDay ? formatDay(ts) : formatTimestamp(ts) }))
151
+ if (fits(labelled)) return labelled
152
+ }
153
+ return null
154
+ }
155
+
156
+ function alignedTicks(minTs: number, maxTs: number, stepHours: number): { ts: number; startsDay: boolean }[] {
157
+ const out: { ts: number; startsDay: boolean }[] = []
158
+ const start = new Date(minTs * 1000)
159
+ const stepDays = stepHours / 24
160
+ // Each day's start is built from its date: where a DST change skips
161
+ // midnight the Date lands on 01:00, and stepping with setDate would carry
162
+ // that hour into every later day.
163
+ for (let offset = 0; ; offset++) {
164
+ const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate() + offset)
165
+ if (cursor.getTime() / 1000 > maxTs) break
166
+ if (stepHours < 24) {
167
+ for (let hour = 0; hour < 24; hour += stepHours) {
168
+ const ts = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), hour).getTime() / 1000
169
+ if (ts >= minTs && ts <= maxTs) out.push({ ts, startsDay: hour === 0 })
170
+ }
171
+ } else {
172
+ // Index days by calendar date so the chosen midnights stay put as the window slides.
173
+ const dayIndex = Math.round(Date.UTC(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()) / (DAY_SECONDS * 1000))
174
+ const ts = cursor.getTime() / 1000
175
+ if (dayIndex % stepDays === 0 && ts >= minTs) out.push({ ts, startsDay: true })
176
+ }
177
+ }
178
+ return out
179
+ }
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { ClusterSwitcher, type ClusterSwitcherHandle, type ClusterSwitcherItem } from './ClusterSwitcher'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+ let root: Root
9
+ let host: HTMLDivElement
10
+
11
+ const items: ClusterSwitcherItem[] = [
12
+ { id: 'prod', name: 'prod' },
13
+ { id: 'staging', name: 'staging' },
14
+ ]
15
+
16
+ beforeEach(() => {
17
+ host = document.createElement('div')
18
+ document.body.appendChild(host)
19
+ root = createRoot(host)
20
+ })
21
+ afterEach(() => {
22
+ act(() => root.unmount())
23
+ host.remove()
24
+ })
25
+
26
+ function open(props: { currentSelectable?: boolean }, onSelect: (item: ClusterSwitcherItem) => void) {
27
+ const ref = { current: null as ClusterSwitcherHandle | null }
28
+ act(() => root.render(
29
+ <ClusterSwitcher ref={ref} currentId="prod" currentName="prod" items={items} onSelect={onSelect} searchable={false} {...props} />,
30
+ ))
31
+ act(() => ref.current?.open())
32
+ }
33
+
34
+ function row(name: string) {
35
+ return [...host.querySelectorAll<HTMLButtonElement>('button[data-highlighted]')].find(b => b.textContent?.includes(name))!
36
+ }
37
+
38
+ describe('ClusterSwitcher current row', () => {
39
+ it('is inert by default', () => {
40
+ const onSelect = vi.fn()
41
+ open({}, onSelect)
42
+ expect(row('prod').disabled).toBe(true)
43
+ act(() => row('prod').click())
44
+ expect(onSelect).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('can be picked when the host asks for it', () => {
48
+ const onSelect = vi.fn()
49
+ open({ currentSelectable: true }, onSelect)
50
+ expect(row('prod').disabled).toBe(false)
51
+ act(() => row('prod').click())
52
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'prod' }))
53
+ })
54
+ })
@@ -74,6 +74,9 @@ export interface ClusterSwitcherProps {
74
74
  variant?: 'chip' | 'segment'
75
75
  /** Muted label shown before the value in the 'segment' variant (e.g. "Cluster"). */
76
76
  label?: string
77
+ /** Lets the current row be picked, for hosts that show a screen about
78
+ * another cluster on top of the current one (picking it dismisses that). */
79
+ currentSelectable?: boolean
77
80
  }
78
81
 
79
82
  // Trigger width cap. With middle-truncation kicking in, this is a
@@ -102,6 +105,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
102
105
  align = 'left',
103
106
  variant = 'chip',
104
107
  label,
108
+ currentSelectable = false,
105
109
  }, ref) => {
106
110
  const [isOpen, setIsOpen] = useState(false)
107
111
  const [search, setSearch] = useState('')
@@ -183,7 +187,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
183
187
  }, [isOpen])
184
188
 
185
189
  const select = (item: ClusterSwitcherItem) => {
186
- if (item.disabled || item.id === currentId) return
190
+ if (item.disabled || (item.id === currentId && !currentSelectable)) return
187
191
  setIsOpen(false)
188
192
  if (item.href) {
189
193
  window.location.href = item.href
@@ -341,12 +345,12 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
341
345
  data-highlighted={idx === highlightedIndex}
342
346
  onClick={() => select(item)}
343
347
  onMouseEnter={() => setHighlightedIndex(idx)}
344
- disabled={isCurrent || item.disabled}
348
+ disabled={(isCurrent && !currentSelectable) || item.disabled}
345
349
  title={item.title}
346
350
  className={`
347
351
  w-full flex items-center gap-2 px-3 py-2 text-left transition-colors
348
352
  ${isCurrent
349
- ? 'selection'
353
+ ? `selection${currentSelectable ? ' cursor-pointer' : ''}`
350
354
  : idx === highlightedIndex
351
355
  ? 'bg-theme-hover cursor-pointer'
352
356
  : 'hover:bg-theme-hover cursor-pointer'}