@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,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]"> </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]"> </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
|
+
}
|