@skyhook-io/k8s-ui 1.18.1 → 1.19.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 (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
package/README.md CHANGED
@@ -83,3 +83,11 @@ tooltip. `certaintyGlyph` and `certaintyValueLabel` expose the same vocabulary.
83
83
  `ActionConfirmDialog` adds context, reviewed writes and optional typed acknowledgement to the simpler `ConfirmDialog`. Hosts own loading, permission checks, stale-fact validation and result classification. Its `result` binds the state (`error`, `unknown` or `partial`), message and optional heading; unknown and partial results lock another submit. Retain that result outside the dialog across dismissal and remount, and clear it only after explicitly reviewing current state. The component does not infer outcomes from HTTP status. Remount it when changing the reviewed target or action, so acknowledgement and disclosure state start fresh. The default error heading makes no claim about whether a write occurred; use a stronger result title only when the producer proves it.
84
84
 
85
85
  Existing CNPG primitive exports remain available and delegate to these shared implementations.
86
+
87
+ `PrinterColumnDef.jsonPath` optionally carries source provenance from newer Radar servers. The UI does not evaluate it: values still come from the server's table converter. Without it, printer-cell tones stay generic. Crossplane pause qualification only recognizes exact Ready/Synced condition paths and corroborated annotation/condition evidence; same-named unrelated columns retain their normal tones. `ConditionTone` also supports opt-in `neutral`; `defaultConditionTone` retains its original return vocabulary. `ComposedRefStatus.pauseState` optionally supplies recorded Crossplane pause context for a composed-resource row.
88
+
89
+ `ForceDeleteConfirmDialog.resourceData` optionally supplies the current object for Crossplane pause context. Paused MR/XR/Claim shapes holding a known Crossplane finalizer receive a cleanup warning; the prop does not change confirmation or force-delete behavior. YAML operation context uses the existing baseline and predicted manifests; removing a pause annotation requests resumption rather than proving it.
90
+
91
+ `YamlReview.mode` distinguishes Apply, strict Create and Update (default: `apply`). Hosts using strict creation should pass `create` so an unpreviewable new document does not imply unknown live pause state. Crossplane operation context reports what the server preview actually keeps or clears; unsupported dry-run documents retain conditional uncertainty rather than treating submitted YAML as a live baseline.
92
+
93
+ For a single editor target, `YamlReview.resourceData` may retain its raw condition evidence when the review YAML intentionally omits status. `EditableYamlView` binds that snapshot to its preview. It is not used to classify unrelated documents in a batch. Generic Apply with neither a known Crossplane shape nor raw acknowledged pause does not infer a resource’s controller from the annotation alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -46,6 +46,7 @@ import {
46
46
  import { ReadyBar } from './ReadyBar'
47
47
  import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
48
48
  import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
49
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
49
50
 
50
51
  function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
51
52
  if (workloadClass !== 'job') {
@@ -337,6 +338,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
337
338
  // showSystem lives in the Filters rail, so Clear resets it too (and its
338
339
  // non-default ON state counts as an active filter that surfaces the button).
339
340
  const anyFilterActive = filters.isActive
341
+ const activeFilterCount = fHealth.size + fClass.size + fType.size + fEnv.size + fSource.size + (systemCount > 0 && showSystem ? 1 : 0)
340
342
  const clearAllFilters = () => {
341
343
  filters.clearAll()
342
344
  }
@@ -355,7 +357,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
355
357
  actions={
356
358
  <>
357
359
  {headerActions}
358
- <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
360
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} phoneLabel={total === 1 ? 'app' : 'apps'} value={total} loading={initialLoading} />
359
361
  {healthTile('unhealthy', 'error')}
360
362
  {healthTile('degraded', 'warning')}
361
363
  {healthTile('healthy', 'success')}
@@ -381,13 +383,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
381
383
  Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
382
384
  the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
383
385
  <aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
384
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
385
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
386
- {anyFilterActive && (
386
+ <FilterRailPanel
387
+ activeCount={activeFilterCount}
388
+ clear={anyFilterActive && (
387
389
  <button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
388
390
  )}
389
- </div>
390
- <div className="flex-1 overflow-y-auto">
391
+ >
391
392
  {initialLoading ? (
392
393
  <BoardRailSkeleton
393
394
  sections={[
@@ -414,7 +415,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
414
415
  )}
415
416
  </>
416
417
  )}
417
- </div>
418
+ </FilterRailPanel>
418
419
  </aside>
419
420
 
420
421
  {/* Content: toolbar (search + sort) over the scrollable table. */}
@@ -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
+ }
@@ -12,6 +12,7 @@ import { ClusterName } from '../ui/ClusterName'
12
12
  import { Input } from '../ui/Input'
13
13
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
14
14
  import { StatusDot, type StatusTone } from '../ui/status-tone'
15
+ import { useKeepInViewport } from '../../hooks/useKeepInViewport'
15
16
 
16
17
  export interface ClusterSwitcherItem {
17
18
  id: string
@@ -107,6 +108,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
107
108
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
108
109
  const rootRef = useRef<HTMLDivElement>(null)
109
110
  const searchInputRef = useRef<HTMLInputElement>(null)
111
+ const menuRef = useRef<HTMLDivElement>(null)
112
+ useKeepInViewport(menuRef, isOpen)
110
113
 
111
114
  useImperativeHandle(ref, () => ({
112
115
  open: () => {
@@ -284,6 +287,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
284
287
 
285
288
  {isOpen && (
286
289
  <div
290
+ ref={menuRef}
287
291
  className={`absolute top-full ${positionClass} mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden`}
288
292
  >
289
293
  {searchable && (
@@ -5,6 +5,7 @@ import { YamlDiffEditor } from '../ui/YamlEditor'
5
5
  import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { pluralToKind } from '../../utils/navigation'
8
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
8
9
  import { toComparableYaml } from './normalize'
9
10
  import { SIDE_TONES, type CompareSide } from './types'
10
11
 
@@ -120,7 +121,10 @@ export function ResourceCompareView({
120
121
  bleed = false,
121
122
  }: ResourceCompareViewProps) {
122
123
  const [specOnly, setSpecOnly] = useState(false)
123
- const [unified, setUnified] = useState(false)
124
+ const [unifiedPicked, setUnified] = useState(false)
125
+ // Two side-by-side panes don't fit a phone, so phones always get one column.
126
+ const isPhone = usePhoneLayout()
127
+ const unified = unifiedPicked || isPhone
124
128
  const [hideUnchanged, setHideUnchanged] = useState(true)
125
129
  const [rawMetadata, setRawMetadata] = useState(false)
126
130
 
@@ -180,7 +184,7 @@ export function ResourceCompareView({
180
184
  <ToggleButton active={rawMetadata} onClick={() => setRawMetadata(v => !v)} icon={<Sparkles className="w-3.5 h-3.5" />} label="Raw metadata" tooltip="Keep server-assigned noise (uid, resourceVersion, managedFields, last-applied)" />
181
185
  <ToggleButton active={specOnly} onClick={() => setSpecOnly(v => !v)} icon={<FileCode2 className="w-3.5 h-3.5" />} label="Spec only" tooltip="Drop status fields from both sides" />
182
186
  <ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged(v => !v)} icon={<FileText className="w-3.5 h-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
183
- <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
187
+ {!isPhone && <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />}
184
188
  </div>
185
189
 
186
190
  <div className="w-px h-5 bg-theme-border shrink-0" aria-hidden />