@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import type { TimelineEvent } from '../../types'
|
|
4
|
+
import { TimelineList } from './TimelineList'
|
|
5
|
+
import { TimelineSwimlanes } from './TimelineSwimlanes'
|
|
6
|
+
|
|
7
|
+
const NOW = 1_700_000_000_000
|
|
8
|
+
const EVENTS: TimelineEvent[] = [
|
|
9
|
+
{
|
|
10
|
+
id: 'a', timestamp: new Date(NOW).toISOString(), source: 'informer',
|
|
11
|
+
kind: 'Deployment', namespace: 'default', name: 'web', eventType: 'add',
|
|
12
|
+
},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// Both pure views expose the same controlled-with-internal-fallback contract for
|
|
16
|
+
// the lifted filters, so a host can share one set of state across the toggle
|
|
17
|
+
// while standalone hosts (passing nothing) keep working on internal state.
|
|
18
|
+
describe('TimelineList controlled vs fallback', () => {
|
|
19
|
+
it('reflects the controlled search value', () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
<TimelineList events={EVENTS} isLoading={false} search="ctrl-list" onSearchChange={() => {}} />,
|
|
22
|
+
)
|
|
23
|
+
expect(html).toContain('value="ctrl-list"')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to internal search state when uncontrolled (always-open empty input)', () => {
|
|
27
|
+
const html = renderToString(<TimelineList events={EVENTS} isLoading={false} />)
|
|
28
|
+
// Always-open static search: empty input present, no collapsed magnifier.
|
|
29
|
+
expect(html).toContain('value=""')
|
|
30
|
+
expect(html).not.toContain('aria-label="Search"')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders the shared toolbar chips (unified with the swimlane)', () => {
|
|
34
|
+
const html = renderToString(<TimelineList events={EVENTS} isLoading={false} />)
|
|
35
|
+
expect(html).toContain('K8s Events')
|
|
36
|
+
expect(html).toContain('Problems')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('TimelineSwimlanes controlled vs fallback', () => {
|
|
41
|
+
it('reflects the controlled search value', () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
<TimelineSwimlanes events={EVENTS} search="ctrl-swim" onSearchChange={() => {}} />,
|
|
44
|
+
)
|
|
45
|
+
expect(html).toContain('value="ctrl-swim"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('falls back to internal search state when uncontrolled (always-open empty input)', () => {
|
|
49
|
+
const html = renderToString(<TimelineSwimlanes events={EVENTS} />)
|
|
50
|
+
expect(html).toContain('value=""')
|
|
51
|
+
expect(html).not.toContain('aria-label="Search"')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders the same shared toolbar chips as the list', () => {
|
|
55
|
+
const html = renderToString(<TimelineSwimlanes events={EVENTS} />)
|
|
56
|
+
expect(html).toContain('K8s Events')
|
|
57
|
+
expect(html).toContain('Problems')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders the single View trigger (Sort + Group live in its popover)', () => {
|
|
61
|
+
const html = renderToString(<TimelineSwimlanes events={EVENTS} />)
|
|
62
|
+
expect(html).toContain('>View<')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
export * from './shared'
|
|
2
2
|
export * from './DiffViewer'
|
|
3
|
+
export type { ActivityTypeFilter, ActivityFilterKey } from './timeline-filters'
|
|
4
|
+
export type { TimelineSort } from './timeline-lane-sort'
|
|
3
5
|
export * from './TimelineList'
|
|
4
6
|
export * from './TimelineSwimlanes'
|
|
7
|
+
export {
|
|
8
|
+
clampSelection,
|
|
9
|
+
clampLensToSelection,
|
|
10
|
+
countEventsAfter,
|
|
11
|
+
mergeGapRanges,
|
|
12
|
+
pickDisplayBucketSizeMs,
|
|
13
|
+
presetToSelection,
|
|
14
|
+
type ScrubberBucket,
|
|
15
|
+
type ScrubberPreset,
|
|
16
|
+
type ScrubberRange,
|
|
17
|
+
} from './scrubber-math'
|
|
18
|
+
export { TimelineStrip, type TimelineStripProps } from './TimelineStrip'
|
|
19
|
+
export {
|
|
20
|
+
advanceLatchedLens,
|
|
21
|
+
deriveLiveSelection,
|
|
22
|
+
isLensLatched,
|
|
23
|
+
quantizeBaseWindow,
|
|
24
|
+
LIVE_TICK_MS,
|
|
25
|
+
BASE_QUANTIZE_STEP_MS,
|
|
26
|
+
type TimelineLiveState,
|
|
27
|
+
} from './timeline-live'
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
barHeight,
|
|
4
|
+
clampSelection,
|
|
5
|
+
clampLensToSelection,
|
|
6
|
+
countEventsAfter,
|
|
7
|
+
formatLensDuration,
|
|
8
|
+
mergeGapRanges,
|
|
9
|
+
pickDisplayBucketSizeMs,
|
|
10
|
+
presetToSelection,
|
|
11
|
+
type ScrubberRange,
|
|
12
|
+
type ScrubberBucket,
|
|
13
|
+
} from './scrubber-math'
|
|
14
|
+
|
|
15
|
+
const MIN = 60_000
|
|
16
|
+
const HOUR = 60 * 60 * 1000
|
|
17
|
+
const DAY = 24 * HOUR
|
|
18
|
+
const NOW = 1_700_000_000_000
|
|
19
|
+
const DOMAIN: ScrubberRange = { fromMs: NOW - 30 * DAY, toMs: NOW }
|
|
20
|
+
|
|
21
|
+
describe('barHeight (bucket → bar mapping)', () => {
|
|
22
|
+
it('is zero for empty buckets or empty data', () => {
|
|
23
|
+
expect(barHeight(0, 100, 44)).toBe(0)
|
|
24
|
+
expect(barHeight(5, 0, 44)).toBe(0)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('gives a sparse bar a visible floor instead of a sub-pixel sliver', () => {
|
|
28
|
+
const h = barHeight(1, 10_000, 44)
|
|
29
|
+
expect(h).toBeGreaterThanOrEqual(Math.max(2, 44 * 0.08))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('caps at the track height and grows monotonically with total', () => {
|
|
33
|
+
expect(barHeight(10_000, 10_000, 44)).toBeLessThanOrEqual(44)
|
|
34
|
+
expect(barHeight(100, 10_000, 44)).toBeLessThan(barHeight(2_500, 10_000, 44))
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('clampSelection (domain + maxSelectionMs)', () => {
|
|
39
|
+
it('caps width at maxSelectionMs and flags it, keeping the right edge', () => {
|
|
40
|
+
const sel: ScrubberRange = { fromMs: NOW - 10 * DAY, toMs: NOW }
|
|
41
|
+
const { selection, clampedToMax } = clampSelection(sel, DOMAIN, 7 * DAY, 'end')
|
|
42
|
+
expect(clampedToMax).toBe(true)
|
|
43
|
+
expect(selection.toMs).toBe(NOW)
|
|
44
|
+
expect(selection.toMs - selection.fromMs).toBe(7 * DAY)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shifts a selection that runs past the domain back inside it', () => {
|
|
48
|
+
const sel: ScrubberRange = { fromMs: NOW - HOUR, toMs: NOW + 5 * DAY }
|
|
49
|
+
const { selection } = clampSelection(sel, DOMAIN, 7 * DAY, 'end')
|
|
50
|
+
expect(selection.toMs).toBeLessThanOrEqual(DOMAIN.toMs)
|
|
51
|
+
expect(selection.fromMs).toBeGreaterThanOrEqual(DOMAIN.fromMs)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('enforces a minimum width so handles never overlap', () => {
|
|
55
|
+
const sel: ScrubberRange = { fromMs: NOW - 1000, toMs: NOW }
|
|
56
|
+
const { selection } = clampSelection(sel, DOMAIN, 7 * DAY, 'end')
|
|
57
|
+
expect(selection.toMs - selection.fromMs).toBeGreaterThanOrEqual(MIN)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not flag clampedToMax when the selection already fits', () => {
|
|
61
|
+
const sel: ScrubberRange = { fromMs: NOW - 2 * DAY, toMs: NOW }
|
|
62
|
+
const { clampedToMax } = clampSelection(sel, DOMAIN, 7 * DAY, 'end')
|
|
63
|
+
expect(clampedToMax).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('clampLensToSelection (lens ⊂ selection)', () => {
|
|
68
|
+
const SEL: ScrubberRange = { fromMs: NOW - 4 * HOUR, toMs: NOW }
|
|
69
|
+
|
|
70
|
+
it('leaves a lens already inside the selection untouched', () => {
|
|
71
|
+
const lens: ScrubberRange = { fromMs: NOW - 3 * HOUR, toMs: NOW - 2 * HOUR }
|
|
72
|
+
expect(clampLensToSelection(lens, SEL)).toEqual(lens)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('shifts an overhanging lens back inside, preserving its width', () => {
|
|
76
|
+
const lens: ScrubberRange = { fromMs: NOW - HOUR, toMs: NOW + 2 * HOUR }
|
|
77
|
+
const width = lens.toMs - lens.fromMs
|
|
78
|
+
const clamped = clampLensToSelection(lens, SEL)
|
|
79
|
+
expect(clamped.toMs).toBe(SEL.toMs)
|
|
80
|
+
expect(clamped.toMs - clamped.fromMs).toBe(width)
|
|
81
|
+
expect(clamped.fromMs).toBeGreaterThanOrEqual(SEL.fromMs)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shifts a lens hanging off the left edge back inside', () => {
|
|
85
|
+
const lens: ScrubberRange = { fromMs: NOW - 6 * HOUR, toMs: NOW - 5 * HOUR }
|
|
86
|
+
const width = lens.toMs - lens.fromMs
|
|
87
|
+
const clamped = clampLensToSelection(lens, SEL)
|
|
88
|
+
expect(clamped.fromMs).toBe(SEL.fromMs)
|
|
89
|
+
expect(clamped.toMs - clamped.fromMs).toBe(width)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('collapses a lens wider than the selection to the full selection', () => {
|
|
93
|
+
const lens: ScrubberRange = { fromMs: NOW - 10 * HOUR, toMs: NOW + 5 * HOUR }
|
|
94
|
+
expect(clampLensToSelection(lens, SEL)).toEqual({ fromMs: SEL.fromMs, toMs: SEL.toMs })
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('formatLensDuration (chip label)', () => {
|
|
99
|
+
it('renders sub-hour widths as minutes', () => {
|
|
100
|
+
expect(formatLensDuration(15 * 60_000)).toBe('15m')
|
|
101
|
+
expect(formatLensDuration(30 * 60_000)).toBe('30m')
|
|
102
|
+
})
|
|
103
|
+
it('renders hour widths as hours, days as days', () => {
|
|
104
|
+
expect(formatLensDuration(HOUR)).toBe('1h')
|
|
105
|
+
expect(formatLensDuration(8 * HOUR)).toBe('8h')
|
|
106
|
+
expect(formatLensDuration(DAY)).toBe('1d')
|
|
107
|
+
expect(formatLensDuration(7 * DAY)).toBe('7d')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('presetToSelection', () => {
|
|
112
|
+
it('sets the brush to [now - ms, now]', () => {
|
|
113
|
+
const { selection } = presetToSelection(DAY, NOW, DOMAIN, 7 * DAY)
|
|
114
|
+
expect(selection.toMs).toBe(NOW)
|
|
115
|
+
expect(selection.toMs - selection.fromMs).toBe(DAY)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('clamps a preset wider than maxSelectionMs (e.g. 30d → 7d cap)', () => {
|
|
119
|
+
const { selection, clampedToMax } = presetToSelection(30 * DAY, NOW, DOMAIN, 7 * DAY)
|
|
120
|
+
expect(clampedToMax).toBe(true)
|
|
121
|
+
expect(selection.toMs - selection.fromMs).toBe(7 * DAY)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('mergeGapRanges (recording-gap dedupe / merge / clamp)', () => {
|
|
126
|
+
it('drops zero-width spans', () => {
|
|
127
|
+
const gaps = mergeGapRanges([
|
|
128
|
+
{ fromMs: 100, toMs: 100 },
|
|
129
|
+
{ fromMs: 500, toMs: 500 },
|
|
130
|
+
])
|
|
131
|
+
expect(gaps).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('dedupes identical spans', () => {
|
|
135
|
+
const gaps = mergeGapRanges([
|
|
136
|
+
{ fromMs: 100, toMs: 200 },
|
|
137
|
+
{ fromMs: 100, toMs: 200 },
|
|
138
|
+
])
|
|
139
|
+
expect(gaps).toEqual([{ fromMs: 100, toMs: 200 }])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('merges overlapping and back-to-back spans into one band', () => {
|
|
143
|
+
const gaps = mergeGapRanges([
|
|
144
|
+
{ fromMs: 100, toMs: 300 },
|
|
145
|
+
{ fromMs: 250, toMs: 400 }, // overlaps
|
|
146
|
+
{ fromMs: 400, toMs: 500 }, // adjacent
|
|
147
|
+
{ fromMs: 700, toMs: 800 }, // disjoint
|
|
148
|
+
])
|
|
149
|
+
expect(gaps).toEqual([
|
|
150
|
+
{ fromMs: 100, toMs: 500 },
|
|
151
|
+
{ fromMs: 700, toMs: 800 },
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('normalizes reversed edges before merging', () => {
|
|
156
|
+
const gaps = mergeGapRanges([{ fromMs: 300, toMs: 100 }])
|
|
157
|
+
expect(gaps).toEqual([{ fromMs: 100, toMs: 300 }])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('clips to the domain and drops spans entirely outside it', () => {
|
|
161
|
+
const domain: ScrubberRange = { fromMs: 200, toMs: 600 }
|
|
162
|
+
const gaps = mergeGapRanges(
|
|
163
|
+
[
|
|
164
|
+
{ fromMs: 100, toMs: 400 }, // clipped to 200
|
|
165
|
+
{ fromMs: 500, toMs: 900 }, // clipped to 600
|
|
166
|
+
{ fromMs: 1000, toMs: 1100 }, // outside → dropped
|
|
167
|
+
],
|
|
168
|
+
domain,
|
|
169
|
+
)
|
|
170
|
+
expect(gaps).toEqual([
|
|
171
|
+
{ fromMs: 200, toMs: 400 },
|
|
172
|
+
{ fromMs: 500, toMs: 600 },
|
|
173
|
+
])
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('pickDisplayBucketSizeMs (adaptive strip bucket width)', () => {
|
|
178
|
+
const H = HOUR
|
|
179
|
+
|
|
180
|
+
it('keeps 1h bars at a 7-day domain (168 bars)', () => {
|
|
181
|
+
expect(pickDisplayBucketSizeMs(7 * DAY)).toBe(H)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps 1h bars at the live ~7.66-day domain (no smearing across a gap)', () => {
|
|
185
|
+
expect(pickDisplayBucketSizeMs(7.66 * DAY)).toBe(H)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('steps to 3h bars around a 22-day domain', () => {
|
|
189
|
+
expect(pickDisplayBucketSizeMs(22 * DAY)).toBe(3 * H)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('steps to 12h bars around a 92-day domain', () => {
|
|
193
|
+
expect(pickDisplayBucketSizeMs(92 * DAY)).toBe(12 * H)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('never exceeds the coarsest rung (24h) for very deep retention', () => {
|
|
197
|
+
expect(pickDisplayBucketSizeMs(3650 * DAY)).toBe(24 * H)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('countEventsAfter (frozen "new events" pull)', () => {
|
|
202
|
+
const bucket = (startMs: number, total: number): ScrubberBucket => ({
|
|
203
|
+
startMs,
|
|
204
|
+
endMs: startMs + HOUR,
|
|
205
|
+
total,
|
|
206
|
+
warnings: 0,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('is zero for an empty bucket list', () => {
|
|
210
|
+
expect(countEventsAfter([], NOW)).toBe(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('sums totals of buckets starting at/after the toMs hour, floored to the hour', () => {
|
|
214
|
+
// toMs mid-hour floors to its hour start, so the bucket AT that hour counts.
|
|
215
|
+
const hourStart = Math.floor(NOW / HOUR) * HOUR
|
|
216
|
+
const buckets = [
|
|
217
|
+
bucket(hourStart - 2 * HOUR, 5), // before → excluded
|
|
218
|
+
bucket(hourStart, 3), // the toMs hour → included
|
|
219
|
+
bucket(hourStart + HOUR, 7), // after → included
|
|
220
|
+
]
|
|
221
|
+
expect(countEventsAfter(buckets, hourStart + 30 * MIN)).toBe(10)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('excludes buckets that start strictly before the toMs hour', () => {
|
|
225
|
+
const hourStart = Math.floor(NOW / HOUR) * HOUR
|
|
226
|
+
const buckets = [bucket(hourStart - HOUR, 9)]
|
|
227
|
+
expect(countEventsAfter(buckets, hourStart)).toBe(0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('returns the raw sum (display cap is applied by the chip, not the helper)', () => {
|
|
231
|
+
const hourStart = Math.floor(NOW / HOUR) * HOUR
|
|
232
|
+
const buckets = [bucket(hourStart, 1500)]
|
|
233
|
+
expect(countEventsAfter(buckets, hourStart)).toBe(1500)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scrubber-math — pure geometry, selection math, and shared types for the
|
|
3
|
+
* timeline strip. No React or JSX: everything here is unit-testable without a
|
|
4
|
+
* DOM. The strip components (TimelineStrip, TimelineSwimlanes) and the host
|
|
5
|
+
* wrappers import these; all rendering and theme colors live in the components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface ScrubberBucket {
|
|
13
|
+
startMs: number
|
|
14
|
+
endMs: number
|
|
15
|
+
total: number
|
|
16
|
+
warnings: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScrubberRange {
|
|
20
|
+
fromMs: number
|
|
21
|
+
toMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ScrubberPreset {
|
|
25
|
+
label: string
|
|
26
|
+
ms: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Constants
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
// Smallest brush width the user can shrink to — below this the events fetch is
|
|
34
|
+
// too narrow to be useful and handles overlap.
|
|
35
|
+
const MIN_SELECTION_MS = 60_000
|
|
36
|
+
|
|
37
|
+
// Smallest view window any zoom control may produce. Shared by the strip's
|
|
38
|
+
// band resize, the swimlane zoom ladder (whose lowest rung is 0.25h), and the
|
|
39
|
+
// wheel/pinch zoom, so every way of narrowing the window bottoms out at the
|
|
40
|
+
// same width.
|
|
41
|
+
export const MIN_WINDOW_MS = 15 * 60_000
|
|
42
|
+
|
|
43
|
+
const MINUTE_MS = 60_000
|
|
44
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
45
|
+
const DAY_MS = 24 * HOUR_MS
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Pure geometry + selection math (exported for testing)
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
export type SelectionAnchor = 'start' | 'end' | 'center'
|
|
52
|
+
|
|
53
|
+
function normalize(sel: ScrubberRange): ScrubberRange {
|
|
54
|
+
return sel.fromMs <= sel.toMs ? sel : { fromMs: sel.toMs, toMs: sel.fromMs }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clamp a selection to the domain and to [MIN_SELECTION_MS, maxSelectionMs].
|
|
59
|
+
* `anchor` decides which edge stays put when the width is adjusted:
|
|
60
|
+
* - 'end' keep the right edge (presets pinned to "now", left-handle drag)
|
|
61
|
+
* - 'start' keep the left edge (right-handle drag)
|
|
62
|
+
* - 'center' keep the midpoint (zoom)
|
|
63
|
+
* Returns `clampedToMax` for callers that want to surface the cap; none do yet.
|
|
64
|
+
*/
|
|
65
|
+
export function clampSelection(
|
|
66
|
+
sel: ScrubberRange,
|
|
67
|
+
domain: ScrubberRange,
|
|
68
|
+
maxSelectionMs?: number,
|
|
69
|
+
anchor: SelectionAnchor = 'end',
|
|
70
|
+
): { selection: ScrubberRange; clampedToMax: boolean } {
|
|
71
|
+
const domainWidth = Math.max(0, domain.toMs - domain.fromMs)
|
|
72
|
+
const n = normalize(sel)
|
|
73
|
+
let { fromMs, toMs } = n
|
|
74
|
+
const width = toMs - fromMs
|
|
75
|
+
|
|
76
|
+
const minWidth = Math.min(MIN_SELECTION_MS, domainWidth)
|
|
77
|
+
let maxWidth = domainWidth
|
|
78
|
+
if (maxSelectionMs != null) maxWidth = Math.min(maxWidth, maxSelectionMs)
|
|
79
|
+
|
|
80
|
+
const targetWidth = Math.max(minWidth, Math.min(width, maxWidth))
|
|
81
|
+
const clampedToMax = maxSelectionMs != null && width > maxSelectionMs && targetWidth < width
|
|
82
|
+
|
|
83
|
+
if (anchor === 'end') {
|
|
84
|
+
fromMs = toMs - targetWidth
|
|
85
|
+
} else if (anchor === 'start') {
|
|
86
|
+
toMs = fromMs + targetWidth
|
|
87
|
+
} else {
|
|
88
|
+
const center = (fromMs + toMs) / 2
|
|
89
|
+
fromMs = center - targetWidth / 2
|
|
90
|
+
toMs = center + targetWidth / 2
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Shift the whole window back inside the domain without changing its width.
|
|
94
|
+
if (fromMs < domain.fromMs) {
|
|
95
|
+
const shift = domain.fromMs - fromMs
|
|
96
|
+
fromMs += shift
|
|
97
|
+
toMs += shift
|
|
98
|
+
}
|
|
99
|
+
if (toMs > domain.toMs) {
|
|
100
|
+
const shift = toMs - domain.toMs
|
|
101
|
+
fromMs -= shift
|
|
102
|
+
toMs -= shift
|
|
103
|
+
}
|
|
104
|
+
fromMs = Math.max(domain.fromMs, fromMs)
|
|
105
|
+
toMs = Math.min(domain.toMs, toMs)
|
|
106
|
+
|
|
107
|
+
// Pixel-to-time math produces fractional ms; emit integers so consumers can
|
|
108
|
+
// put the values straight into query params (servers parse epoch-ms ints).
|
|
109
|
+
return { selection: { fromMs: Math.round(fromMs), toMs: Math.round(toMs) }, clampedToMax }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalize recording-gap ranges for rendering: drop zero/negative-width spans,
|
|
114
|
+
* dedupe identical ones, merge overlapping/adjacent spans, and (when a domain is
|
|
115
|
+
* given) clip to it — spans entirely outside the domain are dropped. Pure so the
|
|
116
|
+
* host's bucket→gap extraction can be unit-tested without a DOM.
|
|
117
|
+
*/
|
|
118
|
+
export function mergeGapRanges(ranges: ScrubberRange[], domain?: ScrubberRange): ScrubberRange[] {
|
|
119
|
+
const valid = ranges
|
|
120
|
+
.map((r) => ({ fromMs: Math.min(r.fromMs, r.toMs), toMs: Math.max(r.fromMs, r.toMs) }))
|
|
121
|
+
.filter((r) => r.toMs > r.fromMs)
|
|
122
|
+
.sort((a, b) => a.fromMs - b.fromMs)
|
|
123
|
+
|
|
124
|
+
const merged: ScrubberRange[] = []
|
|
125
|
+
for (const r of valid) {
|
|
126
|
+
const last = merged[merged.length - 1]
|
|
127
|
+
// <= folds both overlapping and back-to-back spans into one band.
|
|
128
|
+
if (last && r.fromMs <= last.toMs) {
|
|
129
|
+
last.toMs = Math.max(last.toMs, r.toMs)
|
|
130
|
+
} else {
|
|
131
|
+
merged.push({ ...r })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!domain) return merged
|
|
136
|
+
const clipped: ScrubberRange[] = []
|
|
137
|
+
for (const r of merged) {
|
|
138
|
+
const fromMs = Math.max(r.fromMs, domain.fromMs)
|
|
139
|
+
const toMs = Math.min(r.toMs, domain.toMs)
|
|
140
|
+
if (toMs > fromMs) clipped.push({ fromMs, toMs })
|
|
141
|
+
}
|
|
142
|
+
return clipped
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Candidate display-bucket widths (hours) for the strip's histogram, coarsest
|
|
146
|
+
// last. Adaptive selection keeps the bar count bounded without over-widening
|
|
147
|
+
// bars — a wide bucket smears a short burst of events across empty time, so the
|
|
148
|
+
// strip would paint data where there is none (e.g. across a recording gap).
|
|
149
|
+
// Sub-hour rungs serve hosts that bucket raw events client-side (the local
|
|
150
|
+
// ring) when the displayed span is framed tightly around a narrow selection;
|
|
151
|
+
// hosts limited to hourly rollups (the retained server overview) pass a
|
|
152
|
+
// minBucketMs of one hour and never see them.
|
|
153
|
+
const DISPLAY_BUCKET_RUNGS_MS = [
|
|
154
|
+
MINUTE_MS,
|
|
155
|
+
5 * MINUTE_MS,
|
|
156
|
+
10 * MINUTE_MS,
|
|
157
|
+
30 * MINUTE_MS,
|
|
158
|
+
...[1, 2, 3, 6, 12, 24].map((h) => h * HOUR_MS),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
// Upper bound on rendered bars — the smallest rung whose count fits under this
|
|
162
|
+
// wins, so bars stay as fine as the strip width reasonably supports.
|
|
163
|
+
const MAX_DISPLAY_BARS = 256
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pick the finest display-bucket width for a domain: the smallest rung such that
|
|
167
|
+
* `domainWidthMs / rung <= MAX_DISPLAY_BARS`, falling back to the coarsest rung.
|
|
168
|
+
* Pure + exported so the rung boundaries can be unit-tested.
|
|
169
|
+
*/
|
|
170
|
+
export function pickDisplayBucketSizeMs(domainWidthMs: number, minBucketMs = HOUR_MS): number {
|
|
171
|
+
for (const rung of DISPLAY_BUCKET_RUNGS_MS) {
|
|
172
|
+
if (rung < minBucketMs) continue
|
|
173
|
+
if (domainWidthMs / rung <= MAX_DISPLAY_BARS) return rung
|
|
174
|
+
}
|
|
175
|
+
return DISPLAY_BUCKET_RUNGS_MS[DISPLAY_BUCKET_RUNGS_MS.length - 1]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A preset sets the brush to [now - ms, now], clamped. */
|
|
179
|
+
export function presetToSelection(
|
|
180
|
+
ms: number,
|
|
181
|
+
now: number,
|
|
182
|
+
domain: ScrubberRange,
|
|
183
|
+
maxSelectionMs?: number,
|
|
184
|
+
): { selection: ScrubberRange; clampedToMax: boolean } {
|
|
185
|
+
return clampSelection({ fromMs: now - ms, toMs: now }, domain, maxSelectionMs, 'end')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Clamp the lens window into the applied selection, preserving its width. If the
|
|
190
|
+
* lens is wider than the selection it collapses to the full selection (the lens
|
|
191
|
+
* can never show more than what the query loaded).
|
|
192
|
+
*/
|
|
193
|
+
export function clampLensToSelection(lens: ScrubberRange, selection: ScrubberRange): ScrubberRange {
|
|
194
|
+
const selWidth = Math.max(0, selection.toMs - selection.fromMs)
|
|
195
|
+
const lensWidth = lens.toMs - lens.fromMs
|
|
196
|
+
if (lensWidth >= selWidth) return { fromMs: selection.fromMs, toMs: selection.toMs }
|
|
197
|
+
let fromMs = lens.fromMs
|
|
198
|
+
let toMs = lens.toMs
|
|
199
|
+
if (fromMs < selection.fromMs) {
|
|
200
|
+
toMs += selection.fromMs - fromMs
|
|
201
|
+
fromMs = selection.fromMs
|
|
202
|
+
}
|
|
203
|
+
if (toMs > selection.toMs) {
|
|
204
|
+
fromMs -= toMs - selection.toMs
|
|
205
|
+
toMs = selection.toMs
|
|
206
|
+
}
|
|
207
|
+
return { fromMs: Math.round(fromMs), toMs: Math.round(toMs) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Human-readable duration for the lens-width chip label, e.g. "15m" / "8h" / "3d". */
|
|
211
|
+
export function formatLensDuration(ms: number): string {
|
|
212
|
+
const minutes = Math.round(ms / 60_000)
|
|
213
|
+
if (minutes < 60) return `${minutes}m`
|
|
214
|
+
const hours = ms / HOUR_MS
|
|
215
|
+
if (hours < 24) return Number.isInteger(hours) ? `${hours}h` : `${hours.toFixed(1)}h`
|
|
216
|
+
const days = ms / DAY_MS
|
|
217
|
+
return Number.isInteger(days) ? `${days}d` : `${days.toFixed(1)}d`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Approximate count of events recorded after `toMs`, for the frozen chip's "Go
|
|
222
|
+
* live · N new" pull. Floors `toMs` to the hour and sums the `total` of every
|
|
223
|
+
* bucket starting at/after that hour — hour-granular is fine, it's a CTA hint,
|
|
224
|
+
* not a metric. Pure + exported for testing.
|
|
225
|
+
*/
|
|
226
|
+
export function countEventsAfter(buckets: ScrubberBucket[], toMs: number): number {
|
|
227
|
+
const hourStart = Math.floor(toMs / HOUR_MS) * HOUR_MS
|
|
228
|
+
let sum = 0
|
|
229
|
+
for (const b of buckets) {
|
|
230
|
+
if (b.startMs >= hourStart) sum += b.total
|
|
231
|
+
}
|
|
232
|
+
return sum
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Bar height ∝ total, sqrt-scaled with a visible floor so sparse bars show. */
|
|
236
|
+
export function barHeight(total: number, maxTotal: number, trackHeight: number): number {
|
|
237
|
+
if (total <= 0 || maxTotal <= 0) return 0
|
|
238
|
+
const floor = Math.max(2, trackHeight * 0.08)
|
|
239
|
+
const scaled = (Math.sqrt(total) / Math.sqrt(maxTotal)) * trackHeight
|
|
240
|
+
return Math.max(floor, Math.min(trackHeight, scaled))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Compact pill label, e.g. "Jul 1 14:30". */
|
|
244
|
+
export function formatScrubberPill(ms: number): string {
|
|
245
|
+
const d = new Date(ms)
|
|
246
|
+
const date = d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
247
|
+
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
248
|
+
return `${date} ${time}`
|
|
249
|
+
}
|