@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.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. 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
+ }