@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,836 @@
1
+ /**
2
+ * TimelineStrip — a log-explorer-style time control: ONE query-range
3
+ * picker + ONE results histogram whose draggable view-window band pans the lanes.
4
+ * No minimap, no ×8 framing — the histogram spans the query range directly (the
5
+ * host feeds `selection` as the displayed span), so a narrow window is never a
6
+ * sub-pixel sliver because the query IS the view.
7
+ *
8
+ * Two nested spans, never confused:
9
+ * • QUERY = the data pulled from the server = the histogram's full width. Changed
10
+ * only by the query-range picker (presets or a custom From/To). Refetches.
11
+ * • WINDOW = the slice currently shown in the lanes below = the blue band on the
12
+ * histogram. Always a sub-range of the query (never wider). Purely a view —
13
+ * moving/resizing it re-renders the lanes instantly, it never re-queries.
14
+ *
15
+ * Three controls:
16
+ * 1. Query-range picker — sets the QUERY (the fetched span).
17
+ * 2. Window − / + — grows/shrinks the WINDOW keeping its end fixed, capped at the
18
+ * query span.
19
+ * 3. The band — drag to pan the window, drag its edges to resize, or draw a new
20
+ * one anywhere on the histogram. All clamped inside the query.
21
+ *
22
+ * Pure presentation: it never fetches. All colors are theme CSS variables.
23
+ */
24
+
25
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
26
+ import { clsx } from 'clsx'
27
+ import { Calendar, ChevronDown, ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
28
+ import { Tooltip } from '../ui/Tooltip'
29
+ import {
30
+ barHeight,
31
+ clampLensToSelection,
32
+ clampSelection,
33
+ formatLensDuration,
34
+ formatScrubberPill,
35
+ MIN_WINDOW_MS,
36
+ type ScrubberBucket,
37
+ type ScrubberRange,
38
+ type ScrubberPreset,
39
+ } from './scrubber-math'
40
+ import { STALE_AMBER_AFTER_MS, type TimelineLiveState } from './timeline-live'
41
+
42
+ const TRACK_HEIGHT = 48
43
+ // The window band never renders thinner than this (a "you are here" box
44
+ // squeezed to a couple pixels is invisible) — a thumb-width minimum, centered on
45
+ // the actual lens so it stays grabbable and clearly marks where you're looking.
46
+ const LENS_MIN_BAND_PX = 40
47
+ // Below this the resize edge and the move body can't both be grabbed.
48
+ const LENS_MIN_WIDTH_PX = 20
49
+
50
+ export interface TimelineStripProps {
51
+ buckets: ScrubberBucket[]
52
+ loading?: boolean
53
+ gaps?: ScrubberRange[]
54
+ /** Full retained bounds — clamps the query controls; NOT the histogram span. */
55
+ domain: ScrubberRange
56
+ /** Oldest recorded moment. When the query extends before it, the strip dims
57
+ * that region — "you can't scroll here because nothing was recorded yet",
58
+ * not "this period was quiet". */
59
+ historyUnavailableBeforeMs?: number
60
+ /** Exact loaded-event count for the footer caption. Buckets
61
+ * straddling the query edge spill a few events in/out, so a bucket sum can
62
+ * disagree with the toolbar chips (one story for the numbers). */
63
+ totalInQueryRange?: number
64
+ /** Loaded range = the histogram span (the strip shows exactly this). */
65
+ selection: ScrubberRange
66
+ onSelectionChange: (sel: ScrubberRange) => void
67
+ maxSelectionMs?: number
68
+ presets?: ScrubberPreset[]
69
+ onPresetSelect?: (preset: ScrubberPreset) => void
70
+ /** The swimlane's visible window, drawn as the draggable band inside the strip. */
71
+ lens?: ScrubberRange
72
+ onLensChange?: (lens: ScrubberRange) => void
73
+ /** When false the band can only pan, not resize — for hosts (list view) where
74
+ * the lens mirrors a scrollport whose height fixes the window size. */
75
+ lensResizable?: boolean
76
+ liveState?: TimelineLiveState
77
+ onLiveChipClick?: () => void
78
+ className?: string
79
+ }
80
+
81
+ function msToX(ms: number, domain: ScrubberRange, width: number): number {
82
+ const span = domain.toMs - domain.fromMs
83
+ if (span <= 0) return 0
84
+ return ((ms - domain.fromMs) / span) * width
85
+ }
86
+
87
+ // Field stamp for the custom-range pickers, e.g. "Jul 8, 12:01 AM".
88
+ function fieldStamp(ms: number): string {
89
+ return new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
90
+ }
91
+
92
+ const DOW_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']
93
+
94
+ // A compact, theme-styled datetime picker: a stamp button opening a month-grid
95
+ // popover with a time field and a "Now" shortcut.
96
+ function DateTimeField({ label, valueMs, onChange, nowMs }: {
97
+ label: string
98
+ valueMs: number
99
+ onChange: (ms: number) => void
100
+ nowMs: number
101
+ }) {
102
+ const [open, setOpen] = useState(false)
103
+ const rootRef = useRef<HTMLDivElement>(null)
104
+ const value = new Date(valueMs)
105
+ const [viewY, setViewY] = useState(() => value.getFullYear())
106
+ const [viewM, setViewM] = useState(() => value.getMonth())
107
+ useEffect(() => {
108
+ if (!open) return
109
+ const d = new Date(valueMs)
110
+ setViewY(d.getFullYear())
111
+ setViewM(d.getMonth())
112
+ const onDown = (e: PointerEvent) => {
113
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
114
+ }
115
+ // Capture phase: a stopPropagation elsewhere must not keep the calendar open.
116
+ window.addEventListener('pointerdown', onDown, true)
117
+ return () => window.removeEventListener('pointerdown', onDown, true)
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [open])
120
+ const prevMonth = () => { if (viewM === 0) { setViewY(viewY - 1); setViewM(11) } else setViewM(viewM - 1) }
121
+ const nextMonth = () => { if (viewM === 11) { setViewY(viewY + 1); setViewM(0) } else setViewM(viewM + 1) }
122
+ const daysInMonth = new Date(viewY, viewM + 1, 0).getDate()
123
+ const firstDow = new Date(viewY, viewM, 1).getDay()
124
+ const pickDay = (day: number) =>
125
+ onChange(new Date(viewY, viewM, day, value.getHours(), value.getMinutes()).getTime())
126
+ const timeStr = `${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}`
127
+ const setTime = (t: string) => {
128
+ const m = t.match(/^(\d{2}):(\d{2})$/)
129
+ if (m) onChange(new Date(value.getFullYear(), value.getMonth(), value.getDate(), +m[1], +m[2]).getTime())
130
+ }
131
+ const today = new Date(nowMs)
132
+ return (
133
+ <div ref={rootRef} className="relative flex min-w-0 flex-col gap-1">
134
+ <span className="text-[10.5px] text-theme-text-tertiary">{label}</span>
135
+ <button
136
+ type="button"
137
+ onClick={() => setOpen((v) => !v)}
138
+ aria-haspopup="dialog"
139
+ aria-expanded={open}
140
+ className={clsx(
141
+ 'flex w-full items-center justify-between gap-2 rounded-md border bg-theme-elevated px-2 py-1.5 text-xs tabular-nums text-theme-text-primary',
142
+ open ? 'border-accent' : 'border-theme-border hover:border-accent',
143
+ )}
144
+ >
145
+ {fieldStamp(valueMs)}
146
+ <Calendar className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
147
+ </button>
148
+ {open && (
149
+ <div
150
+ className="absolute left-0 top-full z-[60] mt-1 w-[232px] rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg"
151
+ role="dialog"
152
+ aria-label={`Pick ${label.toLowerCase()} date and time`}
153
+ >
154
+ <div className="mb-1.5 flex items-center justify-between">
155
+ <button type="button" onClick={prevMonth} aria-label="Previous month" className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary">
156
+ <ChevronLeft className="h-3.5 w-3.5" />
157
+ </button>
158
+ <span className="text-xs font-semibold text-theme-text-primary">
159
+ {new Date(viewY, viewM).toLocaleDateString([], { month: 'long', year: 'numeric' })}
160
+ </span>
161
+ <button type="button" onClick={nextMonth} aria-label="Next month" className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary">
162
+ <ChevronRight className="h-3.5 w-3.5" />
163
+ </button>
164
+ </div>
165
+ <div className="grid grid-cols-7 text-center text-[10px] text-theme-text-tertiary">
166
+ {DOW_LABELS.map((d, i) => <span key={i} className="py-0.5">{d}</span>)}
167
+ </div>
168
+ <div className="grid grid-cols-7">
169
+ {Array.from({ length: firstDow }).map((_, i) => <span key={`pad-${i}`} />)}
170
+ {Array.from({ length: daysInMonth }).map((_, i) => {
171
+ const day = i + 1
172
+ const isSelected = value.getFullYear() === viewY && value.getMonth() === viewM && value.getDate() === day
173
+ const isToday = today.getFullYear() === viewY && today.getMonth() === viewM && today.getDate() === day
174
+ return (
175
+ <button
176
+ key={day}
177
+ type="button"
178
+ onClick={() => pickDay(day)}
179
+ className={clsx(
180
+ 'h-7 rounded text-xs tabular-nums transition-colors',
181
+ isSelected
182
+ ? 'bg-accent font-semibold text-white'
183
+ : isToday
184
+ ? 'font-semibold text-accent-text hover:bg-theme-hover'
185
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
186
+ )}
187
+ >
188
+ {day}
189
+ </button>
190
+ )
191
+ })}
192
+ </div>
193
+ <div className="mt-2 flex items-center justify-between gap-2 border-t border-theme-border pt-2">
194
+ <input
195
+ type="time"
196
+ value={timeStr}
197
+ onChange={(e) => setTime(e.target.value)}
198
+ className="rounded border border-theme-border bg-theme-elevated px-1.5 py-1 text-xs tabular-nums text-theme-text-primary focus:border-accent focus:outline-none"
199
+ />
200
+ <button type="button" onClick={() => { onChange(nowMs); setOpen(false) }} className="text-xs font-medium text-accent-text hover:underline">
201
+ Now
202
+ </button>
203
+ </div>
204
+ </div>
205
+ )}
206
+ </div>
207
+ )
208
+ }
209
+
210
+ // Window-edge label parts, kept short + separate so the pill can stack the time
211
+ // over a muted date instead of one long wrapping string.
212
+ function bandTime(ms: number): string {
213
+ return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
214
+ }
215
+ // Strip-corner stamp, e.g. "Jul 6, 10:17 PM" — the query range's true bounds.
216
+ function footerStamp(ms: number): string {
217
+ return new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
218
+ }
219
+
220
+ // Round window sizes the − / + stepper snaps to, so the jumps are consistent
221
+ // regardless of a dragged start size (no 7.5h → 15h → 3.75h ladders). Capped to
222
+ // the query span at use — the window is always ⊆ the query.
223
+ const MIN = 60_000
224
+ const HR = 60 * MIN
225
+ const DY = 24 * HR
226
+ export const WINDOW_RUNGS_MS = [
227
+ MIN_WINDOW_MS, 30 * MIN,
228
+ HR, 2 * HR, 6 * HR, 12 * HR,
229
+ DY, 2 * DY, 7 * DY, 14 * DY, 30 * DY,
230
+ ]
231
+
232
+ /** The next round window size above/below `currentMs`, capped at `capMs`. */
233
+ export function nextWindowRungMs(currentMs: number, dir: 1 | -1, capMs: number): number {
234
+ const rungs = WINDOW_RUNGS_MS.filter((r) => r <= capMs)
235
+ if (rungs.length === 0) return Math.min(currentMs, capMs)
236
+ if (dir > 0) {
237
+ // 5% slack so a value already on a rung jumps to the NEXT one, not itself.
238
+ return rungs.find((r) => r > currentMs * 1.05) ?? capMs
239
+ }
240
+ const smaller = [...rungs].reverse().find((r) => r < currentMs * 0.95)
241
+ return smaller ?? rungs[0]
242
+ }
243
+
244
+ /**
245
+ * Resize the view WINDOW to `targetMs` keeping its END fixed, inside — and never
246
+ * wider than — the QUERY range. End-anchored like the wheel zoom: widening
247
+ * reaches farther back in time, narrowing focuses on the most recent slice
248
+ * (a 2–5pm window widened stays ended at 5pm and grows past 2pm). Only when
249
+ * the start hits the query floor does the end give way.
250
+ */
251
+ export function resizeWindowWithinQuery(
252
+ window: ScrubberRange,
253
+ targetMs: number,
254
+ query: ScrubberRange,
255
+ ): ScrubberRange {
256
+ const width = Math.min(targetMs, query.toMs - query.fromMs)
257
+ let toMs = window.toMs
258
+ let fromMs = toMs - width
259
+ if (fromMs < query.fromMs) {
260
+ fromMs = query.fromMs
261
+ toMs = Math.min(query.toMs, fromMs + width)
262
+ }
263
+ return { fromMs, toMs }
264
+ }
265
+
266
+ type LensDrag =
267
+ | { mode: 'move'; startX: number; fromMs: number; toMs: number }
268
+ | { mode: 'resize-start'; startX: number; fromMs: number; toMs: number }
269
+ | { mode: 'resize-end'; startX: number; fromMs: number; toMs: number }
270
+ // Draw a fresh window by dragging on the histogram background; anchored at the
271
+ // pointer-down time, the other edge tracks the cursor.
272
+ | { mode: 'draw'; anchorMs: number }
273
+
274
+ export function TimelineStrip({
275
+ buckets,
276
+ loading,
277
+ gaps,
278
+ domain,
279
+ historyUnavailableBeforeMs,
280
+ totalInQueryRange,
281
+ selection,
282
+ onSelectionChange,
283
+ maxSelectionMs,
284
+ presets,
285
+ onPresetSelect,
286
+ lens,
287
+ onLensChange,
288
+ lensResizable = true,
289
+ liveState,
290
+ onLiveChipClick,
291
+ className,
292
+ }: TimelineStripProps) {
293
+ const containerRef = useRef<HTMLDivElement>(null)
294
+ const trackRef = useRef<HTMLDivElement>(null)
295
+ const [width, setWidth] = useState(800)
296
+ const [pickerOpen, setPickerOpen] = useState(false)
297
+ const [customFromMs, setCustomFromMs] = useState(0)
298
+ const [customToMs, setCustomToMs] = useState(0)
299
+
300
+ useLayoutEffect(() => {
301
+ const el = trackRef.current
302
+ if (!el) return
303
+ const measure = () => setWidth(el.clientWidth || 800)
304
+ measure()
305
+ const ro = new ResizeObserver(measure)
306
+ ro.observe(el)
307
+ return () => ro.disconnect()
308
+ }, [])
309
+
310
+ // Seed the custom pickers from the current query when the dialog opens.
311
+ useEffect(() => {
312
+ if (!pickerOpen) return
313
+ setCustomFromMs(selection.fromMs)
314
+ setCustomToMs(selection.toMs)
315
+ }, [pickerOpen, selection.fromMs, selection.toMs])
316
+
317
+ // Close the picker on outside-click / Escape. "Outside" means outside the
318
+ // trigger + dialog (pickerRef) — NOT the whole strip: scoping to containerRef
319
+ // kept the dialog open when clicking the histogram, stepper, or live chip.
320
+ // Capture phase, so a stopPropagation anywhere below can't swallow the close.
321
+ const pickerRef = useRef<HTMLDivElement>(null)
322
+ useEffect(() => {
323
+ if (!pickerOpen) return
324
+ const onDown = (e: PointerEvent) => {
325
+ if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false)
326
+ }
327
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setPickerOpen(false)
328
+ window.addEventListener('pointerdown', onDown, true)
329
+ window.addEventListener('keydown', onKey)
330
+ return () => {
331
+ window.removeEventListener('pointerdown', onDown, true)
332
+ window.removeEventListener('keydown', onKey)
333
+ }
334
+ }, [pickerOpen])
335
+
336
+ const selSpan = selection.toMs - selection.fromMs
337
+ const msPerPx = width > 0 ? selSpan / width : 0
338
+
339
+ // Keep the window inside the query at all times (the invariant: window ⊆ query).
340
+ // When the query shrinks under the current window — e.g. picking a smaller range
341
+ // — clamp the window down to fit rather than let the band overflow the histogram.
342
+ useEffect(() => {
343
+ if (!lens || !onLensChange) return
344
+ const clamped = clampLensToSelection(lens, selection)
345
+ if (clamped.fromMs !== lens.fromMs || clamped.toMs !== lens.toMs) onLensChange(clamped)
346
+ // eslint-disable-next-line react-hooks/exhaustive-deps
347
+ }, [lens?.fromMs, lens?.toMs, selection.fromMs, selection.toMs])
348
+
349
+ // --- View-window (lens) drag ---
350
+ const dragRef = useRef<LensDrag | null>(null)
351
+ useEffect(() => {
352
+ const onMove = (e: PointerEvent) => {
353
+ const drag = dragRef.current
354
+ if (!drag || !onLensChange) return
355
+ // Floor the drag-resize at the 15-min window minimum (or the query span,
356
+ // whichever is smaller), not just the pixel-grabbable minimum.
357
+ const minW = Math.max(LENS_MIN_WIDTH_PX * msPerPx, Math.min(MIN_WINDOW_MS, selSpan))
358
+ if (drag.mode === 'draw') {
359
+ const rect = trackRef.current?.getBoundingClientRect()
360
+ if (!rect || width <= 0) return
361
+ const cur = selection.fromMs + Math.min(1, Math.max(0, (e.clientX - rect.left) / width)) * selSpan
362
+ let from = Math.min(drag.anchorMs, cur)
363
+ let to = Math.max(drag.anchorMs, cur)
364
+ if (to - from < minW) to = from + minW
365
+ onLensChange({ fromMs: Math.max(selection.fromMs, from), toMs: Math.min(selection.toMs, to) })
366
+ return
367
+ }
368
+ const dxMs = (e.clientX - drag.startX) * msPerPx
369
+ if (drag.mode === 'move') {
370
+ const w = drag.toMs - drag.fromMs
371
+ let from = drag.fromMs + dxMs
372
+ from = Math.max(selection.fromMs, Math.min(from, selection.toMs - w))
373
+ onLensChange({ fromMs: from, toMs: from + w })
374
+ } else if (drag.mode === 'resize-start') {
375
+ const from = Math.max(selection.fromMs, Math.min(drag.fromMs + dxMs, drag.toMs - minW))
376
+ onLensChange({ fromMs: from, toMs: drag.toMs })
377
+ } else {
378
+ const to = Math.min(selection.toMs, Math.max(drag.toMs + dxMs, drag.fromMs + minW))
379
+ onLensChange({ fromMs: drag.fromMs, toMs: to })
380
+ }
381
+ }
382
+ const onUp = () => { dragRef.current = null }
383
+ window.addEventListener('pointermove', onMove)
384
+ window.addEventListener('pointerup', onUp)
385
+ return () => {
386
+ window.removeEventListener('pointermove', onMove)
387
+ window.removeEventListener('pointerup', onUp)
388
+ }
389
+ }, [msPerPx, width, selSpan, onLensChange, selection.fromMs, selection.toMs])
390
+
391
+ const beginLensDrag = useCallback(
392
+ (mode: 'move' | 'resize-start' | 'resize-end') => (e: React.PointerEvent) => {
393
+ if (!lens || !onLensChange) return
394
+ if (!lensResizable && mode !== 'move') return
395
+ e.preventDefault()
396
+ e.stopPropagation()
397
+ dragRef.current = { mode, startX: e.clientX, fromMs: lens.fromMs, toMs: lens.toMs }
398
+ },
399
+ [lens, onLensChange, lensResizable],
400
+ )
401
+
402
+ const applyCustom = useCallback(() => {
403
+ if (customToMs <= customFromMs) return
404
+ onSelectionChange(clampSelection({ fromMs: customFromMs, toMs: customToMs }, domain, maxSelectionMs, 'end').selection)
405
+ setPickerOpen(false)
406
+ }, [customFromMs, customToMs, domain, maxSelectionMs, onSelectionChange])
407
+ const customValid = customToMs > customFromMs
408
+
409
+ // A LIVE query reads as its relative width ("Last 7d") — absolute stamps
410
+ // re-render on every tick and read as churn while new data streams in. The
411
+ // exact range rides the tooltip; frozen/custom ranges keep explicit stamps.
412
+ const isLiveSelection =
413
+ liveState?.kind === 'live' ||
414
+ (liveState == null && Math.abs(domain.toMs - selection.toMs) < 2 * 60_000)
415
+ const matchedPreset = presets?.find((p) => Math.abs(selSpan - p.ms) <= Math.max(1000, p.ms * 0.01))
416
+ const absoluteRange = `${formatScrubberPill(selection.fromMs)} — ${formatScrubberPill(selection.toMs)}`
417
+ const queryPillLabel = isLiveSelection
418
+ ? matchedPreset
419
+ ? matchedPreset.label === 'All' ? 'All history' : `Last ${matchedPreset.label}`
420
+ : `Last ${formatLensDuration(selSpan)}`
421
+ : absoluteRange
422
+
423
+ // Draw a fresh window by dragging on the histogram background. The band + its
424
+ // resize edges stopPropagation, so this only fires on empty histogram space.
425
+ const beginDraw = useCallback((e: React.PointerEvent) => {
426
+ if (!onLensChange || !lensResizable || width <= 0) return
427
+ const rect = trackRef.current?.getBoundingClientRect()
428
+ if (!rect) return
429
+ const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / width))
430
+ dragRef.current = { mode: 'draw', anchorMs: selection.fromMs + frac * selSpan }
431
+ }, [onLensChange, lensResizable, selection.fromMs, selSpan, width])
432
+
433
+ // Window − / + resizes the WINDOW (the blue band), not the query — snapping to
434
+ // round rung sizes so the jumps are consistent. Capped at the query span.
435
+ const windowMs = lens ? lens.toMs - lens.fromMs : null
436
+ const stepWindow = (dir: 1 | -1) => {
437
+ if (!lens || !onLensChange || windowMs == null) return
438
+ const target = nextWindowRungMs(windowMs, dir, selection.toMs - selection.fromMs)
439
+ onLensChange(resizeWindowWithinQuery(lens, target, selection))
440
+ }
441
+
442
+ const maxTotal = Math.max(1, ...buckets.map((b) => b.total))
443
+
444
+ // ONE shared hover readout for the whole histogram: a floating chip above the
445
+ // cursor describing the hovered bucket (or pre-data / gap region). Replaces
446
+ // the per-bar native titles — one tooltip that tracks the pointer instead of
447
+ // hundreds of independent ones.
448
+ const [hoverX, setHoverX] = useState<number | null>(null)
449
+ const handleTrackHover = useCallback((e: React.PointerEvent) => {
450
+ const rect = trackRef.current?.getBoundingClientRect()
451
+ if (!rect || width <= 0) return
452
+ setHoverX(Math.min(width, Math.max(0, e.clientX - rect.left)))
453
+ }, [width])
454
+ const hoverLabel = (() => {
455
+ if (hoverX == null || msPerPx <= 0) return null
456
+ const t = selection.fromMs + hoverX * msPerPx
457
+ if (historyUnavailableBeforeMs != null && historyUnavailableBeforeMs > selection.fromMs && t < historyUnavailableBeforeMs) {
458
+ return `No data recorded before ${footerStamp(historyUnavailableBeforeMs)} — Radar wasn't watching yet`
459
+ }
460
+ if (gaps?.some((g) => t >= g.fromMs && t <= g.toMs)) {
461
+ return 'No data recorded — connector was offline'
462
+ }
463
+ const b = buckets.find((bk) => t >= bk.startMs && t < bk.endMs)
464
+ if (!b || b.total === 0) return `${bandTime(t)} · no events`
465
+ return `${bandTime(b.startMs)} – ${bandTime(b.endMs)} · ${b.total.toLocaleString()} events${b.warnings ? ` · ${b.warnings} warnings` : ''}`
466
+ })()
467
+
468
+ // Lens band geometry (kept ≥ LENS_MIN_BAND_PX so a tiny lens stays grabbable).
469
+ const lensGeom = (() => {
470
+ if (!lens) return null
471
+ const fromX = msToX(lens.fromMs, selection, width)
472
+ const toX = msToX(lens.toMs, selection, width)
473
+ const actualW = Math.max(2, toX - fromX)
474
+ const visualW = Math.max(LENS_MIN_BAND_PX, actualW)
475
+ const left = Math.min(Math.max(0, fromX - (visualW - actualW) / 2), width - visualW)
476
+ return { left, visualW }
477
+ })()
478
+
479
+ // Full range still paints the ordinary band (fill + grip + handles) — the
480
+ // handles-only treatment made the window affordance nearly invisible. Only
481
+ // the CAPTION is state-aware: "viewing full range" instead of restating the
482
+ // edge stamps.
483
+ const FULL_RANGE_EPS_PX = 2
484
+ const fullRange = lens != null && width > 0 &&
485
+ msToX(lens.fromMs, selection, width) <= FULL_RANGE_EPS_PX &&
486
+ msToX(lens.toMs, selection, width) >= width - FULL_RANGE_EPS_PX
487
+
488
+ const totalEvents = totalInQueryRange ?? buckets.reduce((sum, b) => sum + b.total, 0)
489
+ const gapsSuffix = gaps && gaps.length > 0 ? ` · ${gaps.length} gap${gaps.length > 1 ? 's' : ''}` : ''
490
+ // Windowed: show the zoomed span's time range + the loaded total ("events ·
491
+ // Last 7d"). The per-window count is deliberately NOT shown here — from hourly
492
+ // buckets it can only be approximate (reads "0 of N" on a sub-hour window over
493
+ // a busy hour), and the toolbar already renders the exact "Showing" count. Two
494
+ // numbers for one thing, one of them wrong, erodes trust; the strip owns the
495
+ // loaded total, the toolbar the visible count.
496
+ const centerCaption = lens && !fullRange
497
+ ? `${bandTime(lens.fromMs)} — ${bandTime(lens.toMs)} · ${totalEvents.toLocaleString()} events · ${queryPillLabel}${gapsSuffix}`
498
+ : lens && lensResizable && onLensChange
499
+ ? `${totalEvents.toLocaleString()} events · viewing full range — drag a handle to narrow${gapsSuffix}`
500
+ : `${totalEvents.toLocaleString()} events · ${queryPillLabel}${gapsSuffix}`
501
+
502
+ return (
503
+ // ONE row: the range picker + window stepper cap the histogram on
504
+ // the left, Live docks right, and the edge stamps + state caption sit under
505
+ // the track. Metrics: every control is a fixed 34px whose vertical
506
+ // center sits on the 48px track's midline (labels float in the airspace
507
+ // above) — content-driven heights made true centering impossible. Cap
508
+ // columns: 7px top + 13px label + 4px gap + 34px control → center 41 ≈
509
+ // the track's 16px offset + 24.
510
+ <div ref={containerRef} className={clsx('relative flex items-start gap-3', className)}>
511
+ <div ref={pickerRef} className="relative mt-[7px] shrink-0">
512
+ {/* No label — the pill's value ("Last 1h") names itself. The empty
513
+ line keeps the 13px label airspace so this control stays level
514
+ with the Zoom stepper and the track midline. */}
515
+ <span aria-hidden className="mb-1 block text-[10.5px] leading-[13px]">&nbsp;</span>
516
+ <Tooltip content={absoluteRange} position="bottom" disabled={pickerOpen}>
517
+ <button
518
+ type="button"
519
+ onClick={() => setPickerOpen((v) => !v)}
520
+ aria-expanded={pickerOpen}
521
+ aria-haspopup="dialog"
522
+ className={clsx(
523
+ 'flex h-[34px] items-center gap-2 whitespace-nowrap rounded-md border bg-theme-elevated px-3 text-[13px] font-semibold text-theme-text-primary',
524
+ pickerOpen ? 'border-accent' : 'border-theme-border hover:border-accent',
525
+ )}
526
+ >
527
+ {queryPillLabel}
528
+ <ChevronDown className="h-3 w-3 text-theme-text-tertiary" />
529
+ </button>
530
+ </Tooltip>
531
+ {pickerOpen && (
532
+ // NO overflow-hidden: the DateTimeField calendars pop past the
533
+ // dialog's bottom edge and were getting clipped to a header sliver.
534
+ // The footer rounds its own bottom corners instead.
535
+ <div className="absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg" role="dialog" aria-label="Time range picker">
536
+ <p className="border-b border-theme-border/60 px-4 py-2.5 text-[11px] leading-relaxed text-theme-text-secondary">
537
+ How much history to load. The blue band zooms into a slice of it.
538
+ </p>
539
+ {presets && presets.length > 0 && (
540
+ <div className="flex flex-col gap-2 border-b border-theme-border/60 px-4 py-3">
541
+ <span className="text-[10px] font-bold uppercase tracking-[0.07em] text-theme-text-tertiary">
542
+ Presets <span className="font-normal normal-case tracking-normal">· apply instantly</span>
543
+ </span>
544
+ <div className="flex flex-wrap gap-1.5">
545
+ {presets.map((p) => {
546
+ const active = Math.abs(selSpan - p.ms) <= Math.max(1000, p.ms * 0.01)
547
+ return (
548
+ <button
549
+ key={p.label}
550
+ type="button"
551
+ aria-pressed={active}
552
+ onClick={() => { onPresetSelect?.(p); setPickerOpen(false) }}
553
+ className={clsx(
554
+ 'rounded-full border px-3 py-1 text-[12.5px] font-semibold transition-colors',
555
+ active
556
+ ? 'border-accent/60 bg-theme-hover text-theme-text-primary'
557
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover',
558
+ )}
559
+ >
560
+ {p.label}
561
+ </button>
562
+ )
563
+ })}
564
+ </div>
565
+ </div>
566
+ )}
567
+ <div className="flex flex-col gap-2 px-4 py-3">
568
+ <span className="text-[10px] font-bold uppercase tracking-[0.07em] text-theme-text-tertiary">Custom</span>
569
+ <div className="grid grid-cols-[1fr_1fr_auto] items-start gap-2">
570
+ <DateTimeField label="From" valueMs={customFromMs} onChange={setCustomFromMs} nowMs={domain.toMs} />
571
+ <DateTimeField label="To" valueMs={customToMs} onChange={setCustomToMs} nowMs={domain.toMs} />
572
+ {/* Label-height spacer keeps Apply on the INPUT row, not floated
573
+ to the top of the taller field columns. */}
574
+ <div className="flex flex-col gap-1">
575
+ <span aria-hidden className="text-[10.5px]">&nbsp;</span>
576
+ <button
577
+ type="button"
578
+ onClick={applyCustom}
579
+ disabled={!customValid}
580
+ className="btn-brand rounded-md px-3.5 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-40"
581
+ >
582
+ Apply
583
+ </button>
584
+ </div>
585
+ </div>
586
+ {!customValid && (
587
+ <span className="text-[10px] text-amber-600 dark:text-amber-400">"To" must be after "From".</span>
588
+ )}
589
+ </div>
590
+ <div className="flex items-center justify-between gap-2 rounded-b-xl bg-theme-elevated px-4 py-1.5 text-[10.5px] text-theme-text-tertiary">
591
+ <span>Browser time · {Intl.DateTimeFormat().resolvedOptions().timeZone}</span>
592
+ <span>History since {formatScrubberPill(domain.fromMs)}</span>
593
+ </div>
594
+ </div>
595
+ )}
596
+ </div>
597
+
598
+ {windowMs != null && lensResizable && (
599
+ <div className="mt-[7px] shrink-0">
600
+ <span className="mb-1 block text-center text-[10.5px] font-bold uppercase leading-[13px] tracking-[0.08em] text-theme-text-tertiary">Zoom</span>
601
+ <Tooltip content="The slice shown in the lanes below — always within the loaded range" position="bottom" wrapperClassName="shrink-0">
602
+ <div className="inline-flex h-[34px] items-center overflow-hidden rounded-md border border-theme-border bg-theme-elevated">
603
+ <button type="button" aria-label="Zoom in" onClick={() => stepWindow(-1)} className="flex h-full w-[30px] items-center justify-center text-theme-text-secondary hover:bg-theme-hover">
604
+ <Minus className="h-3 w-3" />
605
+ </button>
606
+ <span className="flex h-full items-center border-x border-theme-border-light px-2 text-[11.5px] font-semibold tabular-nums text-theme-text-primary">{formatLensDuration(windowMs)}</span>
607
+ <button type="button" aria-label="Zoom out" onClick={() => stepWindow(1)} className="flex h-full w-[30px] items-center justify-center text-theme-text-secondary hover:bg-theme-hover">
608
+ <Plus className="h-3 w-3" />
609
+ </button>
610
+ </div>
611
+ </Tooltip>
612
+ </div>
613
+ )}
614
+
615
+ {/* Histogram block — the flexible center: track above, edge stamps +
616
+ state caption below. mt-4 = the label airspace, putting the 48px
617
+ track's midline level with the 34px controls' centers. */}
618
+ <div className="mt-4 min-w-0 flex-1">
619
+ <div className="relative">
620
+ {hoverLabel != null && hoverX != null && (
621
+ <div
622
+ className="pointer-events-none absolute -top-7 z-40 -translate-x-1/2 whitespace-nowrap rounded border border-theme-border bg-theme-base px-2 py-0.5 text-[11px] tabular-nums text-theme-text-primary shadow-theme-md"
623
+ style={{ left: Math.min(Math.max(hoverX, 80), Math.max(80, width - 80)) }}
624
+ data-testid="strip-hover-readout"
625
+ role="status"
626
+ >
627
+ {hoverLabel}
628
+ </div>
629
+ )}
630
+ <div
631
+ ref={trackRef}
632
+ onPointerDown={beginDraw}
633
+ onPointerMove={handleTrackHover}
634
+ onPointerLeave={() => setHoverX(null)}
635
+ className={clsx(
636
+ 'relative overflow-hidden rounded-md border border-theme-border bg-theme-elevated',
637
+ lens && onLensChange && 'cursor-crosshair',
638
+ )}
639
+ style={{ height: TRACK_HEIGHT }}
640
+ data-testid="strip-histogram"
641
+ >
642
+ {/* bars */}
643
+ {loading
644
+ ? buckets.length === 0 && (
645
+ <div className="absolute inset-0 flex items-end gap-0.5 px-1.5 pb-1.5">
646
+ {Array.from({ length: 24 }).map((_, i) => (
647
+ <div key={i} className="flex-1 animate-pulse rounded-[1px] bg-theme-border/40" style={{ height: `${20 + (i % 5) * 12}%` }} />
648
+ ))}
649
+ </div>
650
+ )
651
+ : buckets.map((b, i) => {
652
+ const h = barHeight(b.total, maxTotal, TRACK_HEIGHT - 8)
653
+ if (h <= 0) return null
654
+ // Position by TIME, never by array index: the host's buckets are
655
+ // SPARSE (empty slots are omitted), so index-spacing scattered bars
656
+ // uniformly across the strip at positions unrelated to their actual
657
+ // time — a bar would sit "under the window" while holding events
658
+ // from a different hour, and an empty window looked populated.
659
+ // Bars also clip at the recording floor: a bucket straddling it
660
+ // otherwise bleeds under the pre-data dimming and renders as a
661
+ // two-toned bar, even though all its events are after the floor.
662
+ const barFromMs = Math.max(
663
+ b.startMs,
664
+ selection.fromMs,
665
+ historyUnavailableBeforeMs ?? selection.fromMs,
666
+ )
667
+ const startX = msToX(barFromMs, selection, width)
668
+ const endX = msToX(Math.min(b.endMs, selection.toMs), selection, width)
669
+ if (endX <= startX) return null
670
+ const w = Math.max(1, endX - startX - 1)
671
+ if (endX <= 0 || startX >= width) return null
672
+ const warnFrac = b.total > 0 ? Math.min(1, b.warnings / b.total) : 0
673
+ const warnH = b.warnings > 0 ? Math.max(2, h * warnFrac) : 0
674
+ // Bars inside the WINDOW (what's shown in the lanes) read bright; bars
675
+ // in the query but outside the window are muted context — INCLUDING the
676
+ // warning overlay, or an out-of-window warning bar shows a bright red
677
+ // cap under the band and reads as "events here" when the window is empty.
678
+ const mid = (b.startMs + b.endMs) / 2
679
+ const inWindow = !lens || (mid >= lens.fromMs && mid <= lens.toMs)
680
+ return (
681
+ <div key={i} className="absolute bottom-1" style={{ left: startX, width: w }}>
682
+ {/* Out-of-window bars are muted but still legible — the dimmed
683
+ part of a photo crop, not invisible. */}
684
+ <div className={clsx('w-full rounded-[1px]', inWindow ? 'bg-accent/60' : 'bg-theme-text-tertiary/45')} style={{ height: h }} />
685
+ {/* Warning cap is AMBER: red is reserved for "actually
686
+ broken"; these buckets count warning events, not failures. */}
687
+ {warnH > 0 && <div className={clsx('absolute bottom-0 w-full rounded-[1px]', inWindow ? 'bg-[var(--color-warning)]/80' : 'bg-[var(--color-warning)]/40')} style={{ height: warnH }} />}
688
+ </div>
689
+ )
690
+ })}
691
+
692
+ {/* Pre-data region: the query extends before the oldest recorded moment.
693
+ Dimmed + edged so "nothing was recorded yet" is distinguishable from
694
+ "this period was quiet" — and the list's scroll floor is explained. */}
695
+ {historyUnavailableBeforeMs != null && historyUnavailableBeforeMs > selection.fromMs && (() => {
696
+ const edgeX = Math.min(msToX(historyUnavailableBeforeMs, selection, width), width)
697
+ if (edgeX <= 0) return null
698
+ return (
699
+ <div
700
+ className="pointer-events-auto absolute bottom-0 top-0 left-0 z-[5]"
701
+ style={{ width: edgeX }}
702
+ data-testid="strip-predata"
703
+ >
704
+ <div className="absolute inset-0 bg-theme-base/60" />
705
+ <div className="absolute bottom-0 top-0 right-0 w-px bg-theme-border" />
706
+ {edgeX > 130 && (
707
+ <span className="absolute left-2 top-1/2 -translate-y-1/2 whitespace-nowrap text-[10px] text-theme-text-tertiary">
708
+ no data before {bandTime(historyUnavailableBeforeMs)}
709
+ </span>
710
+ )}
711
+ </div>
712
+ )
713
+ })()}
714
+
715
+ {/* recording-gap hatch */}
716
+ {gaps?.map((g, i) => {
717
+ const left = Math.max(0, msToX(g.fromMs, selection, width))
718
+ const right = Math.min(width, msToX(g.toMs, selection, width))
719
+ if (right - left <= 0) return null
720
+ return (
721
+ <div
722
+ key={`gap-${i}`}
723
+ className="pointer-events-none absolute top-0 bottom-0"
724
+ style={{
725
+ left,
726
+ width: right - left,
727
+ background: 'repeating-linear-gradient(45deg, transparent 0, transparent 4px, var(--border-default) 4px, var(--border-default) 5px)',
728
+ opacity: 0.5,
729
+ }}
730
+ />
731
+ )
732
+ })}
733
+
734
+ {/* view-window band (the lens) */}
735
+ {lensGeom && lens && (
736
+ <div
737
+ role="slider"
738
+ tabIndex={0}
739
+ aria-label="Visible window — drag to pan the lanes"
740
+ aria-valuemin={selection.fromMs}
741
+ aria-valuemax={selection.toMs}
742
+ aria-valuenow={lens.fromMs}
743
+ onPointerDown={beginLensDrag('move')}
744
+ className="absolute top-0 z-10 flex cursor-grab touch-none items-center justify-center gap-[2.5px] rounded-sm active:cursor-grabbing focus:outline-none focus:ring-2 focus:ring-accent"
745
+ style={{
746
+ left: lensGeom.left,
747
+ width: lensGeom.visualW,
748
+ height: TRACK_HEIGHT,
749
+ background: 'var(--selection-bg)',
750
+ border: '2px solid var(--accent)',
751
+ boxShadow: '0 0 0 1px var(--bg-base)',
752
+ }}
753
+ data-testid="strip-lens"
754
+ >
755
+ {lensResizable && (
756
+ <span onPointerDown={beginLensDrag('resize-start')} className="absolute left-[-4px] top-1/2 h-5 w-2 -translate-y-1/2 cursor-ew-resize rounded" style={{ background: 'var(--accent)' }} aria-hidden />
757
+ )}
758
+ {/* three-bar grip */}
759
+ <span aria-hidden style={{ width: 1.5, height: 11, borderRadius: 1, background: 'var(--accent)' }} />
760
+ <span aria-hidden style={{ width: 1.5, height: 11, borderRadius: 1, background: 'var(--accent)' }} />
761
+ <span aria-hidden style={{ width: 1.5, height: 11, borderRadius: 1, background: 'var(--accent)' }} />
762
+ {lensResizable && (
763
+ <span onPointerDown={beginLensDrag('resize-end')} className="absolute right-[-4px] top-1/2 h-5 w-2 -translate-y-1/2 cursor-ew-resize rounded" style={{ background: 'var(--accent)' }} aria-hidden />
764
+ )}
765
+ </div>
766
+ )}
767
+ </div>
768
+ </div>
769
+
770
+ {/* Edge stamps + state caption under the track: query start left, query
771
+ end (+ "· now" while live) right, and between them either the window's
772
+ range ("3:41 — 4:11 · 125 events · Last 7d") or the full-range state line. */}
773
+ <div className="mt-1 flex items-center justify-between gap-2 text-[10.5px] tabular-nums text-theme-text-tertiary">
774
+ <span className="whitespace-nowrap">{footerStamp(selection.fromMs)}</span>
775
+ <span
776
+ className={clsx('min-w-0 truncate whitespace-nowrap', lens && !fullRange && 'font-medium text-accent-text')}
777
+ data-testid="strip-window-range"
778
+ >
779
+ {centerCaption}
780
+ </span>
781
+ <span className="whitespace-nowrap">
782
+ {footerStamp(selection.toMs)}
783
+ {isLiveSelection && <span className="text-theme-text-tertiary/80"> · now</span>}
784
+ </span>
785
+ </div>
786
+ </div>
787
+
788
+ {liveState && (
789
+ // mt: track offset (16) + track center (24) − chip half-height (17).
790
+ <div className="mt-[23px] shrink-0">
791
+ <StripLiveChip state={liveState} onClick={onLiveChipClick} />
792
+ </div>
793
+ )}
794
+ </div>
795
+ )
796
+ }
797
+
798
+ // Go-live / frozen chip. Live+latched is inert (already following now); a frozen
799
+ // or unlatched state is a clickable CTA back to the live edge. Both live states
800
+ // render the SAME "Live" text so the chip width is constant and a latch flip
801
+ // never shoves the histogram over; the jump affordance rides the hollow style,
802
+ // the click, and the tooltip instead.
803
+ function StripLiveChip({ state, onClick }: { state: TimelineLiveState; onClick?: () => void }) {
804
+ if (state.kind === 'live') {
805
+ if (state.latched) {
806
+ return (
807
+ <span className="inline-flex h-[34px] items-center gap-1.5 rounded-full border border-transparent bg-theme-hover px-3 text-xs font-semibold text-theme-text-secondary">
808
+ <span className="h-1.5 w-1.5 rounded-full bg-green-500" />
809
+ Live
810
+ </span>
811
+ )
812
+ }
813
+ return (
814
+ <Tooltip content="The window is behind the live edge — click to jump to now" position="bottom">
815
+ <button type="button" onClick={onClick} className="inline-flex h-[34px] items-center gap-1.5 rounded-full border border-theme-border bg-theme-elevated px-3 text-xs font-semibold text-theme-text-secondary hover:bg-theme-hover">
816
+ <span className="h-1.5 w-1.5 rounded-full border border-green-500 bg-transparent" />
817
+ Live
818
+ </button>
819
+ </Tooltip>
820
+ )
821
+ }
822
+ const stale = Date.now() - state.asOfMs > STALE_AMBER_AFTER_MS
823
+ return (
824
+ <button
825
+ type="button"
826
+ onClick={onClick}
827
+ className={clsx(
828
+ 'inline-flex h-[34px] items-center gap-1.5 rounded-full px-3 text-xs font-semibold text-white',
829
+ stale ? 'bg-amber-500 hover:bg-amber-600' : 'bg-green-600 hover:bg-green-700',
830
+ )}
831
+ >
832
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
833
+ Go live{state.newEventCount ? ` · ${state.newEventCount.toLocaleString()} new` : ''}
834
+ </button>
835
+ )
836
+ }