@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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- 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.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +41 -0
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- 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.
|
|
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.
|
|
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,
|
|
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
|
+
}
|
|
@@ -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
|
-
?
|
|
353
|
+
? `selection${currentSelectable ? ' cursor-pointer' : ''}`
|
|
350
354
|
: idx === highlightedIndex
|
|
351
355
|
? 'bg-theme-hover cursor-pointer'
|
|
352
356
|
: 'hover:bg-theme-hover cursor-pointer'}
|