@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +17 -5
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +2 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +54 -1
- package/src/types/gitops-insights.ts +8 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
@@ -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
|
-
<
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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,
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
return { ts, x: toX(ts),
|
|
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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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 [
|
|
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 />
|