@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
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
1
+ import { Fragment, useState, useMemo, useRef, useCallback, useEffect } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import {
4
4
  AlertCircle,
@@ -6,11 +6,9 @@ import {
6
6
  RefreshCw,
7
7
  ZoomIn,
8
8
  ZoomOut,
9
+ ChevronLeft,
9
10
  ChevronRight,
10
11
  X,
11
- List,
12
- GanttChart,
13
- ArrowUpDown,
14
12
  Clock,
15
13
  MemoryStick,
16
14
  Package,
@@ -21,28 +19,515 @@ import {
21
19
  Timer,
22
20
  RotateCcw,
23
21
  Shield,
24
- Trash2,
22
+ Pin,
23
+ ChevronsDownUp,
24
+ ChevronsUpDown,
25
25
  } from 'lucide-react'
26
26
  import type { TimelineEvent, Topology } from '../../types'
27
27
  import type { NavigateToResource } from '../../utils/navigation'
28
28
  import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
29
29
  import { PaneLoader } from '../ui/PaneLoader'
30
- import { SearchBox } from '../ui/SearchBox'
30
+ import { TimelineToolbar } from './TimelineToolbar'
31
+ import { sortTimelineLanes, TIMELINE_SORT_DEFAULT, type TimelineSort } from './timeline-lane-sort'
32
+ import {
33
+ matchesActivityFilter,
34
+ matchesTimelineSearch,
35
+ mergeKindOptions,
36
+ describeActiveFilters,
37
+ type ActivityFilterKey,
38
+ } from './timeline-filters'
31
39
  import { pluralize } from '../../utils/pluralize'
32
40
  import { gitOpsRouteForKind } from '../../utils/gitops-route'
33
41
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
34
42
  import { DiffViewer } from './DiffViewer'
35
- import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
43
+ import { getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
44
+ import { MiddleEllipsis } from '../ui/MiddleEllipsis'
36
45
  import { Tooltip } from '../ui/Tooltip'
37
- import { buildResourceHierarchy, isProblematicEvent, type ResourceLane as BaseResourceLane } from '../../utils/resource-hierarchy'
46
+ import { ResourceRefBadge } from '../ui/drawer-components'
47
+ import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
48
+ import { groupQualifiesLaneId } from '../../utils/navigation'
49
+ import type { AppMembershipIndex } from '../../utils/applications'
50
+ import { Layers } from 'lucide-react'
38
51
  import {
39
52
  formatAxisTime,
40
53
  formatFullTime,
41
54
  buildHealthSpans,
42
- HealthSpan,
55
+ buildLaneMemberSpans,
56
+ sweepAggregateHealth,
57
+ formatAggregateHealthTooltip,
43
58
  timeToX as sharedTimeToX,
44
59
  } from './shared'
45
60
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
61
+ import { clampLensToSelection, formatLensDuration, MIN_WINDOW_MS, type ScrubberRange } from './scrubber-math'
62
+
63
+ // Predefined zoom levels (window widths in hours): 15m, 30m, 1h, 2h, 4h, 8h,
64
+ // 12h, 1d, 2d, 3d, 7d. Hoisted so the controlled-window adapter can size the
65
+ // view from a window width instead of the internal `zoom` state.
66
+ const ZOOM_LEVELS = [0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168]
67
+
68
+ const HOUR_MS = 60 * 60 * 1000
69
+
70
+ // A lane with at most this many direct children auto-expands on first render:
71
+ // a small family's whole anatomy is cheap to show, so we reveal it by default.
72
+ // Larger families (a Deployment with 20 pods, a big app group) stay collapsed to
73
+ // keep the list scannable — the user opens them deliberately. 4 keeps a typical
74
+ // Deployment→ReplicaSet(+a few pods) or CronJob→Job×2 open while collapsing noisy
75
+ // fan-outs.
76
+ export const AUTO_COLLAPSE_THRESHOLD = 4
77
+
78
+ // Deterministic default expansion, computed from the lane tree alone (no
79
+ // storage, no window state) so SSR and the first client render agree. A lane
80
+ // with 1..THRESHOLD children starts expanded; 0 children or a large fan-out
81
+ // starts collapsed. Recurses to every depth so a small Job under a large CronJob
82
+ // still defaults open.
83
+ //
84
+ // STRUCTURAL count (not the in-window visible subset): the window is time-derived
85
+ // (effectiveNow) and would desync SSR from hydration, and keying the default on
86
+ // the lens would thrash a family open/closed as the user pans. The per-render
87
+ // window filter (visibleChildren in renderLane) then hides out-of-window rows from
88
+ // whatever this opened, so a structurally-small family reveals only its live
89
+ // members without a large fan-out ever auto-opening on a transient in-window dip.
90
+ export function computeAutoExpandedLanes(lanes: BaseResourceLane[]): Set<string> {
91
+ const out = new Set<string>()
92
+ const walk = (lane: BaseResourceLane): void => {
93
+ const n = lane.children?.length ?? 0
94
+ if (n > 0 && n <= AUTO_COLLAPSE_THRESHOLD) out.add(lane.id)
95
+ for (const c of lane.children ?? []) walk(c)
96
+ }
97
+ for (const l of lanes) walk(l)
98
+ return out
99
+ }
100
+
101
+ /** Every lane id in the tree (parents + all descendants), pre-order. */
102
+ export function collectLaneIdsDeep(lanes: BaseResourceLane[]): string[] {
103
+ const out: string[] = []
104
+ const walk = (lane: BaseResourceLane): void => {
105
+ out.push(lane.id)
106
+ for (const c of lane.children ?? []) walk(c)
107
+ }
108
+ for (const l of lanes) walk(l)
109
+ return out
110
+ }
111
+
112
+ /**
113
+ * Freeze each lane's auto-expand default at FIRST sighting. A live poll rebuilds the
114
+ * whole lane tree every tick; recomputing the count-based default per tick would open
115
+ * or close rows on their own as a family's child count drifts across
116
+ * AUTO_COLLAPSE_THRESHOLD (a Deployment picking up a 5th pod would silently collapse).
117
+ * Instead the structural default is computed once per lane id: a lane already recorded
118
+ * in `prev` keeps its default; a newly-arrived lane adopts the current structural
119
+ * default. Existing rows are never reopened/closed by arriving events. Pure (returns a
120
+ * new map); the caller holds it in a ref across renders.
121
+ */
122
+ export function reconcileAutoExpand(
123
+ prev: ReadonlyMap<string, boolean>,
124
+ lanes: BaseResourceLane[],
125
+ ): Map<string, boolean> {
126
+ const fresh = computeAutoExpandedLanes(lanes)
127
+ const next = new Map(prev)
128
+ for (const id of collectLaneIdsDeep(lanes)) {
129
+ if (!next.has(id)) next.set(id, fresh.has(id))
130
+ }
131
+ return next
132
+ }
133
+
134
+ /**
135
+ * Live-mode order hysteresis. In live mode the importance rank recomputes every poll
136
+ * (recency buckets + new events shift scores), reshuffling lanes under the user's gaze.
137
+ * This keeps lanes already on screen in their previous relative order and splices NEW
138
+ * lanes in by their fresh rank: a new lane lands right after the surviving lane that
139
+ * precedes it in the fresh ranking (or at the very top when none precedes it). Existing
140
+ * lanes never move relative to each other, so the viewport doesn't jump. Pure + exported
141
+ * for tests; the caller memoizes the previous order by lane id.
142
+ */
143
+ export function mergeLaneOrderById(
144
+ prevOrder: readonly string[],
145
+ fresh: readonly string[],
146
+ ): string[] {
147
+ const prevSet = new Set(prevOrder)
148
+ const freshSet = new Set(fresh)
149
+ const survivors = prevOrder.filter((id) => freshSet.has(id))
150
+ const insertAfter = new Map<string, string[]>()
151
+ const front: string[] = []
152
+ let lastSurvivor: string | null = null
153
+ for (const id of fresh) {
154
+ if (prevSet.has(id)) {
155
+ lastSurvivor = id
156
+ continue
157
+ }
158
+ if (lastSurvivor == null) front.push(id)
159
+ else {
160
+ const list = insertAfter.get(lastSurvivor)
161
+ if (list) list.push(id)
162
+ else insertAfter.set(lastSurvivor, [id])
163
+ }
164
+ }
165
+ const out = [...front]
166
+ for (const id of survivors) {
167
+ out.push(id)
168
+ const ins = insertAfter.get(id)
169
+ if (ins) out.push(...ins)
170
+ }
171
+ return out
172
+ }
173
+
174
+ // Base resource-label column width; compact widens it (see the laneLabelPx /
175
+ // laneTrackInsetPx locals). The label column + 32px (mr-8) right gutter frame the
176
+ // event track; gap bands and the "Now" line map x into `calc(label + (100% -
177
+ // inset) * frac)`, so those per-mode locals MUST match the rendered label cells
178
+ // or the Now line and gap bands draw against a track shifted left of the real one.
179
+ const LANE_LABEL_PX = 360
180
+
181
+ // A gap band wider than this (px) has room for its "connector offline" caption.
182
+ const GAP_LABEL_MIN_PX = 80
183
+
184
+ /** A time window; shared shape with the scrubber's ScrubberRange. */
185
+ export type TimeWindow = ScrubberRange
186
+
187
+ /**
188
+ * Clamp a view window into bounds, preserving width. A window wider than the
189
+ * bounds collapses to the full bounds (fully zoomed out = bounds edge-to-edge).
190
+ * This is the same invariant as the scrubber's lens clamp, reused so the two
191
+ * can't drift.
192
+ */
193
+ export const clampWindowToBounds = clampLensToSelection
194
+
195
+ /**
196
+ * Step a view window to the next/previous zoom preset, keeping its END fixed,
197
+ * then clamp into bounds. End-anchored: zooming out reaches farther back in
198
+ * time, zooming in focuses on the most recent slice — the natural reading of
199
+ * a timeline whose right edge is "now". (Center anchoring made both directions
200
+ * eat into the recent edge, which read as the view drifting.)
201
+ */
202
+ export function zoomWindowWithinBounds(
203
+ win: TimeWindow,
204
+ dir: 'in' | 'out',
205
+ bounds?: TimeWindow,
206
+ ): TimeWindow {
207
+ const widthHours = (win.toMs - win.fromMs) / HOUR_MS
208
+ const idx = ZOOM_LEVELS.findIndex((l) => l >= widthHours)
209
+ const cur = idx === -1 ? ZOOM_LEVELS.length - 1 : idx
210
+ const nextIdx = dir === 'in'
211
+ ? Math.max(0, cur - 1)
212
+ : Math.min(ZOOM_LEVELS.length - 1, cur + 1)
213
+ const nextWidthMs = ZOOM_LEVELS[nextIdx] * HOUR_MS
214
+ const next = { fromMs: win.toMs - nextWidthMs, toMs: win.toMs }
215
+ return bounds ? clampWindowToBounds(next, bounds) : next
216
+ }
217
+
218
+ /**
219
+ * Continuous analog of {@link zoomWindowWithinBounds} for smooth wheel/pinch
220
+ * zoom: scale the window WIDTH by `factor` (>1 widens = zoom out, <1 narrows =
221
+ * zoom in) instead of snapping to the preset ladder. End-anchored and
222
+ * bounds-clamped exactly like the stepped version, so the wheel and the zoom
223
+ * buttons land the view in the same place — they differ only in granularity.
224
+ */
225
+ export function zoomWindowContinuous(
226
+ win: TimeWindow,
227
+ factor: number,
228
+ bounds?: TimeWindow,
229
+ ): TimeWindow {
230
+ const curWidth = win.toMs - win.fromMs
231
+ const maxWidth = bounds ? bounds.toMs - bounds.fromMs : Number.POSITIVE_INFINITY
232
+ const nextWidth = Math.max(MIN_WINDOW_MS, Math.min(curWidth * factor, maxWidth))
233
+ const next = { fromMs: win.toMs - nextWidth, toMs: win.toMs }
234
+ return bounds ? clampWindowToBounds(next, bounds) : next
235
+ }
236
+
237
+ // Map a wheel delta to a continuous zoom factor. Normalizing the delta mode and
238
+ // clamping the per-event delta makes one mouse notch a consistent step across
239
+ // devices, while a trackpad pinch (many tiny ctrl-wheel events) accumulates
240
+ // smoothly. >1 zooms out (wider window), <1 zooms in. Pure + exported for tests.
241
+ const WHEEL_ZOOM_SENSITIVITY = 0.0025
242
+ export function wheelZoomFactor(deltaY: number, deltaMode = 0): number {
243
+ let dy = deltaY
244
+ if (deltaMode === 1) dy *= 16 // DOM_DELTA_LINE → ~16px/line
245
+ else if (deltaMode === 2) dy *= 400 // DOM_DELTA_PAGE → ~one viewport
246
+ dy = Math.max(-100, Math.min(100, dy))
247
+ return Math.exp(dy * WHEEL_ZOOM_SENSITIVITY)
248
+ }
249
+
250
+ /**
251
+ * True when the entire view window sits inside a recording gap — the swimlane is
252
+ * empty because nothing was recorded, not because the period was quiet. Pure +
253
+ * exported for tests.
254
+ */
255
+ export function windowInsideGap(
256
+ win: TimeWindow | undefined,
257
+ gaps: TimeWindow[] | undefined,
258
+ ): boolean {
259
+ if (!win || !gaps || gaps.length === 0) return false
260
+ return gaps.some((g) => g.fromMs <= win.fromMs && g.toMs >= win.toMs)
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Event-marker shape coding (created ▲ / modified ● / deleted ▼ / warning ◆ /
265
+ // historical ○). Colors live in text-* classes so the glyph fills via
266
+ // currentColor and dark: variants keep contrast.
267
+ // ---------------------------------------------------------------------------
268
+ type MarkerShape = 'triangle-up' | 'triangle-down' | 'diamond' | 'circle' | 'ring'
269
+
270
+ function eventShape(event: TimelineEvent): MarkerShape {
271
+ if (isHistoricalEvent(event)) return 'ring'
272
+ if (isProblematicEvent(event)) return 'diamond'
273
+ if (isChangeEvent(event)) {
274
+ switch (event.eventType) {
275
+ case 'add': return 'triangle-up'
276
+ case 'delete': return 'triangle-down'
277
+ case 'update': return 'circle'
278
+ }
279
+ }
280
+ return 'circle'
281
+ }
282
+
283
+ function eventColorClass(event: TimelineEvent): string {
284
+ if (isProblematicEvent(event)) return 'text-amber-500 dark:text-amber-400'
285
+ if (isHistoricalEvent(event)) return 'text-theme-text-tertiary'
286
+ // ONE activity hue (color budget): every change/create/delete/
287
+ // informational dot is info-blue — the SHAPE carries the event class
288
+ // (▲ created / ● modified / ▼ deleted), hue is reserved for status.
289
+ return 'text-blue-600 dark:text-blue-400'
290
+ }
291
+
292
+ // Markers are centered on their time — but a live event's time sits AT the Now
293
+ // line, so a centered glyph/pill crosses it (and can clip the track edge). Near
294
+ // the edge the anchor flips from center to right so the marker's right edge
295
+ // kisses the Now line instead — dots respect the Now line.
296
+ // 97.5% ≈ 32px on a ~1300px track — enough clearance for the widest ×N pill
297
+ // (a 99% threshold still let pill halves poke ~4px past the Now line).
298
+ const NOW_EDGE_ANCHOR_PCT = 97.5
299
+ function markerAnchor(x: number): { left: string; anchorClass: string } {
300
+ const clamped = Math.min(x, 100)
301
+ return clamped >= NOW_EDGE_ANCHOR_PCT
302
+ ? { left: '100%', anchorClass: '-translate-x-full' }
303
+ : { left: `${clamped}%`, anchorClass: '-translate-x-1/2' }
304
+ }
305
+
306
+ /** A pure SVG-free glyph; `size` is the height in px (triangles are ~1.18× wide). */
307
+ function MarkerGlyph({ shape, size, className }: { shape: MarkerShape; size: number; className?: string }) {
308
+ const half = size / 1.7
309
+ if (shape === 'triangle-up') {
310
+ return <span className={className} style={{ display: 'block', width: 0, height: 0, borderLeft: `${half}px solid transparent`, borderRight: `${half}px solid transparent`, borderBottom: `${size}px solid currentColor` }} />
311
+ }
312
+ if (shape === 'triangle-down') {
313
+ return <span className={className} style={{ display: 'block', width: 0, height: 0, borderLeft: `${half}px solid transparent`, borderRight: `${half}px solid transparent`, borderTop: `${size}px solid currentColor` }} />
314
+ }
315
+ if (shape === 'diamond') {
316
+ const d = size * 0.8
317
+ return <span className={clsx(className, 'rounded-[2px]')} style={{ display: 'block', width: d, height: d, background: 'currentColor', transform: 'rotate(45deg)' }} />
318
+ }
319
+ if (shape === 'ring') {
320
+ return <span className={clsx(className, 'rounded-full')} style={{ display: 'block', width: size, height: size, border: '2px solid currentColor' }} />
321
+ }
322
+ return <span className={clsx(className, 'rounded-full')} style={{ display: 'block', width: size, height: size, background: 'currentColor' }} />
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Cluster near-overlapping markers into a single "×N" pill. Pure + exported so
327
+ // the collapse logic is contract-testable independently of layout.
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /** Gap (in x-percent of the track) below which two markers are treated as overlapping. */
331
+ export const CLUSTER_MIN_GAP_PCT = 1.4
332
+
333
+ export interface PositionedTimelineEvent {
334
+ event: TimelineEvent
335
+ /** Horizontal position as a percentage (0-100) of the track width. */
336
+ x: number
337
+ }
338
+
339
+ export interface TimelineEventCluster {
340
+ /** Average x-percent of the cluster's members. */
341
+ x: number
342
+ events: TimelineEvent[]
343
+ /** Highest-severity member — drives the pill glyph and the click target. */
344
+ dominant: TimelineEvent
345
+ count: number
346
+ }
347
+
348
+ /** Severity used to pick a cluster's dominant marker (higher wins; ties keep the earlier event). */
349
+ export function eventSeverityRank(event: TimelineEvent): number {
350
+ if (isProblematicEvent(event)) return 3
351
+ if (isChangeEvent(event)) {
352
+ switch (event.eventType) {
353
+ case 'delete': return 2
354
+ case 'add': return 1
355
+ case 'update': return 0
356
+ }
357
+ }
358
+ return 0
359
+ }
360
+
361
+ /** A cluster may span at most this multiple of the min gap. Chaining alone
362
+ * (each neighbor within minGap) lets a dense stream collapse into one pill
363
+ * covering a long stretch of the track — a pill claiming events "at this
364
+ * position" while its members sit far apart in time. The cap breaks the
365
+ * chain, so a dense run renders as several pills at distinct positions. */
366
+ export const CLUSTER_MAX_SPAN_FACTOR = 2
367
+
368
+ /**
369
+ * Collapse positioned events whose x-positions are within `minGap` of the
370
+ * previous one into clusters, capped so one cluster never spans more than
371
+ * CLUSTER_MAX_SPAN_FACTOR × minGap of the track. Input order is irrelevant
372
+ * (sorted internally); a single event passes through as a count-1 cluster.
373
+ */
374
+ export function clusterEventsByPosition(
375
+ positioned: PositionedTimelineEvent[],
376
+ minGap: number,
377
+ ): TimelineEventCluster[] {
378
+ const sorted = [...positioned].sort((a, b) => a.x - b.x)
379
+ const clusters: TimelineEventCluster[] = []
380
+ const maxSpan = minGap * CLUSTER_MAX_SPAN_FACTOR
381
+ let cur: { events: TimelineEvent[]; dominant: TimelineEvent; count: number; sum: number; startX: number; lastX: number } | null = null
382
+ const flush = () => {
383
+ if (cur) clusters.push({ x: cur.sum / cur.count, events: cur.events, dominant: cur.dominant, count: cur.count })
384
+ }
385
+ for (const { event, x } of sorted) {
386
+ if (cur && x - cur.lastX < minGap && x - cur.startX <= maxSpan) {
387
+ cur.events.push(event)
388
+ cur.count++
389
+ cur.sum += x
390
+ cur.lastX = x
391
+ if (eventSeverityRank(event) > eventSeverityRank(cur.dominant)) cur.dominant = event
392
+ } else {
393
+ flush()
394
+ cur = { events: [event], dominant: event, count: 1, sum: x, startX: x, lastX: x }
395
+ }
396
+ }
397
+ flush()
398
+ return clusters
399
+ }
400
+
401
+ /** One line of a cluster pill's hover breakdown: a shared label with a count. */
402
+ export interface ClusterBreakdownLine {
403
+ label: string
404
+ count: number
405
+ }
406
+
407
+ function breakdownLabel(event: TimelineEvent): string {
408
+ if (isProblematicEvent(event)) return event.reason || 'Warning'
409
+ if (isChangeEvent(event)) {
410
+ if (event.eventType === 'add') return 'Created'
411
+ if (event.eventType === 'delete') return 'Deleted'
412
+ if (event.eventType === 'update') return 'Modified'
413
+ return 'Changed'
414
+ }
415
+ return event.reason || 'Event'
416
+ }
417
+
418
+ /**
419
+ * Group a cluster's members into "count× label" lines for the pill tooltip,
420
+ * ordered by severity (warnings first), then count, then label. Labels reuse
421
+ * the single-marker vocabulary: the K8s reason for warnings, the operation
422
+ * for change events. Caps at `maxLines`; the remainder's event total is
423
+ * reported as `more`.
424
+ */
425
+ export function clusterBreakdown(
426
+ events: TimelineEvent[],
427
+ maxLines = 5,
428
+ ): { lines: ClusterBreakdownLine[]; more: number } {
429
+ const groups = new Map<string, { count: number; rank: number }>()
430
+ for (const event of events) {
431
+ const label = breakdownLabel(event)
432
+ const rank = eventSeverityRank(event)
433
+ const group = groups.get(label)
434
+ if (group) {
435
+ group.count++
436
+ if (rank > group.rank) group.rank = rank
437
+ } else {
438
+ groups.set(label, { count: 1, rank })
439
+ }
440
+ }
441
+ const ordered = [...groups.entries()]
442
+ .map(([label, group]) => ({ label, count: group.count, rank: group.rank }))
443
+ .sort((a, b) => b.rank - a.rank || b.count - a.count || a.label.localeCompare(b.label))
444
+ const lines = ordered.slice(0, maxLines).map(({ label, count }) => ({ label, count }))
445
+ const more = ordered.slice(maxLines).reduce((sum, group) => sum + group.count, 0)
446
+ return { lines, more }
447
+ }
448
+
449
+ /** The drawer state a cluster opens into: a single-event cluster opens the
450
+ * one-event panel; a multi-event cluster opens in cluster mode with members
451
+ * ordered most-severe-first. `preferId` (URL restore) selects that member when
452
+ * it's present; otherwise the dominant (top of the severity order) is selected —
453
+ * the glyph the pill already promised. Shared by the click handler and the
454
+ * restore resolver so their ordering can't drift. */
455
+ export function clusterDrawerState(
456
+ cluster: TimelineEventCluster,
457
+ preferId?: string,
458
+ ): { events: TimelineEvent[]; selectedId: string } {
459
+ if (cluster.count === 1) {
460
+ const ev = cluster.dominant
461
+ return { events: [ev], selectedId: ev.id }
462
+ }
463
+ const ordered = [...cluster.events].sort((a, b) => eventSeverityRank(b) - eventSeverityRank(a))
464
+ const selectedId = preferId && ordered.some((e) => e.id === preferId) ? preferId : ordered[0].id
465
+ return { events: ordered, selectedId }
466
+ }
467
+
468
+ /** Resolve a persisted event id back to the drawer state it should open into,
469
+ * reproducing exactly what the current render shows. Walks the rendered rows
470
+ * (pinned rows first, then the visible lanes) in render order, honoring live
471
+ * expansion via `laneTrackEvents`, and runs the SAME position clustering the
472
+ * markers use on the containing row's track. Returns null when the id is on no
473
+ * rendered row's track (pruned, filtered out, or scrolled beyond the window) —
474
+ * the caller strips the stale param. Same window/filters → same clustering, so
475
+ * restoration is deterministic. Pure + exported for tests. */
476
+ export function resolveEventCluster(
477
+ rows: BaseResourceLane[],
478
+ expandedLanes: ReadonlySet<string>,
479
+ id: string,
480
+ timeToX: (timestampMs: number) => number,
481
+ ): { events: TimelineEvent[]; selectedId: string } | null {
482
+ const visit = (lane: BaseResourceLane): { events: TimelineEvent[]; selectedId: string } | null => {
483
+ const expanded = expandedLanes.has(lane.id)
484
+ const track = laneTrackEvents(lane, expanded)
485
+ if (track.some((e) => e.id === id)) {
486
+ const positioned = track
487
+ .map((event) => ({ event, x: timeToX(new Date(event.timestamp).getTime()) }))
488
+ .filter(({ x }) => x >= 0 && x <= 100)
489
+ const cluster = clusterEventsByPosition(positioned, CLUSTER_MIN_GAP_PCT)
490
+ .find((c) => c.events.some((e) => e.id === id))
491
+ if (cluster) return clusterDrawerState(cluster, id)
492
+ }
493
+ if (expanded && lane.children) {
494
+ for (const child of lane.children) {
495
+ const found = visit(child)
496
+ if (found) return found
497
+ }
498
+ }
499
+ return null
500
+ }
501
+ for (const lane of rows) {
502
+ const found = visit(lane)
503
+ if (found) return found
504
+ }
505
+ return null
506
+ }
507
+
508
+ /** URL ↔ drawer reconciliation guard. A `selectedEventId` (mount deep-link OR a
509
+ * post-mount back/forward nav) must win over the "sync the drawer's id back to
510
+ * the URL" effect until its restore attempt settles — otherwise a transient
511
+ * closed-drawer null strips the param before the (possibly still-loading) rows
512
+ * can resolve it. Pending stays true only while the drawer is CLOSED and the id
513
+ * hasn't been resolved-or-ruled-out yet (`settledId`); an OPEN drawer is
514
+ * authoritative, so the report effect is free to sync the URL to it. A
515
+ * `dismissedId` (the id the user just closed) is NOT pending: the close is
516
+ * authoritative, so the report effect must be free to strip the param even
517
+ * before the URL has caught up — otherwise the still-present ?event=<id> echoes
518
+ * back as a fresh restore and reopens the drawer. Pure + exported for tests. */
519
+ export function restoreIsPending(
520
+ selectedEventId: string | null,
521
+ drawerSelectedId: string | null,
522
+ settledId: string | null,
523
+ dismissedId: string | null = null,
524
+ ): boolean {
525
+ if (selectedEventId == null) return false
526
+ if (drawerSelectedId != null) return false
527
+ if (selectedEventId === settledId) return false
528
+ if (selectedEventId === dismissedId) return false
529
+ return true
530
+ }
46
531
 
47
532
  export interface TimelineSwimlanesProps {
48
533
  events: TimelineEvent[]
@@ -63,6 +548,85 @@ export interface TimelineSwimlanesProps {
63
548
  // to drive server-side delete filtering on the underlying fetch.
64
549
  showDeleted?: boolean
65
550
  onShowDeletedChange?: (showDeleted: boolean) => void
551
+ // Controlled pinned-only filter (falls back to internal state when absent) —
552
+ // lifted so the host can URL-persist it with the rest of the controls.
553
+ pinnedOnly?: boolean
554
+ onPinnedOnlyChange?: (pinnedOnly: boolean) => void
555
+ // Controlled-window adapter (the LENS). When `viewWindow` is set the component
556
+ // renders exactly that window instead of its internal zoom/pan-derived one, and
557
+ // pan / zoom / "→ Now" emit the would-be window via `onViewWindowChange` rather
558
+ // than mutating internal state. `bounds` clamps the window (pan can't exit it;
559
+ // zoom-out caps at its width). When a bound edge is reached, an "extend" hint
560
+ // fires `onExtendRequest`. All optional — absent props leave the uncontrolled
561
+ // path (OSS/local mode, WorkloadView) byte-identical.
562
+ viewWindow?: TimeWindow
563
+ onViewWindowChange?: (w: TimeWindow) => void
564
+ bounds?: TimeWindow
565
+ onExtendRequest?: (dir: 'past' | 'future') => void
566
+ // Host-supplied clock for the "Now" line and health-span right edges. The
567
+ // retained host passes its live tick so both advance while watching (and
568
+ // freeze with the window in paused mode); without it they'd pin to mount
569
+ // time and new events would render PAST the stale Now line. Local mode
570
+ // omits it and keeps the mount-stable clock.
571
+ nowMs?: number
572
+ // Recording gaps (connector offline) as absolute time ranges. Rendered as
573
+ // vertical hatched bands behind the lanes; also drives the empty-state copy
574
+ // when the whole view window sits inside a gap. Retained-mode only — local
575
+ // mode omits it and behavior is unchanged.
576
+ gaps?: TimeWindow[]
577
+ // Controlled shared-filter state. When omitted each is managed internally; the
578
+ // host passes them so search / activity-type / kind survive the view switch
579
+ // and match the list view exactly. Absent props = current standalone behavior.
580
+ search?: string
581
+ onSearchChange?: (value: string) => void
582
+ activityFilter?: ActivityFilterKey[]
583
+ onActivityFilterChange?: (keys: ActivityFilterKey[]) => void
584
+ kindFilter?: string[]
585
+ onKindFilterChange?: (kinds: string[]) => void
586
+ // Server app-membership index (from GET /api/applications). When present and
587
+ // grouping='app', lanes are grouped into app header lanes via the membership
588
+ // cascade. Absent → the legacy label grouping (owner fallback) is used, no crash.
589
+ appIndex?: AppMembershipIndex
590
+ // Controlled grouping mode. When omitted managed internally (default 'app').
591
+ // The host lifts it so it survives the view switch like the other view options.
592
+ grouping?: TimelineGrouping
593
+ onGroupingChange?: (grouping: TimelineGrouping) => void
594
+ // Controlled lane sort. Lifted alongside grouping so it survives the view
595
+ // switch; managed internally (default 'importance') when omitted.
596
+ sort?: TimelineSort
597
+ onSortChange?: (sort: TimelineSort) => void
598
+ // Pinned resource lanes — a stationary section rendered ABOVE all other lanes,
599
+ // in pin order, bypassing the visible-window filter and every grouping mode.
600
+ // Pure: this component owns no storage. The host holds the list (and persists
601
+ // it) and toggles via onTogglePin. Absent → no pin affordances render (the
602
+ // uncontrolled/WorkloadView path is unchanged). A pinned resource with no
603
+ // events in the loaded selection still renders as an empty track.
604
+ pinnedLanes?: PinnedLaneRef[]
605
+ onTogglePin?: (ref: PinnedLaneRef) => void
606
+ // App-group name click → host navigates to the Applications page (?app=<key>).
607
+ onAppClick?: (appKey: string) => void
608
+ // Observable drawer selection, for URL routing. When the host wires these,
609
+ // every drawer open/select/close (cluster row switches included) reports the
610
+ // SELECTED event id upward (close → null); and a non-null `selectedEventId` on
611
+ // a closed drawer is restored once lanes are built — resolved against the
612
+ // current render (same window/filters → same clustering → deterministic) via
613
+ // resolveEventCluster. An id absent after data settles reports null once (the
614
+ // host strips the stale param). Absent → the drawer stays fully internal and
615
+ // behavior is byte-identical.
616
+ selectedEventId?: string | null
617
+ onSelectedEventChange?: (id: string | null) => void
618
+ // True while the retained view is in LIVE mode (window latched to now, polling
619
+ // every tick). Enables order hysteresis: existing lanes keep their relative
620
+ // order across polls so arriving data doesn't reshuffle the list under the user.
621
+ // A user-initiated re-rank (sort / grouping / filter change, or leaving live)
622
+ // adopts the fresh importance rank. Absent/false → fresh rank every render
623
+ // (local mode / WorkloadView unchanged).
624
+ isLive?: boolean
625
+ // Strip the power-user toolbar (search / activity + kind filters / deleted /
626
+ // view menu / counts / legend) for an embedded, single-subject swimlane where
627
+ // those controls are overkill — e.g. the workload detail's Timeline tab. The
628
+ // lanes, axis, Now line, and clustering are unchanged. Default false.
629
+ compact?: boolean
66
630
  }
67
631
 
68
632
  interface ResourceLane extends BaseResourceLane {
@@ -135,7 +699,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
135
699
  breakdown.problematic = Math.min(problematicCount * 40, 200)
136
700
 
137
701
  // 4. Tertiary: Activity type
138
- const operations = new Set(allEvents.map(e => e.eventType).filter(t => isOperation(t as any)))
702
+ const operations = new Set(allEvents.map(e => e.eventType).filter(t => isOperation(t)))
139
703
  breakdown.variety = operations.size * 10 // Up to 30 for all three types
140
704
 
141
705
  // Add/delete with caps
@@ -186,7 +750,15 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
186
750
  return breakdown
187
751
  }
188
752
 
189
- export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineSwimlanesProps) {
753
+ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange, pinnedOnly: pinnedOnlyProp, onPinnedOnlyChange, viewWindow, onViewWindowChange, bounds, onExtendRequest, nowMs, gaps, onAppClick, search: searchProp, onSearchChange, activityFilter: activityFilterProp, onActivityFilterChange, kindFilter: kindFilterProp, onKindFilterChange, appIndex, grouping: groupingProp, onGroupingChange, sort: sortProp, onSortChange, pinnedLanes, onTogglePin, selectedEventId, onSelectedEventChange, isLive, compact = false }: TimelineSwimlanesProps) {
754
+ // Controlled when the host drives the visible window (retained-mode lens).
755
+ const controlled = viewWindow != null
756
+ // Compact gives the label column extra width — no namespace subtitle or nested
757
+ // tree competing for it, and resource names (esp. hashed Pod/RS names) are long.
758
+ // The overlay geometry (Now line, gap bands) must track the same width or drift.
759
+ const laneLabelPx = compact ? 440 : LANE_LABEL_PX
760
+ const laneTrackInsetPx = laneLabelPx + 32
761
+ const laneLabelWidthClass = compact ? 'w-[440px]' : 'w-[360px]'
190
762
  // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
191
763
  // deep-link to GitOps detail rather than the resource drawer — the lane is
192
764
  // already telling the user "this controller had changes/events"; the GitOps
@@ -202,28 +774,93 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
202
774
  const containerRef = useRef<HTMLDivElement>(null)
203
775
  const [zoom, setZoom] = useState(1)
204
776
  const [panOffset, setPanOffset] = useState(0)
205
- const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
777
+ // The detail drawer. A single marker opens a one-event drawer (today's exact
778
+ // panel); a cluster pill opens the SAME drawer carrying all N members so none
779
+ // is unreachable. `selectedId` is the member whose detail is shown.
780
+ const [drawer, setDrawer] = useState<{ events: TimelineEvent[]; selectedId: string } | null>(null)
781
+ // The id the user just closed, so the restore effect doesn't reopen it from a
782
+ // not-yet-stripped ?event= echo. Set on any user close (X / Escape / re-click),
783
+ // cleared when the selection settles to null or a new drawer opens.
784
+ const dismissedIdRef = useRef<string | null>(null)
785
+ // The member currently detailed — drives the marker/pill highlight so the pill
786
+ // whose cluster is open reads as selected.
787
+ const selectedEvent = useMemo(
788
+ () => (drawer ? drawer.events.find((e) => e.id === drawer.selectedId) ?? null : null),
789
+ [drawer],
790
+ )
206
791
  const [isDragging, setIsDragging] = useState(false)
207
- const [dragStart, setDragStart] = useState({ x: 0, offset: 0 })
208
- const [searchTerm, setSearchTerm] = useState('')
209
- const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
792
+ // `offset` drives the uncontrolled pan; `windowFrom/To` capture the window at
793
+ // grab time for the controlled path (shifting an absolute window, not panOffset).
794
+ const [dragStart, setDragStart] = useState({ x: 0, offset: 0, windowFrom: 0, windowTo: 0 })
795
+ const [searchInternal, setSearchInternal] = useState('')
796
+ const searchTerm = searchProp ?? searchInternal
797
+ const setSearchTerm = onSearchChange ?? setSearchInternal
798
+ const [activityFilterInternal, setActivityFilterInternal] = useState<ActivityFilterKey[]>([])
799
+ const activityFilter = activityFilterProp ?? activityFilterInternal
800
+ const setActivityFilter = onActivityFilterChange ?? setActivityFilterInternal
801
+ const [kindFilterInternal, setKindFilterInternal] = useState<string[]>([])
802
+ const kindFilter = kindFilterProp ?? kindFilterInternal
803
+ const setKindFilter = onKindFilterChange ?? setKindFilterInternal
804
+ // The user's explicit expand/collapse choices, per lane id. Overrides the
805
+ // count-based auto default below — once the user touches a lane we never fight
806
+ // them. Empty on first render, so the initial view is fully deterministic.
807
+ const [userLaneOverrides, setUserLaneOverrides] = useState<Map<string, boolean>>(new Map())
210
808
  const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
211
- const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
809
+ // Legend is on-demand: the marker/health key is hidden until the user asks
810
+ // for it via the toolbar's Legend button, rather than always occupying a row.
811
+ const [showLegend, setShowLegend] = useState(false)
812
+ // Grouping mode: 'app' (membership cascade) | 'owner' (owner/topology only) |
813
+ // 'flat' (no parenting). Controlled by the host when provided, else internal.
814
+ const [groupingInternal, setGroupingInternal] = useState<TimelineGrouping>('app')
815
+ const grouping = groupingProp ?? groupingInternal
816
+ const setGrouping = onGroupingChange ?? setGroupingInternal
817
+
818
+ const [sortInternal, setSortInternal] = useState<TimelineSort>(TIMELINE_SORT_DEFAULT)
819
+ const sort = sortProp ?? sortInternal
820
+ const setSort = onSortChange ?? setSortInternal
212
821
  const [showDeletedInternal, setShowDeletedInternal] = useState(true)
822
+ // Pinned-only filter: session-local; auto-inert when the last pin is removed
823
+ // (the toggle disappears and effectivePinnedOnly falls back to false).
824
+ const [pinnedOnlyInternal, setPinnedOnlyInternal] = useState(false)
825
+ const pinnedOnly = pinnedOnlyProp ?? pinnedOnlyInternal
826
+ const setPinnedOnly = onPinnedOnlyChange ?? setPinnedOnlyInternal
213
827
  const showDeleted = showDeletedProp ?? showDeletedInternal
214
828
  const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
215
829
 
216
- // Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
217
- const laneOrderRef = useRef<Map<string, number>>(new Map())
218
- const [sortVersion, setSortVersion] = useState(0) // Increment to re-sort lanes
830
+ // Clear every content filter at once (search + activity + kind + show-deleted).
831
+ // Each setter already resolves to the controlled callback or the internal
832
+ // state setter, so this works in both host-driven and standalone modes.
833
+ const clearAllFilters = useCallback(() => {
834
+ setSearchTerm('')
835
+ setActivityFilter([])
836
+ setKindFilter([])
837
+ setShowDeleted(true)
838
+ }, [setSearchTerm, setActivityFilter, setKindFilter, setShowDeleted])
839
+
219
840
 
220
841
  // Stable "now" time - captured once on mount, only changes when user interacts
221
842
  // This prevents the time window from auto-shifting and causing re-renders
222
843
  const [stableNow] = useState(() => Date.now())
844
+ // Host clock wins when provided (retained live tick); local mode stays mount-stable.
845
+ const effectiveNow = nowMs ?? stableNow
223
846
 
224
- // Auto-adjust zoom based on event distribution (only once on initial load)
847
+ // Track pixel width (container minus the label column + gutter) so gap bands
848
+ // can decide whether they're wide enough to caption.
849
+ const [trackPx, setTrackPx] = useState(0)
225
850
  useEffect(() => {
226
- if (hasAutoZoomed || events.length === 0) return
851
+ const node = containerRef.current
852
+ if (!node) return
853
+ const measure = () => setTrackPx(Math.max(0, node.clientWidth - laneTrackInsetPx))
854
+ measure()
855
+ const ro = new ResizeObserver(measure)
856
+ ro.observe(node)
857
+ return () => ro.disconnect()
858
+ }, [laneTrackInsetPx])
859
+
860
+ // Auto-adjust zoom based on event distribution (only once on initial load).
861
+ // Skipped when controlled — the host owns the window, so internal zoom is inert.
862
+ useEffect(() => {
863
+ if (hasAutoZoomed || events.length === 0 || controlled) return
227
864
 
228
865
  const now = Date.now()
229
866
  const timestamps = events.map(e => new Date(e.timestamp).getTime())
@@ -246,7 +883,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
246
883
 
247
884
  setZoom(optimalZoom)
248
885
  setHasAutoZoomed(true)
249
- }, [events, hasAutoZoomed])
886
+ }, [events, hasAutoZoomed, controlled])
250
887
 
251
888
  // Keyboard shortcuts
252
889
  useRegisterShortcut({
@@ -256,24 +893,60 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
256
893
  category: 'Timeline',
257
894
  scope: 'timeline',
258
895
  handler: () => {
259
- if (selectedEvent) setSelectedEvent(null)
896
+ if (drawer) closeDrawer()
897
+ },
898
+ })
899
+
900
+ // ↑/↓ step through a cluster drawer's members (inert for a single-event drawer).
901
+ const moveClusterSelection = useCallback((dir: 1 | -1) => {
902
+ setDrawer((prev) => {
903
+ if (!prev || prev.events.length < 2) return prev
904
+ const idx = prev.events.findIndex((e) => e.id === prev.selectedId)
905
+ const next = (idx + dir + prev.events.length) % prev.events.length
906
+ return { ...prev, selectedId: prev.events[next].id }
907
+ })
908
+ }, [])
909
+ const clusterOpen = !!drawer && drawer.events.length > 1
910
+ useRegisterShortcut({
911
+ id: 'swimlane-cluster-prev',
912
+ keys: 'ArrowUp',
913
+ description: 'Previous clustered event',
914
+ category: 'Timeline',
915
+ scope: 'timeline',
916
+ handler: (e) => {
917
+ if (!clusterOpen) return
918
+ e.preventDefault()
919
+ moveClusterSelection(-1)
920
+ },
921
+ })
922
+ useRegisterShortcut({
923
+ id: 'swimlane-cluster-next',
924
+ keys: 'ArrowDown',
925
+ description: 'Next clustered event',
926
+ category: 'Timeline',
927
+ scope: 'timeline',
928
+ handler: (e) => {
929
+ if (!clusterOpen) return
930
+ e.preventDefault()
931
+ moveClusterSelection(1)
260
932
  },
261
933
  })
262
934
 
263
- // Filter events by search term
935
+ // Apply the shared filters (deleted, activity-type, kind, search) through the
936
+ // same predicates the list uses, so the two views can't drift.
264
937
  const filteredEvents = useMemo(() => {
265
- const baseEvents = showDeleted ? events : events.filter(e => e.eventType !== 'delete')
266
- if (!searchTerm) return baseEvents
267
-
268
- const term = searchTerm.toLowerCase()
269
- return baseEvents.filter(e =>
270
- e.name.toLowerCase().includes(term) ||
271
- e.kind.toLowerCase().includes(term) ||
272
- e.namespace?.toLowerCase().includes(term) ||
273
- e.reason?.toLowerCase().includes(term) ||
274
- e.message?.toLowerCase().includes(term)
275
- )
276
- }, [events, searchTerm, showDeleted])
938
+ return events.filter((e) => {
939
+ if (!showDeleted && e.eventType === 'delete') return false
940
+ if (!matchesActivityFilter(e, activityFilter)) return false
941
+ if (kindFilter.length > 0 && !kindFilter.includes(e.kind)) return false
942
+ if (!matchesTimelineSearch(e, searchTerm)) return false
943
+ return true
944
+ })
945
+ }, [events, searchTerm, showDeleted, activityFilter, kindFilter])
946
+
947
+ // Kind dropdown options: seed set + every kind present in the (unfiltered)
948
+ // events. The swimlane fetches all kinds, so deriving from events is stable.
949
+ const kindOptions = useMemo(() => mergeKindOptions(events.map((e) => e.kind)), [events])
277
950
 
278
951
  // Build hierarchical lanes using owner references + topology edges
279
952
  // Uses the shared utility from utils/resource-hierarchy.ts
@@ -282,126 +955,461 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
282
955
  const baseLanes = buildResourceHierarchy({
283
956
  events: filteredEvents,
284
957
  topology,
285
- groupByApp,
958
+ grouping,
959
+ appIndex,
286
960
  })
287
961
 
288
- // Add score breakdown to each lane (specific to swimlanes view)
289
- const lanesWithScores: ResourceLane[] = baseLanes.map(lane => ({
290
- ...lane,
291
- scoreBreakdown: calculateInterestingnessWithBreakdown(lane),
292
- }))
293
-
294
- // Sort by interestingness score (highest first)
295
- return lanesWithScores.sort((a, b) => {
296
- const aScore = a.scoreBreakdown?.total ?? calculateInterestingness(a)
297
- const bScore = b.scoreBreakdown?.total ?? calculateInterestingness(b)
298
- return bScore - aScore
962
+ // Add score breakdown to each lane (specific to swimlanes view). An app-group
963
+ // header carries no own events, so its score is the MAX of its members —
964
+ // otherwise the empty-lane penalty would sink every app to the bottom. The
965
+ // top-level ordering is applied later by `orderedLanes` (sort-mode aware).
966
+ const lanesWithScores: ResourceLane[] = baseLanes.map(lane => {
967
+ if (lane.isAppGroup) {
968
+ const memberScores = (lane.children ?? []).map(calculateInterestingness)
969
+ return { ...lane, scoreBreakdown: { ...calculateInterestingnessWithBreakdown(lane), total: memberScores.length ? Math.max(...memberScores) : 0 } }
970
+ }
971
+ return { ...lane, scoreBreakdown: calculateInterestingnessWithBreakdown(lane) }
299
972
  })
300
- }, [filteredEvents, topology, sortVersion, groupByApp])
301
973
 
302
- // Re-sort lanes by interestingness score
303
- const handleRefreshSort = useCallback(() => {
304
- // Reset lane order to force re-sort by interestingness
305
- laneOrderRef.current = new Map()
306
- setSortVersion(v => v + 1)
307
- }, [])
974
+ return lanesWithScores
975
+ }, [filteredEvents, topology, grouping, appIndex])
976
+
977
+ // Same-kind cross-group collisions among the visible lanes (CAPI vs CNPG
978
+ // `Cluster`). Only lanes in this set show a disambiguating group chip — the API
979
+ // group is otherwise invisible. Pure + memoized on the built lane tree.
980
+ const collidingKeys = useMemo(() => collidingLaneKeys(lanes), [lanes])
981
+
982
+ // Auto-expand default frozen per lane id at first sighting (see
983
+ // reconcileAutoExpand) so a live poll's rebuilt tree can't reopen/close rows as
984
+ // child counts drift. Held in a ref across renders; new lanes adopt their
985
+ // structural default, existing lanes keep theirs.
986
+ const autoExpandRef = useRef<Map<string, boolean>>(new Map())
987
+
988
+ // Effective expansion = frozen auto default, then the user's per-lane overrides
989
+ // layered on top. `lanes` holds every id (nested), and pinned rows are extracted
990
+ // from it, so this one set covers both sections.
991
+ const expandedLanes = useMemo(() => {
992
+ const frozen = reconcileAutoExpand(autoExpandRef.current, lanes)
993
+ autoExpandRef.current = frozen
994
+ const eff = new Set<string>()
995
+ for (const [id, auto] of frozen) if (auto) eff.add(id)
996
+ for (const [id, expanded] of userLaneOverrides) {
997
+ if (expanded) eff.add(id)
998
+ else eff.delete(id)
999
+ }
1000
+ return eff
1001
+ }, [lanes, userLaneOverrides])
308
1002
 
309
- // Toggle lane expansion
1003
+ // Toggle lane expansion — pins the flipped state as a user override so it wins
1004
+ // over the auto default from here on.
310
1005
  const toggleLane = useCallback((laneId: string) => {
311
- setExpandedLanes(prev => {
312
- const next = new Set(prev)
313
- if (next.has(laneId)) {
314
- next.delete(laneId)
315
- } else {
316
- next.add(laneId)
317
- }
1006
+ const isExpanded = expandedLanes.has(laneId)
1007
+ setUserLaneOverrides(prev => {
1008
+ const next = new Map(prev)
1009
+ next.set(laneId, !isExpanded)
318
1010
  return next
319
1011
  })
320
- }, [])
1012
+ }, [expandedLanes])
1013
+
1014
+ // Bulk expand/collapse (RESOURCE header toggle): pin an override for every
1015
+ // lane that has children, so the whole tree opens/closes regardless of the
1016
+ // frozen auto defaults. REPLACES prior per-row overrides — "expand all" means
1017
+ // all, not "all except the two rows I once closed".
1018
+ const setAllExpanded = useCallback((expanded: boolean) => {
1019
+ const next = new Map<string, boolean>()
1020
+ const walk = (ls: ResourceLane[]) => {
1021
+ for (const l of ls) {
1022
+ if (l.children?.length) {
1023
+ next.set(l.id, expanded)
1024
+ walk(l.children as ResourceLane[])
1025
+ }
1026
+ }
1027
+ }
1028
+ walk(lanes)
1029
+ setUserLaneOverrides(next)
1030
+ }, [lanes])
1031
+
1032
+ // One morphing toggle: when at least half the groups are open the
1033
+ // button offers collapse, otherwise expand — one control, state-aware.
1034
+ const expandableIds = useMemo(() => {
1035
+ const ids: string[] = []
1036
+ const walk = (ls: ResourceLane[]) => {
1037
+ for (const l of ls) {
1038
+ if (l.children?.length) {
1039
+ ids.push(l.id)
1040
+ walk(l.children as ResourceLane[])
1041
+ }
1042
+ }
1043
+ }
1044
+ walk(lanes)
1045
+ return ids
1046
+ }, [lanes])
1047
+ const mostlyExpanded =
1048
+ expandableIds.length > 0 &&
1049
+ expandableIds.filter((id) => expandedLanes.has(id)).length >= expandableIds.length / 2
1050
+ useRegisterShortcut({
1051
+ id: 'swimlane-toggle-expand-all',
1052
+ keys: 'e',
1053
+ description: 'Expand/collapse all resources',
1054
+ category: 'Timeline',
1055
+ scope: 'timeline',
1056
+ handler: (ev) => {
1057
+ ev.preventDefault()
1058
+ setAllExpanded(!mostlyExpanded)
1059
+ },
1060
+ })
321
1061
 
322
- // Calculate visible time range
1062
+ // Calculate visible time range. When controlled, the host's window replaces the
1063
+ // internal zoom/pan/stableNow math entirely (adapter layer — the uncontrolled
1064
+ // branch below is unchanged).
323
1065
  const visibleTimeRange = useMemo(() => {
1066
+ if (viewWindow) {
1067
+ return {
1068
+ start: viewWindow.fromMs,
1069
+ end: viewWindow.toMs,
1070
+ windowMs: viewWindow.toMs - viewWindow.fromMs,
1071
+ now: effectiveNow,
1072
+ }
1073
+ }
324
1074
  const windowMs = zoom * 60 * 60 * 1000
325
- const end = stableNow - panOffset
1075
+ const end = effectiveNow - panOffset
326
1076
  const start = end - windowMs
327
- return { start, end, windowMs, now: stableNow }
328
- }, [zoom, panOffset, stableNow])
1077
+ return { start, end, windowMs, now: effectiveNow }
1078
+ }, [zoom, panOffset, effectiveNow, viewWindow])
1079
+
1080
+ // Apply the chosen ordering to the top-level lanes. 'recent' needs the visible
1081
+ // window (newest-in-view first), so this lives after visibleTimeRange. The
1082
+ // pinned section is ordered separately (strict pin order) and never flows here.
1083
+ const orderedLanes = useMemo(
1084
+ () => sortTimelineLanes(lanes, sort, {
1085
+ windowStart: visibleTimeRange.start,
1086
+ windowEnd: visibleTimeRange.end,
1087
+ scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
1088
+ }),
1089
+ [lanes, sort, visibleTimeRange],
1090
+ )
1091
+
1092
+ // Live-mode order hysteresis. The importance rank recomputes every poll (recency
1093
+ // decay + new events), which reshuffles the whole list — the "screen jumps" the
1094
+ // user sees. While live, keep the prior relative order and splice new lanes in by
1095
+ // rank (mergeLaneOrderById). A user-initiated re-rank — sort / grouping / any
1096
+ // content-filter change, or leaving live — drops the hysteresis and adopts the
1097
+ // fresh rank. The live edge sliding (or a live pan) is NOT a re-rank; keeping order
1098
+ // calm across it is the whole point. Inert when !isLive (local / WorkloadView).
1099
+ const laneOrderRef = useRef<string[] | null>(null)
1100
+ const rankResetKey = useMemo(
1101
+ () => JSON.stringify([sort, grouping, searchTerm, showDeleted, activityFilter, kindFilter]),
1102
+ [sort, grouping, searchTerm, showDeleted, activityFilter, kindFilter],
1103
+ )
1104
+ const rankResetKeyRef = useRef(rankResetKey)
1105
+ const wasLiveRef = useRef(false)
1106
+ const stableOrderedLanes = useMemo(() => {
1107
+ const freshIds = orderedLanes.map((l) => l.id)
1108
+ const resetKeyChanged = rankResetKeyRef.current !== rankResetKey
1109
+ rankResetKeyRef.current = rankResetKey
1110
+ const enteringLive = !!isLive && !wasLiveRef.current
1111
+ wasLiveRef.current = !!isLive
1112
+ if (!isLive || resetKeyChanged || enteringLive || laneOrderRef.current == null) {
1113
+ laneOrderRef.current = freshIds
1114
+ return orderedLanes
1115
+ }
1116
+ const mergedIds = mergeLaneOrderById(laneOrderRef.current, freshIds)
1117
+ laneOrderRef.current = mergedIds
1118
+ const byId = new Map(orderedLanes.map((l) => [l.id, l]))
1119
+ return mergedIds.map((id) => byId.get(id)).filter((l): l is ResourceLane => l != null)
1120
+ }, [orderedLanes, isLive, rankResetKey])
1121
+
1122
+ // Window-width label (the visible span, e.g. "15m" / "8h" / "3d"), rendered
1123
+ // only in local (uncontrolled) mode. Shares the scrubber's lens-width
1124
+ // formatting so the two never drift.
1125
+ const windowLabel = formatLensDuration(visibleTimeRange.windowMs)
1126
+
1127
+ // "→ Now" is a local-mode affordance only: controlled/retained mode hides it
1128
+ // (the scrubber's live/paused chip owns the path back to now). Uncontrolled it
1129
+ // shows once the view has panned into the past, and resets the internal pan.
1130
+ const showJumpToNow = !controlled && panOffset > 0
1131
+ const handleJumpToNow = () => {
1132
+ setPanOffset(0)
1133
+ }
1134
+
1135
+ // Extend affordances: when the controlled window is pinned against a bound edge
1136
+ // there may be more retained data just beyond the loaded selection. The future
1137
+ // side only offers extension when the selection actually ends before ~now (a
1138
+ // historical query) — you can't load data that doesn't exist yet.
1139
+ const EDGE_EPSILON_MS = 1000
1140
+ const atPastEdge = controlled && !!bounds && !!onExtendRequest
1141
+ && visibleTimeRange.start <= bounds.fromMs + EDGE_EPSILON_MS
1142
+ const atFutureEdge = controlled && !!bounds && !!onExtendRequest
1143
+ && visibleTimeRange.end >= bounds.toMs - EDGE_EPSILON_MS
1144
+ && bounds.toMs < effectiveNow - 60 * 1000
1145
+
1146
+ // Events inside the current view window (post-filter). Drives the toolbar
1147
+ // "events" count so it's view-scoped like "resources" — a lens sitting in a
1148
+ // recording gap reads "0 resources · 0 events", not "0 resources · N events".
1149
+ const eventsInWindow = useMemo(() => {
1150
+ const { start, end } = visibleTimeRange
1151
+ return filteredEvents.filter((e) => {
1152
+ const t = new Date(e.timestamp).getTime()
1153
+ return t >= start && t <= end
1154
+ })
1155
+ }, [filteredEvents, visibleTimeRange])
1156
+
1157
+ // Recording gaps clipped to the visible window, for the hatched lane bands.
1158
+ const visibleGaps = useMemo(() => {
1159
+ if (!gaps || gaps.length === 0) return []
1160
+ const { start, end } = visibleTimeRange
1161
+ const out: TimeWindow[] = []
1162
+ for (const g of gaps) {
1163
+ const fromMs = Math.max(g.fromMs, start)
1164
+ const toMs = Math.min(g.toMs, end)
1165
+ if (toMs > fromMs) out.push({ fromMs, toMs })
1166
+ }
1167
+ return out
1168
+ }, [gaps, visibleTimeRange])
1169
+
1170
+ // Pin MOVES a row: pinned lanes (and pinned children inside groups) leave
1171
+ // the regular list entirely — the pinned section is their only home.
1172
+ const pinnedIdSetForFilter = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
1173
+ const pinnedAppKeys = useMemo(
1174
+ () => new Set((pinnedLanes ?? []).flatMap((p) => (p.type === 'appGroup' ? [p.appKey] : []))),
1175
+ [pinnedLanes],
1176
+ )
1177
+ const unpinnedLanes = useMemo(
1178
+ () => removePinnedLanes(stableOrderedLanes, pinnedIdSetForFilter, pinnedAppKeys),
1179
+ [stableOrderedLanes, pinnedIdSetForFilter, pinnedAppKeys],
1180
+ )
329
1181
 
330
1182
  // Filter out lanes with no events in the visible time window
331
1183
  const visibleLanes = useMemo(() => {
332
1184
  const { start, end } = visibleTimeRange
333
- return lanes.filter(lane => {
1185
+ return unpinnedLanes.filter(lane => {
334
1186
  const allLaneEvents = lane.allEventsSorted || []
335
1187
  return allLaneEvents.some(e => {
336
1188
  const t = new Date(e.timestamp).getTime()
337
1189
  return t >= start && t <= end
338
1190
  })
339
1191
  })
340
- }, [lanes, visibleTimeRange])
1192
+ }, [unpinnedLanes, visibleTimeRange])
1193
+
1194
+ // Pinned rows: resolved from the FULL lane list (pre-window-filter, any
1195
+ // grouping) so they stay put while the lens moves and even when they have no
1196
+ // events in the window. The regular list above has them removed (move, not
1197
+ // copy); counts add them back explicitly below.
1198
+ const pinnedLaneRows = useMemo(
1199
+ () => extractPinnedLanes(lanes, pinnedLanes ?? []),
1200
+ [lanes, pinnedLanes],
1201
+ )
1202
+ const effectivePinnedOnly = pinnedOnly && pinnedLaneRows.length > 0
1203
+ // Honest counts while filtered to pins: only pinned rows' in-window events.
1204
+ const pinnedEventsInWindow = useMemo(() => {
1205
+ if (pinnedLaneRows.length === 0) return 0
1206
+ const { start, end } = visibleTimeRange
1207
+ let n = 0
1208
+ for (const lane of pinnedLaneRows) {
1209
+ for (const e of lane.allEventsSorted || []) {
1210
+ const t = new Date(e.timestamp).getTime()
1211
+ if (t >= start && t <= end) n++
1212
+ }
1213
+ }
1214
+ return n
1215
+ }, [pinnedLaneRows, visibleTimeRange])
1216
+
1217
+ const pinnedIdSet = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
1218
+ // A pin button for a lane, or null when the host wired no pin handler. A pinned
1219
+ // lane's button is filled and always visible (in the pinned section or its
1220
+ // original spot); an unpinned one reveals on row hover.
1221
+ const renderPinButton = useCallback((lane: ResourceLane): React.ReactNode => {
1222
+ if (!onTogglePin) return null
1223
+ const pinned = pinnedIdSet.has(lane.id)
1224
+ const ref: PinnedLaneRef = lane.isAppGroup && lane.appKey
1225
+ ? { type: 'appGroup', id: lane.id, appKey: lane.appKey, appName: lane.title ?? lane.name }
1226
+ : { id: lane.id, kind: lane.kind, namespace: lane.namespace, name: lane.name }
1227
+ return (
1228
+ <PinButton
1229
+ pinned={pinned}
1230
+ alwaysVisible={pinned}
1231
+ isAppGroup={lane.isAppGroup}
1232
+ onToggle={() => onTogglePin(ref)}
1233
+ />
1234
+ )
1235
+ }, [onTogglePin, pinnedIdSet])
341
1236
 
342
1237
  // Generate time axis ticks
343
1238
  const axisTicks = useMemo(() => {
344
1239
  const { start, end } = visibleTimeRange
345
1240
  const ticks: { time: number; label: string }[] = []
346
-
347
- let intervalMs: number
348
- if (zoom <= 0.25) {
349
- intervalMs = 2 * 60 * 1000 // 2 min intervals for 15m window
350
- } else if (zoom <= 0.5) {
351
- intervalMs = 5 * 60 * 1000 // 5 min intervals for 30m window
352
- } else if (zoom <= 1) {
353
- intervalMs = 10 * 60 * 1000
354
- } else if (zoom <= 3) {
355
- intervalMs = 30 * 60 * 1000
356
- } else if (zoom <= 6) {
357
- intervalMs = 60 * 60 * 1000
358
- } else if (zoom <= 24) {
359
- intervalMs = 2 * 60 * 60 * 1000 // 2 hour intervals
360
- } else if (zoom <= 72) {
361
- intervalMs = 6 * 60 * 60 * 1000 // 6 hour intervals for up to 3 days
362
- } else {
363
- intervalMs = 24 * 60 * 60 * 1000 // 1 day intervals for larger windows
364
- }
1241
+ const span = end - start
1242
+ if (span <= 0) return ticks
1243
+
1244
+ // Pick the smallest "nice" interval that keeps the tick count under a cap —
1245
+ // so ANY window (a 15-minute view or a 2-year one) renders ~6–10 readable
1246
+ // ticks instead of hundreds smearing into a band. The ladder tops out at a
1247
+ // year; a wider window just steps by years.
1248
+ const MIN = 60 * 1000, HOUR = 60 * MIN, DAY = 24 * HOUR
1249
+ const NICE = [
1250
+ MIN, 2 * MIN, 5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN,
1251
+ HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR,
1252
+ DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY, 90 * DAY, 180 * DAY, 365 * DAY,
1253
+ ]
1254
+ const MAX_TICKS = 10
1255
+ const target = span / MAX_TICKS
1256
+ let intervalMs = NICE.find((n) => n >= target)
1257
+ ?? Math.ceil(target / (365 * DAY)) * 365 * DAY
365
1258
 
366
1259
  const firstTick = Math.ceil(start / intervalMs) * intervalMs
367
-
368
1260
  for (let t = firstTick; t <= end; t += intervalMs) {
369
- ticks.push({
370
- time: t,
371
- label: formatAxisTime(new Date(t)),
372
- })
1261
+ ticks.push({ time: t, label: formatAxisTime(new Date(t)) })
373
1262
  }
374
1263
 
375
1264
  return ticks
376
- }, [visibleTimeRange, zoom])
1265
+ }, [visibleTimeRange])
377
1266
 
378
1267
  // Convert timestamp to X position (0-100%)
379
1268
  const timeToX = useCallback(
380
1269
  (timestamp: number): number => {
381
1270
  const { start, windowMs } = visibleTimeRange
1271
+ // A zero-width window (host bounds with fromMs === toMs) would divide by
1272
+ // zero and emit NaN into `left:` CSS — pin everything to the left edge.
1273
+ if (windowMs <= 0) return 0
382
1274
  return ((timestamp - start) / windowMs) * 100
383
1275
  },
384
1276
  [visibleTimeRange]
385
1277
  )
386
1278
 
387
- // Predefined zoom levels (in hours): 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 2d, 3d, 7d
388
- const ZOOM_LEVELS = [0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168]
1279
+ // Vertical grid line positions (x-percent) shared by every lane backdrop.
1280
+ const gridXs = useMemo(
1281
+ () => axisTicks.map((t) => timeToX(t.time)).filter((x) => x > 0 && x < 100),
1282
+ [axisTicks, timeToX]
1283
+ )
389
1284
 
390
- // Zoom handlers - snap to predefined levels
391
- const handleZoomIn = () => setZoom((z) => {
392
- const idx = ZOOM_LEVELS.findIndex(level => level >= z)
393
- return ZOOM_LEVELS[Math.max(0, idx - 1)]
394
- })
395
- const handleZoomOut = () => setZoom((z) => {
396
- const idx = ZOOM_LEVELS.findIndex(level => level > z)
397
- return ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx === -1 ? ZOOM_LEVELS.length - 1 : idx)]
398
- })
1285
+ // Open the detail drawer for a marker or a cluster pill. A single-event cluster
1286
+ // opens today's one-event drawer (re-click toggles it closed). A multi-event
1287
+ // cluster opens the drawer in CLUSTER mode carrying every member, ordered
1288
+ // most-severe-first and preselecting the top one — that's exactly the event the
1289
+ // pill's glyph already promised, so the drawer opens on the resource the user
1290
+ // was looking at, with the other N-1 one click away. Re-clicking the same pill
1291
+ // toggles it closed.
1292
+ const openCluster = useCallback((cluster: TimelineEventCluster) => {
1293
+ // clusterDrawerState fixes the member ordering (dominant first) so the click
1294
+ // path and the URL-restore path can't drift; re-clicking the same cluster
1295
+ // toggles the drawer closed.
1296
+ const next = clusterDrawerState(cluster)
1297
+ const key = next.events.map((e) => e.id).join('|')
1298
+ const isToggleClose = !!drawer && drawer.events.map((e) => e.id).join('|') === key
1299
+ if (isToggleClose) {
1300
+ // Re-click on the open pill is a user close: record the dismissed id so the
1301
+ // restore effect doesn't resurrect it from the not-yet-stripped ?event=.
1302
+ dismissedIdRef.current = drawer!.selectedId
1303
+ setDrawer(null)
1304
+ return
1305
+ }
1306
+ // Opening (a new selection) clears any prior dismissal — the user intends to
1307
+ // see this drawer, so nothing must suppress it.
1308
+ dismissedIdRef.current = null
1309
+ setDrawer(next)
1310
+ }, [drawer])
1311
+
1312
+ // User close (X button / Escape). Records the dismissed id so the restore
1313
+ // effect treats the id still sitting in ?event= as dismissed, not as a fresh
1314
+ // deep-link to re-open. Cleared once the selection actually settles to null.
1315
+ const closeDrawer = useCallback(() => {
1316
+ if (drawer) dismissedIdRef.current = drawer.selectedId
1317
+ setDrawer(null)
1318
+ }, [drawer])
1319
+
1320
+ // --- Observable drawer selection (URL routing) ---------------------------
1321
+ // The rendered rows the resolver searches: pinned rows first, then the visible
1322
+ // lanes, matching render order so restore lands on the same row the user sees.
1323
+ // pinnedOnly hides the visible lanes entirely, so a restore must only search
1324
+ // rows that actually render — otherwise it lands on a hidden lane the user
1325
+ // can't see (the render below drops visibleLanes under effectivePinnedOnly).
1326
+ const resolverRows = useMemo(
1327
+ () => (effectivePinnedOnly ? pinnedLaneRows : [...pinnedLaneRows, ...visibleLanes]),
1328
+ [effectivePinnedOnly, pinnedLaneRows, visibleLanes],
1329
+ )
1330
+ // The id we've already resolved/ruled-out, so a settled miss reports null once.
1331
+ // Doubles as the guard's `settledId`: a fresh selectedEventId (never settled)
1332
+ // keeps restoreIsPending true so a still-loading restore wins over the strip.
1333
+ const restoredForRef = useRef<string | null>(null)
1334
+ // The last selectedEventId this effect observed, so it can tell a SELECTION
1335
+ // change (re-resolve the open drawer to it) from a DRAWER or window change
1336
+ // (leave an open drawer alone — a user opening a cluster, or a pan, is
1337
+ // authoritative and must not be re-resolved back to the prior selection).
1338
+ const prevSelRef = useRef<string | null>(selectedEventId ?? null)
1339
+
1340
+ useEffect(() => {
1341
+ if (!onSelectedEventChange) return
1342
+ const want = selectedEventId ?? null
1343
+ const selectionChanged = prevSelRef.current !== want
1344
+ prevSelRef.current = want
1345
+ // Clearing the selection re-arms the machine: an id ruled out once must be
1346
+ // resolvable again if the user re-selects it (ref is keyed by id, not reset).
1347
+ // The dismissed id is cleared here too: once the selection has settled to
1348
+ // null the close is complete, so a later back/forward to the same id is a
1349
+ // genuine restore, not the close echo.
1350
+ if (want == null) {
1351
+ restoredForRef.current = null
1352
+ dismissedIdRef.current = null
1353
+ return
1354
+ }
1355
+ // Skip only when the drawer already shows the EXACT wanted event; otherwise a
1356
+ // new external selection (host list click, back/forward nav) re-resolves the
1357
+ // drawer to it — including a different member of the same time-cluster, so its
1358
+ // selectedId follows — instead of the report effect reverting to the stale id.
1359
+ if (drawer && drawer.selectedId === want) return
1360
+ // Only re-resolve an OPEN drawer when the selection itself changed. If this
1361
+ // fire is from a drawer change (the user just opened a different cluster) or a
1362
+ // pan, leave it — otherwise it snaps the click back to the stale selection.
1363
+ if (drawer && !selectionChanged) return
1364
+ if (restoredForRef.current === want) return
1365
+ // A user close wins: the id the user just dismissed must not be reopened by
1366
+ // the ?event=<id> the strip hasn't cleared yet. Once the selection settles to
1367
+ // null (above) the dismissal clears, so a real re-select/history-nav restores.
1368
+ if (dismissedIdRef.current === want) return
1369
+ const resolved = resolveEventCluster(resolverRows, expandedLanes, want, timeToX)
1370
+ if (resolved) {
1371
+ restoredForRef.current = want
1372
+ setDrawer(resolved)
1373
+ return
1374
+ }
1375
+ // Not on any row yet — keep waiting while data is still loading; only give up
1376
+ // (strip the stale param, exactly once) after the fetch has settled.
1377
+ if (isLoading) return
1378
+ restoredForRef.current = want
1379
+ onSelectedEventChange(null)
1380
+ }, [selectedEventId, drawer, resolverRows, expandedLanes, timeToX, isLoading, onSelectedEventChange])
1381
+
1382
+ useEffect(() => {
1383
+ if (!onSelectedEventChange) return
1384
+ const want = selectedEventId ?? null
1385
+ if (restoreIsPending(want, drawer?.selectedId ?? null, restoredForRef.current, dismissedIdRef.current)) return
1386
+ const cur = drawer?.selectedId ?? null
1387
+ if (cur === want) return
1388
+ onSelectedEventChange(cur)
1389
+ }, [drawer, selectedEventId, onSelectedEventChange])
1390
+
1391
+ // Zoom handlers - snap to predefined levels. Controlled: emit the would-be
1392
+ // window (end-anchored, clamped to bounds); uncontrolled: mutate `zoom`.
1393
+ const handleZoomIn = () => {
1394
+ if (controlled) { onViewWindowChange?.(zoomWindowWithinBounds(viewWindow!, 'in', bounds)); return }
1395
+ setZoom((z) => {
1396
+ const idx = ZOOM_LEVELS.findIndex(level => level >= z)
1397
+ return ZOOM_LEVELS[Math.max(0, idx - 1)]
1398
+ })
1399
+ }
1400
+ const handleZoomOut = () => {
1401
+ if (controlled) { onViewWindowChange?.(zoomWindowWithinBounds(viewWindow!, 'out', bounds)); return }
1402
+ setZoom((z) => {
1403
+ const idx = ZOOM_LEVELS.findIndex(level => level > z)
1404
+ return ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx === -1 ? ZOOM_LEVELS.length - 1 : idx)]
1405
+ })
1406
+ }
399
1407
 
400
1408
  // Pan with mouse drag
401
1409
  const handleMouseDown = (e: React.MouseEvent) => {
402
1410
  if (e.button !== 0) return
403
1411
  setIsDragging(true)
404
- setDragStart({ x: e.clientX, offset: panOffset })
1412
+ setDragStart({ x: e.clientX, offset: panOffset, windowFrom: visibleTimeRange.start, windowTo: visibleTimeRange.end })
405
1413
  }
406
1414
 
407
1415
  const handleMouseMove = useCallback(
@@ -413,11 +1421,20 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
413
1421
  const { windowMs } = visibleTimeRange
414
1422
 
415
1423
  const timePerPixel = windowMs / containerWidth
416
- const newOffset = dragStart.offset - dx * timePerPixel
417
1424
 
1425
+ if (controlled) {
1426
+ // Mirror the uncontrolled shift (Δend = +dx·timePerPixel) as an absolute
1427
+ // window shift, clamped to bounds instead of the internal panOffset>=0 cap.
1428
+ const shift = dx * timePerPixel
1429
+ const next = { fromMs: dragStart.windowFrom + shift, toMs: dragStart.windowTo + shift }
1430
+ onViewWindowChange?.(bounds ? clampWindowToBounds(next, bounds) : next)
1431
+ return
1432
+ }
1433
+
1434
+ const newOffset = dragStart.offset - dx * timePerPixel
418
1435
  setPanOffset(Math.max(0, newOffset))
419
1436
  },
420
- [isDragging, dragStart, visibleTimeRange]
1437
+ [isDragging, dragStart, visibleTimeRange, controlled, bounds, onViewWindowChange]
421
1438
  )
422
1439
 
423
1440
  const handleMouseUp = useCallback(() => {
@@ -435,23 +1452,67 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
435
1452
  }
436
1453
  }, [isDragging, handleMouseMove, handleMouseUp])
437
1454
 
438
- // Wheel zoom - snap to predefined levels
439
- const handleWheel = useCallback((e: React.WheelEvent) => {
1455
+ // Wheel: ctrl/cmd = zoom CONTINUOUSLY (scale by the wheel delta, not the preset
1456
+ // ladder — the +/- buttons keep the round preset stops); horizontal scroll =
1457
+ // pan the view window, same motion as dragging the canvas. Attached as a NATIVE
1458
+ // non-passive listener (not React's onWheel, which is passive at the root)
1459
+ // so preventDefault actually blocks the browser's horizontal back/forward
1460
+ // overscroll gesture.
1461
+ const handleWheel = useCallback((e: WheelEvent) => {
440
1462
  if (e.ctrlKey || e.metaKey) {
441
1463
  e.preventDefault()
442
- setZoom((z) => {
443
- const currentIdx = ZOOM_LEVELS.findIndex(level => level >= z)
444
- const idx = currentIdx === -1 ? ZOOM_LEVELS.length - 1 : currentIdx
445
- if (e.deltaY > 0) {
446
- // Zoom out - go to next larger level
447
- return ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)]
448
- } else {
449
- // Zoom in - go to next smaller level
450
- return ZOOM_LEVELS[Math.max(0, idx - 1)]
451
- }
452
- })
1464
+ const factor = wheelZoomFactor(e.deltaY, e.deltaMode)
1465
+ if (controlled) {
1466
+ onViewWindowChange?.(zoomWindowContinuous(viewWindow!, factor, bounds))
1467
+ return
1468
+ }
1469
+ // Uncontrolled: `zoom` is a free hours value (rungs only snap in the
1470
+ // buttons), so scale it directly and clamp to the ladder's span.
1471
+ setZoom((z) => Math.max(ZOOM_LEVELS[0], Math.min(z * factor, ZOOM_LEVELS[ZOOM_LEVELS.length - 1])))
1472
+ return
453
1473
  }
454
- }, [])
1474
+
1475
+ // Horizontal gesture: trackpad swipe (|deltaX| dominates) or shift+wheel.
1476
+ const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY) || e.shiftKey
1477
+ if (!horizontal || !containerRef.current) return
1478
+ e.preventDefault()
1479
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
1480
+ const containerWidth = containerRef.current.clientWidth
1481
+ const shift = delta * (visibleTimeRange.windowMs / containerWidth)
1482
+ if (controlled) {
1483
+ const next = { fromMs: viewWindow!.fromMs + shift, toMs: viewWindow!.toMs + shift }
1484
+ onViewWindowChange?.(bounds ? clampWindowToBounds(next, bounds) : next)
1485
+ } else {
1486
+ setPanOffset((o) => Math.max(0, o - shift))
1487
+ }
1488
+ }, [controlled, viewWindow, bounds, onViewWindowChange, visibleTimeRange])
1489
+
1490
+ // Ref-stable indirection + isLoading in deps: the loading early-return means
1491
+ // the container doesn't exist on first mount, so the listener must (re)attach
1492
+ // once the real tree appears — without re-subscribing on every handler change.
1493
+ const wheelHandlerRef = useRef(handleWheel)
1494
+ wheelHandlerRef.current = handleWheel
1495
+ useEffect(() => {
1496
+ const el = containerRef.current
1497
+ if (!el) return
1498
+ const h = (e: WheelEvent) => wheelHandlerRef.current(e)
1499
+ el.addEventListener('wheel', h, { passive: false })
1500
+ return () => el.removeEventListener('wheel', h)
1501
+ }, [isLoading])
1502
+
1503
+ // Toolbar chip counts tell ONE story with the strip: they count
1504
+ // within the LOADED range (bounds) so "All" matches the strip's loaded total
1505
+ // — not the whole loaded ring, whose 3,5xx total came from nowhere the user
1506
+ // could see. Unbounded (uncontrolled local mode) keeps counting the ring.
1507
+ // MUST sit above the isLoading early return — hooks after it violate the
1508
+ // rules of hooks (React #310) the moment loading flips.
1509
+ const queryScopedEvents = useMemo(() => {
1510
+ if (!bounds) return events
1511
+ return events.filter((e) => {
1512
+ const t = new Date(e.timestamp).getTime()
1513
+ return t >= bounds.fromMs && t <= bounds.toMs
1514
+ })
1515
+ }, [events, bounds])
455
1516
 
456
1517
  if (isLoading) {
457
1518
  return <PaneLoader label="Loading timeline…" className="h-full w-full" />
@@ -460,476 +1521,976 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
460
1521
  // Compute empty state info (but don't early return - we need the toolbar visible)
461
1522
  const hasFilteredEvents = visibleLanes.length === 0 && events.length > 0 && filteredEvents.length === 0
462
1523
 
1524
+ // One lane row and — when expanded — its children, recursively. Shared by the
1525
+ // pinned section and the sorted lanes so the pinned copy reuses the exact track.
1526
+ //
1527
+ // Event attribution (the locked rule) is centralized in `laneTrackEvents`:
1528
+ // - a COLLAPSED parent paints its whole-subtree aggregate (the roll-up)
1529
+ // - an EXPANDED parent paints ONLY its own resource's events; each child
1530
+ // row then renders its OWN slice below it (recursing, so a child that owns
1531
+ // collapsed children of its own paints ITS aggregate).
1532
+ // So no event lands on two visible rows, and a resource that owns events in
1533
+ // the window can't render an empty track. This holds at every depth — a
1534
+ // CronJob member of an app group expands into its Job/Pod rows, each carrying
1535
+ // its own events instead of the ancestor swallowing them all.
1536
+ //
1537
+ // `keyPrefix` keeps a pinned copy's React key distinct from its original;
1538
+ // `depth` (0 = top level) drives the label style and child indentation.
1539
+ const renderLane = (lane: ResourceLane, keyPrefix = '', depth = 0, isLast = false): React.ReactNode => {
1540
+ const isExpanded = expandedLanes.has(lane.id)
1541
+ const hasChildren = !!lane.children?.length
1542
+ // When expanded, a parent renders only children that MOVE in the visible lens
1543
+ // — a child whose whole subtree sits outside the window would otherwise paint
1544
+ // an empty row. Same rule top-level lanes already follow (visibleLanes above).
1545
+ // Exemptions: a pinned child (its row is its home), a child the USER opened
1546
+ // (don't yank a row they deliberately expanded), and a structural app member
1547
+ // (a server-declared workload/Service/Ingress — hiding an app's own Service
1548
+ // makes a matched app read as incomplete). Auto-expanded children are NOT
1549
+ // exempt, so a stale auto-open falls away cleanly as the lens moves off it. The
1550
+ // chevron + count key off this visible set so "N resources" and "+N" match the
1551
+ // rows actually on screen; the collapsed roll-up already paints in-window only.
1552
+ const visibleChildren = hasChildren
1553
+ ? lane.children!.filter((child) =>
1554
+ isChildVisibleInWindow(child, visibleTimeRange.start, visibleTimeRange.end, {
1555
+ pinned: pinnedIdSet.has(child.id),
1556
+ userExpanded: userLaneOverrides.get(child.id) === true,
1557
+ }),
1558
+ )
1559
+ : []
1560
+ const hasVisibleChildren = visibleChildren.length > 0
1561
+ const trackEvents = laneTrackEvents(lane, isExpanded)
1562
+ const ownResource = { kind: lane.kind, namespace: lane.namespace, name: lane.name }
1563
+ // Show the group chip only when this lane collides with another visible lane
1564
+ // of a different API group (same kind+ns+name). The kind badge's title always
1565
+ // carries the full group (non-intrusive), collision or not.
1566
+ const showGroupChip = !lane.isAppGroup && !!lane.group && collidingKeys.has(laneCollisionKey(lane))
1567
+ // Tooltip carries the group only for CRD groups — built-in resources stay
1568
+ // visually untouched (the group appears nowhere for them, not even on hover).
1569
+ const kindBadgeTitle = !lane.isAppGroup && groupQualifiesLaneId(lane.group) ? `${displayKind(lane.kind)} · ${lane.group}` : undefined
1570
+ // An expanded APP header keeps a dimmed "ghost" aggregate sweep: the
1571
+ // rollup stays scannable while the children below carry the detail. Collapsed
1572
+ // parents/apps paint the full-strength aggregate. Expanded non-app parents
1573
+ // (a DaemonSet over its Pods) show their own events, not a rollup.
1574
+ const showGhostAggregate = hasVisibleChildren && isExpanded && lane.isAppGroup
1575
+ const track = (heightClass: string, small?: boolean) => (
1576
+ <LaneTrack
1577
+ className={clsx('flex-1 mr-8', heightClass)}
1578
+ events={trackEvents}
1579
+ ownResource={ownResource}
1580
+ // A collapsed parent/app-group row shows an AGGREGATE: sweep its members'
1581
+ // state families instead of blending latest-event-wins.
1582
+ aggregateLane={hasVisibleChildren && (!isExpanded || showGhostAggregate) ? lane : undefined}
1583
+ ghost={showGhostAggregate}
1584
+ startTime={visibleTimeRange.start}
1585
+ windowMs={visibleTimeRange.windowMs}
1586
+ now={visibleTimeRange.now}
1587
+ timeToX={timeToX}
1588
+ selectedEvent={selectedEvent}
1589
+ onSelectCluster={openCluster}
1590
+ small={small}
1591
+ gridXs={gridXs}
1592
+ />
1593
+ )
1594
+ const childRows = isExpanded && hasVisibleChildren && (
1595
+ <div
1596
+ className="bg-theme-surface/30"
1597
+ style={{ animation: 'swimlane-expand 250ms ease-out both' }}
1598
+ >
1599
+ {visibleChildren.map((child, idx) =>
1600
+ renderLane(child, keyPrefix, depth + 1, idx === visibleChildren.length - 1),
1601
+ )}
1602
+ </div>
1603
+ )
1604
+
1605
+ if (depth === 0) {
1606
+ return (
1607
+ // An app group is a block, not a row: full-strength top/bottom borders
1608
+ // set the app boundary apart from the hairline dividers between the
1609
+ // resource rows inside it (an expanded group's last child suppresses
1610
+ // its own border, so the block edge is the only closing line).
1611
+ <div key={keyPrefix + lane.id} className={clsx(lane.isAppGroup && 'border-y border-theme-border')}>
1612
+ {/* Parent lane */}
1613
+ <div className={lane.isAppGroup && !(isExpanded && hasVisibleChildren) ? undefined : 'border-b-subtle'}>
1614
+ <div className="flex items-center">
1615
+ {/* Lane label */}
1616
+ <div className={clsx('relative shrink-0 border-r border-theme-border px-3 flex items-center gap-1 group/pin', laneLabelWidthClass, compact ? 'py-1' : 'py-2')}>
1617
+ {/* Descender: when expanded, carry the tree line from this row's
1618
+ chevron down to its first child's incoming trunk (rail 0). */}
1619
+ {hasVisibleChildren && isExpanded && (
1620
+ <span className="absolute bottom-0 top-1/2 w-px bg-theme-border" style={{ left: TREE_ROOT_RAIL_PX }} />
1621
+ )}
1622
+ {/* Expand/collapse button */}
1623
+ {hasVisibleChildren ? (
1624
+ <button
1625
+ onClick={() => toggleLane(lane.id)}
1626
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
1627
+ className="p-1 -m-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
1628
+ >
1629
+ <ChevronRight className={clsx(
1630
+ 'w-4 h-4 transition-transform',
1631
+ isExpanded && 'rotate-90'
1632
+ )} />
1633
+ </button>
1634
+ ) : compact ? null : (
1635
+ <div className="w-4" />
1636
+ )}
1637
+ {lane.isAppGroup ? (
1638
+ <AppGroupLaneLabel lane={lane} memberCount={visibleChildren.length} onToggle={() => toggleLane(lane.id)} onAppClick={onAppClick} />
1639
+ ) : (
1640
+ /* Two-line owner lane: [ name (bold) · +N · ⚠ ] on row 1, then a
1641
+ quiet metadata row 2 [ kind · namespace ]. The name owns the
1642
+ full first line so it stops losing the width fight to the kind
1643
+ chip + namespace. Only the NAME navigates; the rest is inert. */
1644
+ <div className="flex-1 min-w-0 flex flex-col gap-0.5">
1645
+ <div className="flex items-center gap-1.5 min-w-0">
1646
+ <Tooltip content={lane.name} wrapperClassName="min-w-0 flex-1">
1647
+ <span
1648
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
1649
+ className={clsx('min-w-0 w-full text-sm text-theme-text-primary hover:text-accent-text hover:underline cursor-pointer', compact ? 'font-medium' : 'font-semibold font-mono')}
1650
+ >
1651
+ <MiddleEllipsis text={lane.name} className="block" />
1652
+ </span>
1653
+ </Tooltip>
1654
+ {hasVisibleChildren && (
1655
+ <span className="shrink-0 text-[11.5px] font-semibold text-theme-text-tertiary">
1656
+ {`+${visibleChildren.length}`}
1657
+ </span>
1658
+ )}
1659
+ <LaneWarnChip events={lane.allEventsSorted || []} />
1660
+ </div>
1661
+ <div className="flex items-center gap-1.5 min-w-0">
1662
+ <KindChip kind={lane.kind} title={kindBadgeTitle} />
1663
+ {showGroupChip && <GroupChip group={lane.group!} />}
1664
+ {lane.namespace && !compact && (
1665
+ <span className="min-w-0 truncate text-[11px] text-theme-text-tertiary">
1666
+ {lane.namespace}
1667
+ </span>
1668
+ )}
1669
+ </div>
1670
+ </div>
1671
+ )}
1672
+ {/* Pin toggle. Real lanes pin the resource; app-group headers pin
1673
+ the app (members re-resolve live). Navigation is scoped to the
1674
+ name text, so the pin sits inert alongside it. */}
1675
+ {renderPinButton(lane)}
1676
+ </div>
1677
+ {track('h-11')}
1678
+ </div>
1679
+ </div>
1680
+ {childRows}
1681
+ </div>
1682
+ )
1683
+ }
1684
+
1685
+ // Depth >= 1: an indented tree row. A row with its own children carries an
1686
+ // expand chevron; the connector's trunk stops half-way only on the last leaf.
1687
+ return (
1688
+ <div key={keyPrefix + lane.id} className={clsx('border-b-subtle', isLast && !(isExpanded && hasVisibleChildren) && 'border-b-0')}>
1689
+ <div className="flex">
1690
+ <ChildLaneLabel
1691
+ kind={lane.kind}
1692
+ group={lane.group}
1693
+ showGroupChip={showGroupChip}
1694
+ kindTitle={kindBadgeTitle}
1695
+ name={lane.name}
1696
+ labelWidthClass={laneLabelWidthClass}
1697
+ isLast={isLast && !(isExpanded && hasVisibleChildren)}
1698
+ depth={depth}
1699
+ hasChildren={hasVisibleChildren}
1700
+ expanded={isExpanded}
1701
+ onToggle={hasVisibleChildren ? () => toggleLane(lane.id) : undefined}
1702
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
1703
+ pinButton={renderPinButton(lane)}
1704
+ title={
1705
+ lane.nestedByContract ? `${lane.name} · linked by naming`
1706
+ : lane.matchedByName ? `${lane.name} · matched by name`
1707
+ : undefined
1708
+ }
1709
+ />
1710
+ {track('h-9', true)}
1711
+ </div>
1712
+ {childRows}
1713
+ </div>
1714
+ )
1715
+ }
1716
+
1717
+ // Empty-state block — shared by the no-rows-at-all case and the
1718
+ // pinned-rows-but-nothing-else case (rendered below the pinned section).
1719
+ const emptyState = (
1720
+ <div className="flex flex-col items-center justify-center h-64 text-theme-text-tertiary">
1721
+ <AlertCircle className="w-12 h-12 mb-4 opacity-50" />
1722
+ {windowInsideGap(viewWindow, gaps) ? (
1723
+ <>
1724
+ {/* View window fully inside a recording gap: empty ≠ quiet. */}
1725
+ <p className="text-lg">Nothing was recorded here — the connector was offline</p>
1726
+ <p className="text-sm mt-1">Pan or zoom out — or drag the blue band on the strip above — to a recorded period</p>
1727
+ </>
1728
+ ) : hasFilteredEvents ? (
1729
+ <>
1730
+ <p className="text-lg">No matching events</p>
1731
+ <p className="text-sm mt-1">
1732
+ {describeActiveFilters({ search: searchTerm, activityFilter, kindFilter, showDeleted }) || 'Try adjusting your filters'}
1733
+ </p>
1734
+ {namespaces && namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in: {namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</p>}
1735
+ <button
1736
+ type="button"
1737
+ onClick={clearAllFilters}
1738
+ className="mt-4 flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-lg text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary transition-colors"
1739
+ >
1740
+ <X className="w-3.5 h-3.5" />
1741
+ Clear filters
1742
+ </button>
1743
+ </>
1744
+ ) : viewWindow && events.length > 0 ? (
1745
+ <>
1746
+ {/* Controlled window over loaded data: quiet ≠ waiting-for-live. Name
1747
+ the exact count sitting outside the window — the strip's bars show
1748
+ where, so point there instead of guessing at "a busier period". */}
1749
+ <p className="text-lg">Nothing changed in this view</p>
1750
+ <p className="text-sm mt-1">Only resources with activity are shown</p>
1751
+ <p className="text-sm mt-1">
1752
+ {pluralize(events.length, 'event')} elsewhere in the query range — move the window on the strip above to see them
1753
+ </p>
1754
+ </>
1755
+ ) : (
1756
+ <>
1757
+ <p className="text-lg">No events yet</p>
1758
+ <p className="text-sm mt-1">Events will appear here as resources change</p>
1759
+ {namespaces && namespaces.length > 0 && (
1760
+ <p className="text-sm mt-2 text-theme-text-secondary">
1761
+ Filtering by namespace: <span className="font-medium text-theme-text-primary">{namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</span>
1762
+ </p>
1763
+ )}
1764
+ {hasLimitedAccess && (
1765
+ <p className="flex items-center gap-1 text-sm mt-2 text-amber-400/80">
1766
+ <Shield className="w-3.5 h-3.5" />
1767
+ Some resource types are not monitored due to RBAC restrictions
1768
+ </p>
1769
+ )}
1770
+ </>
1771
+ )}
1772
+ </div>
1773
+ )
1774
+
463
1775
  return (
464
- <div className="flex flex-col h-full w-full">
465
- {/* Toolbar with search and zoom */}
466
- <div className="border-b border-theme-border bg-theme-surface/30 overflow-hidden">
467
- <div className="flex items-center justify-between px-4 py-2">
468
- <div className="flex items-center gap-4">
469
- {/* Search */}
470
- <SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="swimlane-search" className="w-80" />
471
- {/* Zoom controls */}
472
- <div className="flex items-center gap-2">
1776
+ // Compact sizes to its content (up to the lane area's cap) so a workload with
1777
+ // two lanes doesn't reserve a full pane; the standalone view fills its parent.
1778
+ <div className={clsx('flex flex-col w-full', compact ? 'min-h-0' : 'h-full')}>
1779
+ {/* Toolbar with search and zoom. Hidden in compact mode (embedded
1780
+ single-subject swimlane) — the power-user controls are overkill there. */}
1781
+ {/* No overflow-hidden here: the toolbar's kinds/view-options popovers must overhang. */}
1782
+ {!compact && (
1783
+ <div className="border-b border-theme-border bg-theme-surface/30 relative z-40">
1784
+ <TimelineToolbar
1785
+ search={searchTerm}
1786
+ onSearchChange={setSearchTerm}
1787
+ searchShortcutId="swimlane-search"
1788
+ activityFilter={activityFilter}
1789
+ onActivityFilterChange={setActivityFilter}
1790
+ events={queryScopedEvents}
1791
+ showDeleted={showDeleted}
1792
+ onShowDeletedChange={setShowDeleted}
1793
+ kindFilter={kindFilter}
1794
+ onKindFilterChange={setKindFilter}
1795
+ kindOptions={kindOptions}
1796
+ counts={effectivePinnedOnly
1797
+ ? { resources: pinnedLaneRows.length, events: pinnedEventsInWindow }
1798
+ : { resources: visibleLanes.length + pinnedLaneRows.length, events: eventsInWindow.length }}
1799
+ pinnedCount={pinnedLaneRows.length}
1800
+ pinnedOnly={effectivePinnedOnly}
1801
+ onPinnedOnlyChange={setPinnedOnly}
1802
+ countsFiltered={!!searchTerm || activityFilter.length > 0 || kindFilter.length > 0}
1803
+ view={viewMode}
1804
+ onViewChange={onViewModeChange}
1805
+ viewOptions={{
1806
+ sort: { value: sort, onChange: setSort },
1807
+ grouping: { value: grouping, onChange: setGrouping },
1808
+ }}
1809
+ legend={{ shown: showLegend, onToggle: () => setShowLegend((v) => !v) }}
1810
+ />
1811
+ {/* Swimlane strip controls (local mode only): zoom + "→ Now" sit next to
1812
+ the timeline strip. Retained/controlled mode owns the window through
1813
+ the scrubber (lens-width chip = zoom, live/paused chip = "→ Now"), so
1814
+ this whole row is hidden there. */}
1815
+ {!controlled && (
1816
+ <div className="flex items-center gap-2 px-4 py-1.5">
1817
+ <Tooltip content="Zoom in (Ctrl+scroll)">
473
1818
  <button
474
1819
  onClick={handleZoomIn}
1820
+ aria-label="Zoom in"
475
1821
  className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
476
- title="Zoom in (Ctrl+scroll)"
477
1822
  >
478
1823
  <ZoomIn className="w-4 h-4" />
479
1824
  </button>
1825
+ </Tooltip>
1826
+ <Tooltip content="Zoom out (Ctrl+scroll)">
480
1827
  <button
481
1828
  onClick={handleZoomOut}
1829
+ aria-label="Zoom out"
482
1830
  className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
483
- title="Zoom out (Ctrl+scroll)"
484
1831
  >
485
1832
  <ZoomOut className="w-4 h-4" />
486
1833
  </button>
487
- <span className="text-xs text-theme-text-tertiary">
488
- {zoom < 1 ? `${Math.round(zoom * 60)}m` : zoom >= 24 ? `${Math.round(zoom / 24)}d` : `${zoom}h`} window
489
- </span>
490
- {panOffset > 0 && (
491
- <button
492
- onClick={() => setPanOffset(0)}
493
- className="px-2 py-1 text-xs text-accent-text hover:underline hover:bg-theme-elevated rounded"
494
- title="Jump to current time"
495
- >
496
- → Now
497
- </button>
498
- )}
499
- </div>
500
- {/* Sort by latest */}
501
- <button
502
- onClick={handleRefreshSort}
503
- className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
504
- title="Re-sort by importance"
505
- >
506
- <ArrowUpDown className="w-3.5 h-3.5" />
507
- Sort
508
- </button>
509
- </div>
510
- <div className="flex items-center gap-4">
1834
+ </Tooltip>
511
1835
  <span className="text-xs text-theme-text-tertiary">
512
- {pluralize(visibleLanes.length, 'resource')} · {pluralize(filteredEvents.length, 'event')}
513
- {searchTerm && ` (filtered)`}
1836
+ {windowLabel} window
514
1837
  </span>
515
- {/* Group by app toggle */}
516
- <Tooltip content="Group related resources (Deployment, Service, Pod) by their app.kubernetes.io/name label" position="bottom">
517
- <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
518
- <input
519
- type="checkbox"
520
- checked={groupByApp}
521
- onChange={(e) => setGroupByApp(e.target.checked)}
522
- className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
523
- />
524
- <span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
525
- </label>
526
- </Tooltip>
527
- <Tooltip content="Show resources Radar observed being deleted, including Pods that no longer exist" position="bottom">
528
- <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
529
- <input
530
- type="checkbox"
531
- checked={showDeleted}
532
- onChange={(e) => setShowDeleted(e.target.checked)}
533
- className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
534
- />
535
- <Trash2 className="w-3.5 h-3.5" />
536
- <span className="border-b border-dotted border-theme-text-tertiary">Deleted</span>
537
- </label>
538
- </Tooltip>
539
- {/* View toggle */}
540
- {onViewModeChange && (
541
- <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
1838
+ {showJumpToNow && (
1839
+ <Tooltip content="Jump to current time">
542
1840
  <button
543
- onClick={() => onViewModeChange('list')}
544
- className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded-md transition-colors ${
545
- viewMode === 'list' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
546
- }`}
547
- >
548
- <List className="w-3.5 h-3.5" />
549
- List
550
- </button>
551
- <button
552
- onClick={() => onViewModeChange('swimlane')}
553
- className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded-md transition-colors ${
554
- viewMode === 'swimlane' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
555
- }`}
1841
+ onClick={handleJumpToNow}
1842
+ className="px-2 py-1 text-xs text-accent-text hover:underline hover:bg-theme-elevated rounded"
556
1843
  >
557
- <GanttChart className="w-3.5 h-3.5" />
558
- Timeline
1844
+ → Now
559
1845
  </button>
560
- </div>
1846
+ </Tooltip>
561
1847
  )}
562
1848
  </div>
563
- </div>
564
- {/* Legend */}
565
- <div className="flex flex-wrap items-center gap-3 px-4 pb-2 text-xs text-theme-text-secondary">
566
- <LegendItem color="bg-green-500" label="created" description="Resource was created" />
567
- <LegendItem color="bg-blue-500" label="modified" description="Resource was updated/changed" />
568
- <LegendItem color="bg-red-500" label="deleted" description="Resource was removed" />
569
- <LegendItem color="bg-amber-500" label="warning" description="Warning event (CrashLoopBackOff, Failed, etc.)" />
570
- <LegendItem color="bg-theme-text-tertiary" label="historical" description="Inferred from resource metadata (creation time, etc.)" dashed />
571
- <span className="w-px h-3 bg-theme-border-light mx-1" />
572
- <HealthBarLegendItem color="bg-green-500/60 dark:bg-green-600/60" label="healthy" description="Resource is fully operational" />
573
- <HealthBarLegendItem color="bg-blue-500/60 dark:bg-blue-500/60" label="rolling" description="Expected degradation during deployment rollout" />
574
- <HealthBarLegendItem color="bg-amber-500/60 dark:bg-[#b8861e]" label="degraded" description="Unexpected partial availability" />
575
- <HealthBarLegendItem color="bg-red-500/60 dark:bg-red-500/60" label="unhealthy" description="Resource is failing or not ready" />
576
- <HealthBarLegendItem color="bg-sky-500/60 dark:bg-sky-500/60" label="idle" description="Intentionally off/resting (completed, suspended, scaled to zero)" />
577
- </div>
1849
+ )}
1850
+ {/* Legend: an on-demand popover, not a permanent strip. Two labelled
1851
+ sections — point-in-time event markers and lane-health bars — each a
1852
+ 2-column grid. Toggled by the toolbar's Legend button. */}
1853
+ {showLegend && (
1854
+ <>
1855
+ <div className="fixed inset-0 z-40" onClick={() => setShowLegend(false)} aria-hidden />
1856
+ <div className="absolute right-4 top-full z-50 mt-1 w-[280px] rounded-xl border border-theme-border bg-theme-surface p-4 shadow-theme-lg">
1857
+ <div className="text-[10px] font-bold uppercase leading-none tracking-[0.07em] text-theme-text-tertiary">Events</div>
1858
+ <div className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-theme-text-secondary">
1859
+ {/* One activity hue: shape carries the event class. */}
1860
+ <MarkerLegendItem shape="triangle-up" colorClass="text-blue-600 dark:text-blue-400" label="created" description="Resource was created" />
1861
+ <MarkerLegendItem shape="circle" colorClass="text-blue-600 dark:text-blue-400" label="modified" description="Resource change or informational Kubernetes event" />
1862
+ <MarkerLegendItem shape="triangle-down" colorClass="text-blue-600 dark:text-blue-400" label="deleted" description="Resource was removed" />
1863
+ <MarkerLegendItem shape="diamond" colorClass="text-amber-500 dark:text-amber-400" label="warning" description="Warning event (CrashLoopBackOff, Failed, etc.)" />
1864
+ <MarkerLegendItem shape="ring" colorClass="text-theme-text-tertiary" label="historical" description="Inferred from resource metadata (creation time, etc.)" />
1865
+ <ClusterLegendItem description="Nearby events collapsed into one pill" />
1866
+ </div>
1867
+ <div className="mt-3.5 text-[10px] font-bold uppercase leading-none tracking-[0.07em] text-theme-text-tertiary">Health</div>
1868
+ <div className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-theme-text-secondary">
1869
+ {/* Exactly three status colors; everything else is gray. */}
1870
+ <HealthLegendItem color={HEALTH_STRIP_COLORS.healthy} label="healthy" description="Resource is fully operational" />
1871
+ <HealthLegendItem color={HEALTH_STRIP_COLORS.degraded} label="degraded" description="Unexpected partial availability" />
1872
+ <HealthLegendItem color={HEALTH_STRIP_COLORS.unhealthy} label="unhealthy" description="Resource is failing or not ready" />
1873
+ <HealthLegendItem color="bg-gray-400/50" label="no signal" description="Not a health state: rolling out, idle/suspended, or nothing observed yet" />
1874
+ <HealthLegendItem label="mixed" swatchStyle={MIXED_HEALTH_STRIP_STYLE} description="members disagree — some OK, some degraded; expand or hover for who" />
1875
+ </div>
1876
+ </div>
1877
+ </>
1878
+ )}
578
1879
  </div>
1880
+ )}
579
1881
 
580
- {/* Timeline container */}
581
- <div className="flex-1 overflow-y-auto overflow-x-hidden">
1882
+ {/* Timeline container. Compact caps the lane area (content-height until then,
1883
+ scrolls past it) so the swimlane grows with the number of lanes. */}
1884
+ <div className={clsx('overflow-y-auto overflow-x-hidden', compact ? 'max-h-[240px]' : 'flex-1')}>
582
1885
  <div
583
1886
  ref={containerRef}
584
1887
  className="min-w-full"
585
1888
  onMouseDown={handleMouseDown}
586
- onWheel={handleWheel}
587
1889
  style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
588
1890
  >
589
1891
  {/* Time axis header */}
590
1892
  <div className="sticky top-0 z-30 bg-theme-surface border-b border-theme-border">
591
1893
  <div className="flex">
592
- <div className="w-80 shrink-0 border-r border-theme-border px-3 py-2">
593
- <span className="text-xs font-medium text-theme-text-secondary">Resource</span>
1894
+ <div className={clsx('shrink-0 border-r border-theme-border px-3 h-8 flex items-center', laneLabelWidthClass)}>
1895
+ <span className="text-[11px] font-semibold uppercase tracking-[0.1em] text-theme-text-tertiary">Resource</span>
1896
+ {/* Single morphing toggle: ≥half the groups open → offers
1897
+ collapse-all; otherwise expand-all. Tooltip names the action
1898
+ and its shortcut. ml-auto rides the Tooltip's own flex wrapper
1899
+ so the button stays vertically centered (a plain span around it
1900
+ inflates to the line-height and top-aligns the glyph). */}
1901
+ {/* Flat compact lanes never nest, so bulk expand/collapse is a no-op. */}
1902
+ {!compact && (
1903
+ <Tooltip content={mostlyExpanded ? 'Collapse all (E)' : 'Expand all (E)'} wrapperClassName="ml-auto">
1904
+ <button
1905
+ type="button"
1906
+ onClick={() => setAllExpanded(!mostlyExpanded)}
1907
+ className="rounded p-1 text-theme-text-tertiary hover:bg-theme-elevated hover:text-theme-text-primary"
1908
+ aria-label={mostlyExpanded ? 'Collapse all resources' : 'Expand all resources'}
1909
+ >
1910
+ {mostlyExpanded
1911
+ ? <ChevronsDownUp className="h-3.5 w-3.5" />
1912
+ : <ChevronsUpDown className="h-3.5 w-3.5" />}
1913
+ </button>
1914
+ </Tooltip>
1915
+ )}
594
1916
  </div>
595
1917
  <div className="flex-1 relative h-8 mr-8">
596
- {axisTicks.map((tick) => {
597
- const x = timeToX(tick.time)
598
- if (x < 0 || x > 100) return null
599
- return (
600
- <div
601
- key={tick.time}
602
- className="absolute top-0 bottom-0 flex flex-col items-center"
603
- style={{ left: `${x}%` }}
604
- >
605
- <div className="h-2 w-px bg-theme-hover" />
606
- <span className="text-xs text-theme-text-tertiary mt-0.5">{tick.label}</span>
607
- </div>
608
- )
609
- })}
610
- {/* "Now" marker in header */}
611
1918
  {(() => {
612
- const nowX = timeToX(visibleTimeRange.now)
613
- if (nowX < 0 || nowX > 100) return null
1919
+ // Clamp in live mode: a data load can push `now` past the
1920
+ // still-latched window for one tick; pinning to the right edge
1921
+ // keeps the marker steady instead of blinking on every refresh.
1922
+ const rawNowX = timeToX(visibleTimeRange.now)
1923
+ const nowX = isLive ? Math.min(rawNowX, 100) : rawNowX
1924
+ const nowVisible = nowX >= 0 && nowX <= 100
614
1925
  return (
615
- <div
616
- className="absolute top-0 bottom-0 flex flex-col items-center z-20"
617
- style={{ left: `${nowX}%` }}
618
- >
619
- <div className="h-2 w-0.5 bg-purple-500" />
620
- <span className="text-xs text-purple-500 font-medium mt-0.5">Now</span>
621
- </div>
1926
+ <>
1927
+ {axisTicks.map((tick) => {
1928
+ const x = timeToX(tick.time)
1929
+ // Left cull at 1.5% (not 0): a centered label hugging the
1930
+ // window start would bleed over the Resource column
1931
+ // header. Also cull the tick whose label would collide
1932
+ // with the "Now" label at the live edge.
1933
+ if (x < (atPastEdge ? 3.5 : 1.5)) return null
1934
+ if (x > (atFutureEdge ? 96.5 : 100)) return null
1935
+ if (nowVisible && Math.abs(x - nowX) < 4) return null
1936
+ return (
1937
+ <div
1938
+ key={tick.time}
1939
+ // -translate-x-1/2 centers the tick+label ON the time
1940
+ // position; positioned by the left edge, every label's
1941
+ // visual center sat half a label to the right of the
1942
+ // gridline (and "Now" right of the Now line).
1943
+ className="absolute top-0 bottom-0 flex -translate-x-1/2 flex-col items-center"
1944
+ style={{ left: `${x}%` }}
1945
+ >
1946
+ <div className="h-2 w-px bg-theme-hover" />
1947
+ <span className="mt-0.5 whitespace-nowrap text-xs text-theme-text-tertiary">{tick.label}</span>
1948
+ </div>
1949
+ )
1950
+ })}
1951
+ {/* "Now" marker in header — the label right-aligns to the
1952
+ line at the edge so it never spills into the gutter. */}
1953
+ {nowVisible && (
1954
+ <div
1955
+ className={clsx(
1956
+ 'absolute top-0 bottom-0 z-20 flex flex-col',
1957
+ nowX > 97 ? '-translate-x-full items-end' : '-translate-x-1/2 items-center',
1958
+ )}
1959
+ style={{ left: `${nowX}%` }}
1960
+ >
1961
+ <div className={clsx('h-2 w-0.5 bg-purple-500', nowX > 97 && 'translate-x-0.5')} />
1962
+ <span className="mt-0.5 whitespace-nowrap text-xs font-medium text-purple-500">Now</span>
1963
+ </div>
1964
+ )}
1965
+ </>
622
1966
  )
623
1967
  })()}
1968
+ {/* Extend affordances at the loaded-range edges. Stop propagation so
1969
+ clicking doesn't also start a pan drag on the container. */}
1970
+ {atPastEdge && (
1971
+ <Tooltip content="Start of loaded history — load earlier" position="bottom">
1972
+ <button
1973
+ type="button"
1974
+ onMouseDown={(e) => e.stopPropagation()}
1975
+ onClick={(e) => { e.stopPropagation(); onExtendRequest?.('past') }}
1976
+ className="absolute left-0 top-1/2 z-30 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded border border-theme-border bg-theme-elevated text-theme-text-secondary shadow-theme-sm transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
1977
+ aria-label="Extend the loaded range further into the past"
1978
+ >
1979
+ <ChevronLeft className="h-3 w-3" />
1980
+ </button>
1981
+ </Tooltip>
1982
+ )}
1983
+ {atFutureEdge && (
1984
+ <Tooltip content="End of loaded history — load more recent" position="bottom">
1985
+ <button
1986
+ type="button"
1987
+ onMouseDown={(e) => e.stopPropagation()}
1988
+ onClick={(e) => { e.stopPropagation(); onExtendRequest?.('future') }}
1989
+ className="absolute right-0 top-1/2 z-30 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded border border-theme-border bg-theme-elevated text-theme-text-secondary shadow-theme-sm transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
1990
+ aria-label="Extend the loaded range further toward now"
1991
+ >
1992
+ <ChevronRight className="h-3 w-3" />
1993
+ </button>
1994
+ </Tooltip>
1995
+ )}
624
1996
  </div>
625
1997
  </div>
626
1998
  </div>
627
1999
 
628
- {/* Swimlanes or empty state */}
629
- {visibleLanes.length === 0 ? (
630
- <div className="flex flex-col items-center justify-center h-64 text-theme-text-tertiary">
631
- <AlertCircle className="w-12 h-12 mb-4 opacity-50" />
632
- {hasFilteredEvents ? (
633
- <>
634
- <p className="text-lg">No matching events</p>
635
- <p className="text-sm mt-1">
636
- {searchTerm ? `No results for "${searchTerm}"` : 'Try adjusting your filters'}
637
- </p>
638
- {namespaces && namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in: {namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</p>}
639
- </>
640
- ) : (
641
- <>
642
- <p className="text-lg">No events yet</p>
643
- <p className="text-sm mt-1">Events will appear here as resources change</p>
644
- {namespaces && namespaces.length > 0 && (
645
- <p className="text-sm mt-2 text-theme-text-secondary">
646
- Filtering by namespace: <span className="font-medium text-theme-text-primary">{namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</span>
647
- </p>
648
- )}
649
- {hasLimitedAccess && (
650
- <p className="flex items-center gap-1 text-sm mt-2 text-amber-400/80">
651
- <Shield className="w-3.5 h-3.5" />
652
- Some resource types are not monitored due to RBAC restrictions
653
- </p>
654
- )}
655
- </>
656
- )}
657
- </div>
2000
+ {/* Pinned section + swimlanes, or empty state. Pins always render above
2001
+ everything; the empty-state message still shows below them when the
2002
+ non-pinned window is quiet. */}
2003
+ {pinnedLaneRows.length === 0 && visibleLanes.length === 0 ? (
2004
+ emptyState
658
2005
  ) : (
659
2006
  <div className="relative">
660
- {/* "Now" line through swimlanes */}
2007
+ {/* "Now" line through swimlanes. In live mode a data load can push
2008
+ `now` past the still-latched window for one tick — clamp to the
2009
+ right edge instead of vanishing, or the line blinks/jumps on
2010
+ every refresh ("the Now line breaks as new data loads"). */}
661
2011
  {(() => {
662
- const nowX = timeToX(visibleTimeRange.now)
2012
+ const rawNowX = timeToX(visibleTimeRange.now)
2013
+ const nowX = isLive ? Math.min(rawNowX, 100) : rawNowX
663
2014
  if (nowX < 0 || nowX > 100) return null
664
2015
  return (
665
2016
  <div
666
2017
  className="absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-10 pointer-events-none"
667
- style={{ left: `calc(320px + (100% - 320px - 32px) * ${nowX / 100})` }}
2018
+ style={{ left: `calc(${laneLabelPx}px + (100% - ${laneTrackInsetPx}px) * ${nowX / 100})` }}
668
2019
  />
669
2020
  )
670
2021
  })()}
671
- {visibleLanes.map((lane) => {
672
- const isExpanded = expandedLanes.has(lane.id)
673
- const hasChildren = lane.children && lane.children.length > 0
674
-
2022
+ {/* Recording-gap bands behind the lanes — same hatch language as the
2023
+ strip, inert, captioned when wide enough. */}
2024
+ {visibleGaps.map((g, i) => {
2025
+ const fromFrac = Math.max(0, Math.min(1, timeToX(g.fromMs) / 100))
2026
+ const toFrac = Math.max(0, Math.min(1, timeToX(g.toMs) / 100))
2027
+ if (toFrac <= fromFrac) return null
2028
+ const widthPx = trackPx * (toFrac - fromFrac)
675
2029
  return (
676
- <div key={lane.id}>
677
- {/* Parent lane */}
678
- <div className="border-b-subtle">
679
- <div className="flex">
680
- {/* Lane label */}
681
- <div className="w-80 shrink-0 border-r border-theme-border px-3 py-2 flex items-center gap-1">
682
- {/* Expand/collapse button */}
683
- {hasChildren ? (
684
- <button
685
- onClick={() => toggleLane(lane.id)}
686
- className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
687
- >
688
- <ChevronRight className={clsx(
689
- 'w-3 h-3 transition-transform',
690
- isExpanded && 'rotate-90'
691
- )} />
692
- </button>
693
- ) : (
694
- <div className="w-4" />
695
- )}
696
- <div
697
- className="flex-1 min-w-0 cursor-pointer hover:bg-theme-surface/30 rounded px-1 -mx-1 group"
698
- onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
699
- >
700
- <div className="flex items-center gap-1">
701
- <span className={clsx(
702
- 'text-xs px-1 py-0.5 rounded',
703
- lane.isWorkload ? 'bg-accent-muted text-accent-text' : 'bg-theme-elevated text-theme-text-secondary'
704
- )}>
705
- {displayKind(lane.kind)}
706
- </span>
707
- {hasChildren && (
708
- <span className="text-xs text-theme-text-tertiary">
709
- +{lane.children!.length}
710
- </span>
711
- )}
712
- {/* Issue count badge */}
713
- {(() => {
714
- const allEvents = lane.allEventsSorted || []
715
- const issueCount = allEvents.filter(e => isCriticalIssue(e)).length
716
- if (issueCount === 0) return null
717
- return (
718
- <Tooltip content={`${pluralize(issueCount, 'critical issue')} (OOMKilled, CrashLoopBackOff, etc.)`} position="top">
719
- <span className="flex items-center gap-0.5 text-xs px-1 py-0.5 rounded bg-red-500/15 text-red-600 dark:text-red-300">
720
- <AlertTriangle className="w-3 h-3" />
721
- {issueCount}
722
- </span>
723
- </Tooltip>
724
- )
725
- })()}
726
- </div>
727
- <div className="text-sm text-theme-text-primary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
728
- {lane.name}
729
- </div>
730
- <div className="text-xs text-theme-text-tertiary">{lane.namespace}</div>
731
- </div>
732
- </div>
733
-
734
- {/* Events track - ALWAYS shows all events (summary view) */}
735
- <div className="flex-1 relative h-12 mr-8">
736
- {/* Health bar background layer */}
737
- <HealthBarTrack
738
- events={lane.allEventsSorted || []}
739
- startTime={visibleTimeRange.start}
740
- windowMs={visibleTimeRange.windowMs}
741
- now={visibleTimeRange.now}
742
- />
743
- {/* Event markers layer (on top of health bars) */}
744
- <div className="absolute inset-0 z-10">
745
- {/* All events combined: own + children, pre-sorted in memo so important events render on top */}
746
- {(lane.allEventsSorted || []).map((event, eventIdx) => {
747
- const x = timeToX(new Date(event.timestamp).getTime())
748
- if (x < 0 || x > 100) return null
749
- return (
750
- <EventMarker
751
- key={`summary-${event.id}-${eventIdx}`}
752
- event={event}
753
- x={x}
754
- selected={selectedEvent?.id === event.id}
755
- onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
756
- />
757
- )
758
- })}
759
- </div>
760
- </div>
761
- </div>
762
- </div>
763
-
764
- {/* Child lanes (when expanded) - includes parent as first row */}
765
- {isExpanded && hasChildren && (
766
- <div
767
- className="border-l-2 border-accent/40 ml-3 bg-theme-surface/30"
768
- style={{ animation: 'swimlane-expand 250ms ease-out both' }}
769
- >
770
- {/* Parent's own events as first row (only if it has events) */}
771
- {lane.events.length > 0 && (
772
- <div className="border-b-subtle">
773
- <div className="flex">
774
- <div
775
- className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
776
- onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
777
- >
778
- <div className="flex-1 min-w-0">
779
- <div className="flex items-center gap-1">
780
- <span className="text-xs px-1 py-0.5 rounded bg-accent-muted text-accent-text">
781
- {displayKind(lane.kind)}
782
- </span>
783
- </div>
784
- <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
785
- {lane.name}
786
- </div>
787
- </div>
788
- </div>
789
- <div className="flex-1 relative h-10 mr-8">
790
- {/* Health bar background layer */}
791
- <HealthBarTrack
792
- events={lane.events}
793
- startTime={visibleTimeRange.start}
794
- windowMs={visibleTimeRange.windowMs}
795
- now={visibleTimeRange.now}
796
- />
797
- {/* Event markers layer */}
798
- <div className="absolute inset-0 z-10">
799
- {lane.events.map((event, eventIdx) => {
800
- const x = timeToX(new Date(event.timestamp).getTime())
801
- if (x < 0 || x > 100) return null
802
- return (
803
- <EventMarker
804
- key={`expanded-${event.id}-${eventIdx}`}
805
- event={event}
806
- x={x}
807
- selected={selectedEvent?.id === event.id}
808
- onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
809
- small
810
- />
811
- )
812
- })}
813
- </div>
814
- </div>
815
- </div>
816
- </div>
817
- )}
818
- {/* Children */}
819
- {lane.children!.map((child, idx) => (
820
- <div key={child.id} className={clsx(
821
- 'border-b-subtle',
822
- idx === lane.children!.length - 1 && 'border-b-0'
823
- )}>
824
- <div className="flex">
825
- {/* Child lane label - indented */}
826
- <div
827
- className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
828
- onClick={() => handleLaneOpen(child.kind, child.namespace, child.name, child.group)}
829
- >
830
- <div className="flex-1 min-w-0">
831
- <div className="flex items-center gap-1">
832
- <span className="text-xs px-1 py-0.5 rounded bg-theme-elevated/50 text-theme-text-secondary">
833
- {displayKind(child.kind)}
834
- </span>
835
- </div>
836
- <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
837
- {child.name}
838
- </div>
839
- </div>
840
- </div>
841
-
842
- {/* Child events track */}
843
- <div className="flex-1 relative h-10 mr-8">
844
- {/* Health bar background layer */}
845
- <HealthBarTrack
846
- events={child.events}
847
- startTime={visibleTimeRange.start}
848
- windowMs={visibleTimeRange.windowMs}
849
- now={visibleTimeRange.now}
850
- />
851
- {/* Event markers layer */}
852
- <div className="absolute inset-0 z-10">
853
- {child.events.map((event, eventIdx) => {
854
- const x = timeToX(new Date(event.timestamp).getTime())
855
- if (x < 0 || x > 100) return null
856
- return (
857
- <EventMarker
858
- key={`${child.id}-${event.id}-${eventIdx}`}
859
- event={event}
860
- x={x}
861
- selected={selectedEvent?.id === event.id}
862
- onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
863
- small
864
- />
865
- )
866
- })}
867
- </div>
868
- </div>
869
- </div>
870
- </div>
871
- ))}
872
- </div>
2030
+ <div
2031
+ key={`gap-${i}`}
2032
+ className="pointer-events-none absolute top-0 bottom-0 z-0 flex items-center justify-center overflow-hidden"
2033
+ style={{
2034
+ left: `calc(${laneLabelPx}px + (100% - ${laneTrackInsetPx}px) * ${fromFrac})`,
2035
+ width: `calc((100% - ${laneTrackInsetPx}px) * ${toFrac - fromFrac})`,
2036
+ background:
2037
+ 'repeating-linear-gradient(45deg, transparent 0, transparent 5px, var(--border-default) 5px, var(--border-default) 6px)',
2038
+ opacity: 0.5,
2039
+ }}
2040
+ data-testid="swimlane-gap"
2041
+ >
2042
+ {widthPx > GAP_LABEL_MIN_PX && (
2043
+ <span className="whitespace-nowrap rounded bg-theme-surface/70 px-1 text-[10px] uppercase tracking-wide text-theme-text-tertiary">
2044
+ connector offline
2045
+ </span>
873
2046
  )}
874
2047
  </div>
875
2048
  )
876
2049
  })}
2050
+ {/* PINNED — stationary rows above everything, in pin order, never
2051
+ re-ranked by interestingness. Same time axis as the lanes below. */}
2052
+ {pinnedLaneRows.length > 0 && (
2053
+ <div data-testid="timeline-pinned-section">
2054
+ <div className="flex">
2055
+ <div className={clsx('shrink-0 border-r border-theme-border px-3 py-1.5', laneLabelWidthClass)}>
2056
+ <span className="text-[11px] font-semibold uppercase tracking-[0.1em] text-theme-text-tertiary">Pinned</span>
2057
+ </div>
2058
+ <div className="flex-1" />
2059
+ </div>
2060
+ {pinnedLaneRows.map((lane) => renderLane(lane, 'pinned:'))}
2061
+ {/* Subtle divider separating the pinned vantage from the ranked lanes. */}
2062
+ <div className="border-b border-theme-border" />
2063
+ </div>
2064
+ )}
2065
+ {effectivePinnedOnly
2066
+ ? null
2067
+ : visibleLanes.length === 0
2068
+ // Pinned rows carrying in-window events ARE the content — the
2069
+ // regular section below is just the end of the list, and a list
2070
+ // doesn't caption its own end. The full empty states render only
2071
+ // when the whole canvas would otherwise be blank.
2072
+ ? (pinnedEventsInWindow > 0 ? null : emptyState)
2073
+ : (
2074
+ <>
2075
+ {visibleLanes.map((lane) => renderLane(lane))}
2076
+ {/* End-of-list caption: absence here means "nothing happened
2077
+ to it in this range", not "it doesn't exist". Same voice
2078
+ as the empty states. */}
2079
+ <div className="flex items-center justify-center gap-2 py-6 text-sm text-theme-text-tertiary">
2080
+ <Clock className="h-4 w-4 opacity-50" />
2081
+ <span>{compact ? 'Only resources with activity in this time span are shown' : 'The timeline shows only resources with activity in the loaded range'}</span>
2082
+ </div>
2083
+ </>
2084
+ )}
877
2085
  </div>
878
2086
  )}
879
2087
  </div>
880
2088
  </div>
881
2089
 
882
- {/* Event detail panel */}
883
- {selectedEvent && (
884
- <EventDetailPanel event={selectedEvent} onClose={() => setSelectedEvent(null)} onResourceClick={onResourceClick} />
2090
+ {/* Event detail drawer — single event, or a cluster's full member list. */}
2091
+ {/* The bottom detail drawer is suppressed in compact mode: the embedded
2092
+ host (workload detail) already shows the full event list, so selection is
2093
+ reported upward via onSelectedEventChange instead of opening a redundant
2094
+ second detail surface. */}
2095
+ {drawer && !compact && (
2096
+ <EventDetailPanel
2097
+ events={drawer.events}
2098
+ selectedId={drawer.selectedId}
2099
+ onSelectId={(id) => setDrawer((prev) => (prev ? { ...prev, selectedId: id } : prev))}
2100
+ onClose={closeDrawer}
2101
+ onResourceClick={onResourceClick}
2102
+ allEvents={filteredEvents}
2103
+ />
885
2104
  )}
886
2105
  </div>
887
2106
  )
888
2107
  }
889
2108
 
890
- // Legend item with hover tooltip
891
- interface LegendItemProps {
892
- color: string
893
- label: string
894
- description: string
895
- dashed?: boolean
2109
+ // Health-strip segment colors (solid, pinned to the lane's bottom edge).
2110
+ // Status is EXACTLY three colors: healthy green, degraded amber,
2111
+ // unhealthy red. Anything else — rolling, idle, neutral, unknown — is gray
2112
+ // ("not a health signal"). No fourth status color, ever.
2113
+ const HEALTH_STRIP_COLORS: Record<string, string> = {
2114
+ healthy: 'bg-green-500',
2115
+ rolling: 'bg-gray-400/50',
2116
+ degraded: 'bg-amber-500 dark:bg-[#b8861e]',
2117
+ unhealthy: 'bg-red-500',
2118
+ neutral: 'bg-gray-400/50',
2119
+ idle: 'bg-gray-400/50',
2120
+ }
2121
+
2122
+ function getHealthStripColor(health: string): string {
2123
+ return HEALTH_STRIP_COLORS[health] ?? 'bg-gray-400/50'
896
2124
  }
897
2125
 
898
- function LegendItem({ color, label, description, dashed }: LegendItemProps) {
2126
+ // Legend glyph item (event markers) with hover tooltip.
2127
+ function MarkerLegendItem({ shape, colorClass, label, description }: { shape: MarkerShape; colorClass: string; label: string; description: string }) {
899
2128
  return (
900
2129
  <Tooltip content={description} position="top">
901
- <span className="flex items-center gap-1 cursor-help">
902
- <span className={clsx(
903
- 'w-2 h-2 rounded-full',
904
- dashed ? 'border border-dashed border-current bg-transparent' : color
905
- )} />
906
- <span>{label}</span>
2130
+ <span className="flex items-center gap-1.5 cursor-help">
2131
+ <span className="inline-flex w-3 h-3 items-center justify-center">
2132
+ <MarkerGlyph shape={shape} size={11} className={colorClass} />
2133
+ </span>
2134
+ {/* leading-none: the default line-box's descender space drags the label's
2135
+ optical center below the glyph's — hug the text so centers agree. */}
2136
+ <span className="leading-none">{label}</span>
907
2137
  </span>
908
2138
  </Tooltip>
909
2139
  )
910
2140
  }
911
2141
 
912
- // Health bar legend item - shows a bar instead of a dot
913
- function HealthBarLegendItem({ color, label, description }: LegendItemProps) {
2142
+ // Legend sample for the "×N clustered" pill.
2143
+ function ClusterLegendItem({ description }: { description: string }) {
914
2144
  return (
915
2145
  <Tooltip content={description} position="top">
916
- <span className="flex items-center gap-1 cursor-help">
917
- <span className={clsx('w-4 h-2 rounded-sm', color)} />
918
- <span>{label}</span>
2146
+ <span className="flex items-center gap-1.5 cursor-help">
2147
+ <span className="inline-flex h-4 items-center gap-1 rounded-full border border-theme-border bg-theme-surface px-1.5">
2148
+ <MarkerGlyph shape="circle" size={8} className="text-blue-600 dark:text-blue-400" />
2149
+ <span className="text-[11px] font-semibold leading-none tabular-nums text-theme-text-primary">×5</span>
2150
+ </span>
2151
+ <span className="leading-none">clustered</span>
2152
+ </span>
2153
+ </Tooltip>
2154
+ )
2155
+ }
2156
+
2157
+ // Health strip legend item - shows a bar swatch.
2158
+ function HealthLegendItem({ color, label, description, swatchStyle }: { color?: string; label: string; description: string; swatchStyle?: React.CSSProperties }) {
2159
+ return (
2160
+ <Tooltip content={description} position="top">
2161
+ <span className="flex items-center gap-1.5 cursor-help">
2162
+ <span className={clsx('h-[7px] rounded-[2px]', color)} style={{ width: '18px', ...swatchStyle }} />
2163
+ <span className="leading-none">{label}</span>
2164
+ </span>
2165
+ </Tooltip>
2166
+ )
2167
+ }
2168
+
2169
+ // Amber warning-count chip (⚠ N). Red is reserved for "actually broken"
2170
+ // (that shows on the health bar); the ⚠ summary is a warning tone, so it's amber.
2171
+ function LaneWarnChip({ events }: { events: TimelineEvent[] }) {
2172
+ const issueCount = events.filter((e) => isCriticalIssue(e)).length
2173
+ if (issueCount === 0) return null
2174
+ return (
2175
+ <Tooltip content={`${pluralize(issueCount, 'problem')} (OOMKilled, CrashLoopBackOff, etc.)`} position="top">
2176
+ <span className="flex items-center gap-0.5 text-[11px] font-semibold px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-400">
2177
+ <AlertTriangle className="w-3 h-3" />
2178
+ {issueCount}
2179
+ </span>
2180
+ </Tooltip>
2181
+ )
2182
+ }
2183
+
2184
+ // Indented child lane label with an L-shaped tree connector. `depth` (>=1) steps
2185
+ // the indent + connector one rung right per level so a grandchild (a Pod under a
2186
+ // CronJob member) reads as nested, not sibling. When the child owns a subtree of
2187
+ // its own it carries an expand chevron so it can be drilled into. Only the name
2188
+ // text navigates to the resource; the chevron toggles; the rest of the row is inert.
2189
+ // Tree geometry. The depth-0 (top-level) expand chevron sits ~this many px from
2190
+ // the label's left edge (px-3 + the -m-0.5 p-1 button ≈ 22px). Every child rail
2191
+ // is derived from it so verticals align under the chevron directly above them.
2192
+ const TREE_ROOT_RAIL_PX = 22
2193
+ const CHILD_INDENT_STEP_PX = 22
2194
+ /** Chip label — the real Kind, verbatim (matches the list; no abbreviation,
2195
+ * no ALL-CAPS, no truncation). Kind-less events read as "Event". */
2196
+ export function chipKindLabel(kind: string): { label: string; abbreviated: boolean } {
2197
+ // Some cluster components emit events whose involvedObject has NO kind (e.g.
2198
+ // GKE's resource-tracker "BigQueryUpload" events) — label those as plain events.
2199
+ if (!kind) return { label: 'Event', abbreviated: false }
2200
+ return { label: kind, abbreviated: false }
2201
+ }
2202
+
2203
+ // The kind chip: neutral pill on the lane's second row (color budget —
2204
+ // identity is monochrome). Shows the real Kind in its normal PascalCase; the
2205
+ // name owns the first row so it doesn't lose the width fight to metadata.
2206
+ function KindChip({ kind, title }: { kind: string; title?: string }) {
2207
+ const { label } = chipKindLabel(kind)
2208
+ const tip = title ?? (kind ? undefined : 'Kubernetes event — its source object reports no kind')
2209
+ return (
2210
+ <Tooltip content={tip} disabled={!tip} wrapperClassName="shrink-0">
2211
+ <span
2212
+ className="rounded border border-theme-border-light bg-theme-elevated px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wide text-theme-text-secondary"
2213
+ aria-label={tip}
2214
+ >
2215
+ {label}
2216
+ </span>
2217
+ </Tooltip>
2218
+ )
2219
+ }
2220
+
2221
+ // Disambiguating group chip — the ONLY place a lane's API group surfaces
2222
+ // visually, shown solely when the lane collides with another visible lane of a
2223
+ // different group (same kind+ns+name; CAPI vs CNPG `Cluster`). Quiet styling so
2224
+ // it reads as a subtle qualifier next to the kind badge, not a status pill.
2225
+ function GroupChip({ group }: { group: string }) {
2226
+ return (
2227
+ <Tooltip content={`API group ${group}`} wrapperClassName="shrink-0">
2228
+ <span
2229
+ className="rounded bg-theme-hover px-1 py-px text-[10px] font-medium text-theme-text-tertiary ring-1 ring-inset ring-theme-border"
2230
+ aria-label={`API group ${group}`}
2231
+ >
2232
+ {group}
919
2233
  </span>
920
2234
  </Tooltip>
921
2235
  )
922
2236
  }
923
2237
 
924
- // Health bar track component that renders health spans as background
2238
+ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidthClass = 'w-[360px]', isLast, onClick, pinButton, title, depth = 1, hasChildren, expanded, onToggle }: { kind: string; group?: string; showGroupChip?: boolean; kindTitle?: string; name: string; labelWidthClass?: string; isLast: boolean; onClick: () => void; pinButton?: React.ReactNode; title?: string; depth?: number; hasChildren?: boolean; expanded?: boolean; onToggle?: () => void }) {
2239
+ // Tree rails: the INCOMING trunk sits under the parent's chevron (rail d-1), the
2240
+ // row's own chevron sits on its CHILDREN's rail (rail d). Deriving both from one
2241
+ // ROOT keeps every level's vertical aligned under the chevron above it.
2242
+ const parentRailPx = TREE_ROOT_RAIL_PX + (depth - 1) * CHILD_INDENT_STEP_PX
2243
+ const selfRailPx = TREE_ROOT_RAIL_PX + depth * CHILD_INDENT_STEP_PX
2244
+ const contentPadPx = selfRailPx + 14
2245
+ return (
2246
+ <div
2247
+ // width matches the top-level label column exactly (compact widens it) — a
2248
+ // narrower child width left the label column's right border ragged.
2249
+ className={clsx('relative shrink-0 border-r border-theme-border/50 pr-3 flex items-center gap-1.5 group/pin bg-theme-surface/40', labelWidthClass)}
2250
+ style={{ paddingLeft: contentPadPx }}
2251
+ >
2252
+ {/* Incoming trunk (under the parent's chevron) — elbow (half height) on the
2253
+ last child, full height otherwise so the next sibling connects. */}
2254
+ <span className={clsx('absolute top-0 w-px bg-theme-border', isLast ? 'h-1/2' : 'h-full')} style={{ left: parentRailPx }} />
2255
+ {/* Horizontal branch from the parent rail into this row. */}
2256
+ <span className="absolute top-1/2 h-px bg-theme-border" style={{ left: parentRailPx, width: selfRailPx - parentRailPx }} />
2257
+ {/* Descender: when expanded, carry the line down from this row's chevron to
2258
+ its first child's incoming trunk (which sits on selfRail). */}
2259
+ {hasChildren && expanded && (
2260
+ <span className="absolute bottom-0 top-1/2 w-px bg-theme-border" style={{ left: selfRailPx }} />
2261
+ )}
2262
+ {hasChildren && onToggle && (
2263
+ <button
2264
+ onClick={(e) => { e.stopPropagation(); onToggle() }}
2265
+ // Centered ON this row's rail. bg keeps the line from striking the glyph.
2266
+ className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded bg-theme-surface p-1 text-theme-text-tertiary hover:bg-theme-elevated hover:text-theme-text-primary"
2267
+ style={{ left: selfRailPx }}
2268
+ aria-label={expanded ? 'Collapse' : 'Expand'}
2269
+ >
2270
+ <ChevronRight className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} />
2271
+ </button>
2272
+ )}
2273
+ {/* Single-line child row: kind chip · name (middle-ellipsis). Colored chips
2274
+ are neutral. Only the NAME links to the resource. */}
2275
+ <KindChip kind={kind} title={kindTitle} />
2276
+ {showGroupChip && group && <GroupChip group={group} />}
2277
+ <Tooltip content={title ?? name} wrapperClassName="min-w-0 flex-1">
2278
+ <span
2279
+ onClick={onClick}
2280
+ className="min-w-0 w-full text-[13px] font-mono text-theme-text-secondary hover:text-accent-text hover:underline cursor-pointer"
2281
+ >
2282
+ <MiddleEllipsis text={name} className="block" />
2283
+ </span>
2284
+ </Tooltip>
2285
+ {pinButton}
2286
+ </div>
2287
+ )
2288
+ }
2289
+
2290
+ // Pin toggle on a lane label. stopPropagation so it doesn't trigger the label's
2291
+ // navigate-on-click. Filled + always visible when pinned; hover-reveal otherwise.
2292
+ function PinButton({ pinned, onToggle, alwaysVisible, isAppGroup }: { pinned: boolean; onToggle: () => void; alwaysVisible?: boolean; isAppGroup?: boolean }) {
2293
+ const title = isAppGroup
2294
+ ? (pinned ? 'Unpin app' : 'Pin app — keep all its rows visible while you pan and zoom')
2295
+ : (pinned ? 'Unpin' : 'Pin — keep this row visible while you pan and zoom')
2296
+ return (
2297
+ <Tooltip content={title} wrapperClassName="shrink-0">
2298
+ <button
2299
+ type="button"
2300
+ onClick={(e) => { e.stopPropagation(); onToggle() }}
2301
+ className={clsx(
2302
+ 'p-0.5 rounded hover:bg-theme-elevated transition-opacity',
2303
+ pinned ? 'text-accent-text opacity-100' : 'text-theme-text-tertiary hover:text-theme-text-primary',
2304
+ !pinned && !alwaysVisible && 'opacity-0 group-hover/pin:opacity-100',
2305
+ )}
2306
+ aria-label={isAppGroup ? (pinned ? 'Unpin app' : 'Pin app') : (pinned ? 'Unpin' : 'Pin')}
2307
+ >
2308
+ <Pin className={clsx('w-3.5 h-3.5', pinned && 'fill-current')} />
2309
+ </button>
2310
+ </Tooltip>
2311
+ )
2312
+ }
2313
+
2314
+ // App-group header label — an app roll-up over its member root lanes. Shows the
2315
+ // app name, an optional env chip, and the member count. Evidence rides the App
2316
+ // chip's tooltip (attaching it to the whole flex row would need a layout-breaking
2317
+ // wrapper).
2318
+ function AppGroupLaneLabel({ lane, memberCount, onToggle, onAppClick }: { lane: ResourceLane; memberCount: number; onToggle: () => void; onAppClick?: (appKey: string) => void }) {
2319
+ // A pinned app absent from the current view (owner/flat grouping or vanished)
2320
+ // is dimmed and reads its own honest tooltip.
2321
+ const dimmed = lane.absentPinnedApp
2322
+ const linkable = onAppClick && lane.appKey
2323
+ const tooltip = lane.absentPinnedApp
2324
+ ? 'app not present in the current view/grouping'
2325
+ : (lane.evidence || undefined)
2326
+ return (
2327
+ <div
2328
+ // Two-line app header: App chip · [ name (bold) · +N · ⚠ / ns ]. The
2329
+ // namespace drops to a quiet subtitle so the name gets the full first line;
2330
+ // the row toggles expand, only the name links to Applications.
2331
+ className="flex-1 min-w-0 flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-theme-surface/30"
2332
+ onClick={onToggle}
2333
+ >
2334
+ <Tooltip content={tooltip} disabled={!tooltip} wrapperClassName="shrink-0">
2335
+ <span
2336
+ className={clsx(
2337
+ 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset',
2338
+ dimmed
2339
+ ? 'bg-theme-hover text-theme-text-tertiary ring-theme-border'
2340
+ : 'bg-accent-muted text-accent-text ring-transparent',
2341
+ )}
2342
+ aria-label={tooltip}
2343
+ >
2344
+ <Layers className="w-3 h-3" />
2345
+ App
2346
+ </span>
2347
+ </Tooltip>
2348
+ <div className="min-w-0 flex-1 flex flex-col gap-0.5">
2349
+ <div className="flex items-center gap-1.5 min-w-0">
2350
+ <Tooltip content={linkable ? 'Open in Applications' : (lane.title ?? lane.name)} wrapperClassName="min-w-0 flex-1">
2351
+ <span
2352
+ className={clsx(
2353
+ 'min-w-0 w-full text-sm font-semibold',
2354
+ dimmed ? 'text-theme-text-secondary' : 'text-theme-text-primary',
2355
+ linkable && 'hover:text-accent-text hover:underline cursor-pointer',
2356
+ )}
2357
+ // Name click navigates to the app's Applications page — mirroring how a
2358
+ // resource name routes to its detail; the row body still toggles expand.
2359
+ onClick={linkable ? (e) => { e.stopPropagation(); onAppClick!(lane.appKey!) } : undefined}
2360
+ role={linkable ? 'button' : undefined}
2361
+ aria-label={linkable ? 'Open in Applications' : undefined}
2362
+ >
2363
+ <MiddleEllipsis text={lane.title ?? lane.name} className="block" />
2364
+ </span>
2365
+ </Tooltip>
2366
+ <span className="shrink-0 text-[11.5px] font-semibold text-theme-text-tertiary">
2367
+ {`+${memberCount}`}
2368
+ </span>
2369
+ <LaneWarnChip events={lane.allEventsSorted || []} />
2370
+ </div>
2371
+ {lane.namespace && (
2372
+ <span className="text-[11px] text-theme-text-tertiary truncate">{lane.namespace}</span>
2373
+ )}
2374
+ </div>
2375
+ </div>
2376
+ )
2377
+ }
2378
+
2379
+ export interface LaneTrackProps {
2380
+ /** Events attributed to this lane (already scoped/attributed by the caller). */
2381
+ events: TimelineEvent[]
2382
+ /** The lane's own resource identity — used to derive its health spans. */
2383
+ ownResource?: { kind: string; namespace: string; name: string }
2384
+ /** When set, paint the members' aggregate sweep instead of a single resource's
2385
+ * timeline (a collapsed parent / app-group rollup). */
2386
+ aggregateLane?: ResourceLane
2387
+ /** Dim + thin the aggregate to a ghost (an expanded app header's rollup). */
2388
+ ghost?: boolean
2389
+ startTime: number
2390
+ windowMs: number
2391
+ now: number
2392
+ timeToX: (ms: number) => number
2393
+ selectedEvent?: TimelineEvent | null
2394
+ onSelectCluster?: (cluster: TimelineEventCluster) => void
2395
+ small?: boolean
2396
+ /** Vertical grid-line x positions (0–100); omit for just the center guide. */
2397
+ gridXs?: number[]
2398
+ /** Extra classes for the track container (height, flex, right margin). */
2399
+ className?: string
2400
+ }
2401
+
2402
+ /**
2403
+ * One lane's timeline strip — the backdrop grid, the centered health bar, and the
2404
+ * event-dot markers riding on it. This is THE composable unit shared by the full
2405
+ * timeline page (`TimelineSwimlanes`) and the workload/app detail embed, so the
2406
+ * health-bar + dot rendering can never diverge between them again.
2407
+ */
2408
+ export function LaneTrack({
2409
+ events,
2410
+ ownResource,
2411
+ aggregateLane,
2412
+ ghost,
2413
+ startTime,
2414
+ windowMs,
2415
+ now,
2416
+ timeToX,
2417
+ selectedEvent = null,
2418
+ onSelectCluster,
2419
+ small,
2420
+ gridXs,
2421
+ className,
2422
+ }: LaneTrackProps) {
2423
+ return (
2424
+ <div className={clsx('relative', className)}>
2425
+ <LaneBackdrop gridXs={gridXs ?? []} />
2426
+ <HealthBarTrack
2427
+ events={events}
2428
+ ownResource={ownResource}
2429
+ aggregateLane={aggregateLane}
2430
+ ghost={ghost}
2431
+ startTime={startTime}
2432
+ windowMs={windowMs}
2433
+ now={now}
2434
+ />
2435
+ <LaneEventMarkers
2436
+ events={events}
2437
+ timeToX={timeToX}
2438
+ selectedEvent={selectedEvent}
2439
+ onSelectCluster={onSelectCluster ?? (() => {})}
2440
+ small={small}
2441
+ />
2442
+ </div>
2443
+ )
2444
+ }
2445
+
2446
+ // Lane backdrop: subtle vertical grid lines + a horizontal center guide.
2447
+ function LaneBackdrop({ gridXs }: { gridXs: number[] }) {
2448
+ return (
2449
+ <div className="absolute inset-0 z-0 pointer-events-none">
2450
+ {gridXs.map((x, i) => (
2451
+ <div key={i} className="absolute top-0 bottom-0 w-px bg-theme-border/40" style={{ left: `${x}%` }} />
2452
+ ))}
2453
+ <div className="absolute left-0 right-0 top-1/2 h-px bg-theme-border/40" />
2454
+ </div>
2455
+ )
2456
+ }
2457
+
2458
+ // The 'mixed' segment (members disagree): a green/amber HEALTH weave, not a grey
2459
+ // hatch (grey stripes read as "no data", not "busy"). Solid green means
2460
+ // everyone's fine, solid amber all degraded, and this weave = they disagree — so
2461
+ // the texture stays a health signal, distinct from the recording-gap hatch.
2462
+ const MIXED_HEALTH_STRIP_STYLE: React.CSSProperties = {
2463
+ backgroundImage:
2464
+ 'repeating-linear-gradient(115deg, var(--color-success) 0 5px, var(--color-warning) 5px 10px)',
2465
+ }
2466
+
925
2467
  interface HealthBarTrackProps {
2468
+ ownResource?: { kind: string; namespace: string; name: string }
926
2469
  events: TimelineEvent[]
2470
+ /** When set, the row is a collapsed parent / app group: sweep its members'
2471
+ * honest spans into state-family segments instead of blending its subtree
2472
+ * events into one latest-event-wins timeline. */
2473
+ aggregateLane?: ResourceLane
2474
+ /** Dim + thin the aggregate to a "ghost" — an expanded app header keeps the
2475
+ * rollup scannable without competing with the children below it. */
2476
+ ghost?: boolean
927
2477
  startTime: number
928
2478
  windowMs: number
929
2479
  now: number
930
2480
  }
931
2481
 
932
- function HealthBarTrack({ events, startTime, windowMs, now }: HealthBarTrackProps) {
2482
+ function HealthBarTrack({ events, startTime, windowMs, now, ownResource, aggregateLane, ghost }: HealthBarTrackProps) {
2483
+ if (aggregateLane) {
2484
+ return (
2485
+ <AggregateHealthTrack
2486
+ lane={aggregateLane}
2487
+ ghost={ghost}
2488
+ startTime={startTime}
2489
+ windowMs={windowMs}
2490
+ now={now}
2491
+ />
2492
+ )
2493
+ }
933
2494
  // Filter to change events for health state computation
934
2495
  const changeEvents = events.filter(e => isChangeEvent(e))
935
2496
 
@@ -938,10 +2499,16 @@ function HealthBarTrack({ events, startTime, windowMs, now }: HealthBarTrackProp
938
2499
  changeEvents,
939
2500
  startTime,
940
2501
  now,
941
- events // All events for createdAt extraction
2502
+ events, // All events for createdAt extraction
2503
+ ownResource
942
2504
  )
943
2505
 
944
- if (spans.length === 0) return null
2506
+ // Every row gets a health line even with no derived spans (event dots
2507
+ // floating on nothing read as footnotes with no page). Paint a faint neutral
2508
+ // baseline so the row still has its line.
2509
+ if (spans.length === 0) {
2510
+ return <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 z-0 h-[7px] rounded-[2px] bg-gray-400/25" />
2511
+ }
945
2512
 
946
2513
  return (
947
2514
  <div className="absolute inset-0 z-0">
@@ -955,17 +2522,135 @@ function HealthBarTrack({ events, startTime, windowMs, now }: HealthBarTrackProp
955
2522
 
956
2523
  // Clamp to visible range
957
2524
  const clampedLeft = Math.max(0, left)
958
- const clampedWidth = Math.min(100 - clampedLeft, width - (clampedLeft - left))
2525
+ let clampedWidth = Math.min(100 - clampedLeft, width - (clampedLeft - left))
959
2526
 
960
2527
  if (clampedWidth <= 0) return null
2528
+ // A real span narrower than ~a pixel still deserves ink: a cron pod's
2529
+ // 3-second healthy run must read as a tick, not vanish entirely.
2530
+ clampedWidth = Math.max(clampedWidth, 0.12)
961
2531
 
2532
+ const showCreatedBefore = createdBeforeWindow && i === 0 && createdAt != null
2533
+ // Positioning lives on the Tooltip wrapper so the span itself can fill it.
2534
+ return (
2535
+ <Tooltip
2536
+ key={i}
2537
+ content={`Health: ${span.health} · ${new Date(span.start).toLocaleTimeString()} - ${new Date(span.end).toLocaleTimeString()}`}
2538
+ wrapperClassName="absolute top-1/2 -translate-y-1/2 h-[7px]"
2539
+ wrapperStyle={{ left: `${clampedLeft}%`, width: `${clampedWidth}%` }}
2540
+ >
2541
+ <div className={clsx('relative h-full w-full', getHealthStripColor(span.health))}>
2542
+ {showCreatedBefore && (
2543
+ <span className="absolute bottom-[9px] left-0.5 text-[9px] text-theme-text-tertiary whitespace-nowrap pointer-events-none">
2544
+ ← {formatCreatedBefore(new Date(createdAt!))}
2545
+ </span>
2546
+ )}
2547
+ </div>
2548
+ </Tooltip>
2549
+ )
2550
+ })}
2551
+ </div>
2552
+ )
2553
+ }
2554
+
2555
+ // The collapsed-parent / app-group health strip: an interval sweep over each
2556
+ // member's honest spans. A slice whose member states share one family paints
2557
+ // that family's dominant state; a slice where families disagree paints the
2558
+ // neutral 'mixed' texture. The tooltip names who's off.
2559
+ function AggregateHealthTrack({ lane, startTime, windowMs, now, ghost }: {
2560
+ lane: ResourceLane
2561
+ startTime: number
2562
+ windowMs: number
2563
+ now: number
2564
+ ghost?: boolean
2565
+ }) {
2566
+ const endTime = startTime + windowMs
2567
+ const segments = useMemo(() => {
2568
+ const members = buildLaneMemberSpans(lane, startTime, now)
2569
+ return sweepAggregateHealth(members, startTime, endTime)
2570
+ }, [lane, startTime, endTime, now])
2571
+
2572
+ // Group headers get their line too — a sweep with no segments still
2573
+ // paints the faint neutral baseline (same as leaf rows) so event pills never
2574
+ // float on nothing.
2575
+ if (segments.length === 0) {
2576
+ return <div className={clsx('absolute top-1/2 -translate-y-1/2 left-0 right-0 z-0 rounded-[2px] bg-gray-400/25', ghost ? 'h-[4px]' : 'h-[7px]')} />
2577
+ }
2578
+
2579
+ return (
2580
+ <div className={clsx('absolute inset-0 z-0', ghost && 'opacity-40')}>
2581
+ {segments.map((seg, i) => {
2582
+ const left = sharedTimeToX(seg.start, startTime, windowMs)
2583
+ const right = sharedTimeToX(seg.end, startTime, windowMs)
2584
+ if (right < 0 || left > 100) return null
2585
+ const clampedLeft = Math.max(0, left)
2586
+ let clampedWidth = Math.min(100 - clampedLeft, (right - left) - (clampedLeft - left))
2587
+ if (clampedWidth <= 0) return null
2588
+ clampedWidth = Math.max(clampedWidth, 0.12)
962
2589
  return (
963
- <HealthSpan
2590
+ <Tooltip
964
2591
  key={i}
965
- health={span.health}
966
- left={clampedLeft}
967
- width={clampedWidth}
968
- createdBefore={createdBeforeWindow && i === 0 ? new Date(createdAt!) : undefined}
2592
+ content={formatAggregateHealthTooltip(seg)}
2593
+ wrapperClassName={ghost ? 'absolute top-1/2 -translate-y-1/2 h-[4px]' : 'absolute top-1/2 -translate-y-1/2 h-[7px]'}
2594
+ wrapperStyle={{ left: `${clampedLeft}%`, width: `${clampedWidth}%` }}
2595
+ >
2596
+ <div
2597
+ className={clsx('h-full w-full', !seg.mixed && getHealthStripColor(seg.health))}
2598
+ style={seg.mixed ? MIXED_HEALTH_STRIP_STYLE : undefined}
2599
+ />
2600
+ </Tooltip>
2601
+ )
2602
+ })}
2603
+ </div>
2604
+ )
2605
+ }
2606
+
2607
+ // Relative "created before window" label (mirrors the shared HealthSpan helper).
2608
+ function formatCreatedBefore(date: Date): string {
2609
+ const diffHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
2610
+ const diffDays = diffHours / 24
2611
+ if (diffDays < 1) return `${Math.round(diffHours)}h ago`
2612
+ if (diffDays < 7) return `${Math.round(diffDays)}d ago`
2613
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
2614
+ }
2615
+
2616
+ // Renders a lane's event markers, collapsing near-overlapping ones into "×N" pills.
2617
+ function LaneEventMarkers({ events, timeToX, selectedEvent, onSelectCluster, small }: {
2618
+ events: TimelineEvent[]
2619
+ timeToX: (t: number) => number
2620
+ selectedEvent: TimelineEvent | null
2621
+ onSelectCluster: (cluster: TimelineEventCluster) => void
2622
+ small?: boolean
2623
+ }) {
2624
+ const clusters = useMemo(() => {
2625
+ const positioned = events
2626
+ .map((event) => ({ event, x: timeToX(new Date(event.timestamp).getTime()) }))
2627
+ .filter(({ x }) => x >= 0 && x <= 100)
2628
+ return clusterEventsByPosition(positioned, CLUSTER_MIN_GAP_PCT)
2629
+ }, [events, timeToX])
2630
+
2631
+ return (
2632
+ <div className="absolute inset-0 z-10">
2633
+ {clusters.map((cluster, i) => {
2634
+ const selected = !!selectedEvent && cluster.events.some((e) => e.id === selectedEvent.id)
2635
+ if (cluster.count === 1) {
2636
+ return (
2637
+ <EventMarker
2638
+ key={`m-${cluster.dominant.id}-${i}`}
2639
+ event={cluster.dominant}
2640
+ x={cluster.x}
2641
+ selected={selected}
2642
+ onClick={() => onSelectCluster(cluster)}
2643
+ small={small}
2644
+ />
2645
+ )
2646
+ }
2647
+ return (
2648
+ <ClusterPill
2649
+ key={`c-${cluster.dominant.id}-${i}`}
2650
+ cluster={cluster}
2651
+ selected={selected}
2652
+ onClick={() => onSelectCluster(cluster)}
2653
+ small={small}
969
2654
  />
970
2655
  )
971
2656
  })}
@@ -973,6 +2658,57 @@ function HealthBarTrack({ events, startTime, windowMs, now }: HealthBarTrackProp
973
2658
  )
974
2659
  }
975
2660
 
2661
+ // A collapsed "dominant-glyph ×N" pill. Clicking opens the drawer listing every
2662
+ // member so none of the N is unreachable.
2663
+ function ClusterPill({ cluster, selected, onClick, small }: {
2664
+ cluster: TimelineEventCluster
2665
+ selected?: boolean
2666
+ onClick: () => void
2667
+ small?: boolean
2668
+ }) {
2669
+ const { lines, more } = clusterBreakdown(cluster.events)
2670
+ return (
2671
+ <Tooltip
2672
+ content={
2673
+ <div className="space-y-0.5">
2674
+ <div className="font-medium">{cluster.count} events</div>
2675
+ {lines.map((line) => (
2676
+ <div key={line.label} className="tabular-nums">
2677
+ {line.count}× {line.label}
2678
+ </div>
2679
+ ))}
2680
+ {more > 0 && <div className="text-theme-text-tertiary">…and {more} more</div>}
2681
+ <div className="text-theme-text-tertiary">click to browse</div>
2682
+ </div>
2683
+ }
2684
+ position="top"
2685
+ delay={100}
2686
+ wrapperClassName={clsx('absolute top-1/2 -translate-y-1/2 z-20', markerAnchor(cluster.x).anchorClass)}
2687
+ wrapperStyle={{ left: markerAnchor(cluster.x).left }}
2688
+ >
2689
+ <button
2690
+ aria-label={`Open ${cluster.count} clustered events`}
2691
+ className={clsx(
2692
+ 'inline-flex items-center gap-1 rounded-full border border-theme-border bg-theme-surface shadow-theme-sm transition-transform',
2693
+ small ? 'px-1 py-px' : 'px-1.5 py-0.5',
2694
+ selected ? 'ring-2 ring-accent scale-105' : 'hover:scale-105'
2695
+ )}
2696
+ onClick={(e) => {
2697
+ e.stopPropagation()
2698
+ onClick()
2699
+ }}
2700
+ >
2701
+ {/* Red once per problem: a critical-dominant cluster shows the
2702
+ error glyph — one ⊘ ×N, not N shouting icons or a generic diamond. */}
2703
+ {isCriticalIssue(cluster.dominant)
2704
+ ? <Ban className={clsx('text-red-600 dark:text-red-400', small ? 'h-2.5 w-2.5' : 'h-3 w-3')} />
2705
+ : <MarkerGlyph shape={eventShape(cluster.dominant)} size={small ? 9 : 11} className={eventColorClass(cluster.dominant)} />}
2706
+ <span className="text-[11px] font-semibold tabular-nums text-theme-text-primary">×{cluster.count}</span>
2707
+ </button>
2708
+ </Tooltip>
2709
+ )
2710
+ }
2711
+
976
2712
  // Critical issue reasons that should be prominently highlighted with icons
977
2713
  // This should align with PROBLEMATIC_REASONS in resource-hierarchy.ts
978
2714
  const CRITICAL_ISSUE_REASONS = new Set([
@@ -1159,8 +2895,10 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
1159
2895
  const tooltipLines: string[] = []
1160
2896
  tooltipLines.push(getOperationLabel())
1161
2897
  if (event.message) {
1162
- // Truncate long messages
1163
- const msg = event.message.length > 60 ? event.message.slice(0, 60) + '...' : event.message
2898
+ // Truncate long messages — the tooltip is a scannable preview, the drawer
2899
+ // carries the full text. Wide enough to keep a k8s event's gist (reason +
2900
+ // first clause) without spilling into a paragraph.
2901
+ const msg = event.message.length > 110 ? event.message.slice(0, 110) + '…' : event.message
1164
2902
  tooltipLines.push(msg)
1165
2903
  }
1166
2904
  tooltipLines.push(getRelativeTime(event.timestamp))
@@ -1175,10 +2913,14 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
1175
2913
  content={tooltipText}
1176
2914
  position="top"
1177
2915
  delay={100}
1178
- wrapperClassName="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-20"
1179
- wrapperStyle={{ left: `${x}%` }}
2916
+ className="max-w-sm!"
2917
+ wrapperClassName={clsx('absolute top-1/2 -translate-y-1/2 z-20', markerAnchor(x).anchorClass)}
2918
+ wrapperStyle={{ left: markerAnchor(x).left }}
1180
2919
  >
1181
2920
  <button
2921
+ // Icon-only marker: the tooltip is mouse-only, so mirror it into an
2922
+ // accessible name (event type · message · time) for screen readers.
2923
+ aria-label={tooltipText}
1182
2924
  className={clsx(
1183
2925
  'rounded-full transition-all flex items-center justify-center',
1184
2926
  'w-5 h-5',
@@ -1197,119 +2939,282 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
1197
2939
  )
1198
2940
  }
1199
2941
 
2942
+ // Shape-coded glyph (▲ created / ● modified / ▼ deleted / ◆ warning / ○ historical).
2943
+ const shape = eventShape(event)
2944
+ const size = small ? 11 : 13
2945
+
1200
2946
  return (
1201
2947
  <Tooltip
1202
2948
  content={tooltipText}
1203
2949
  position="top"
1204
2950
  delay={100}
2951
+ className="max-w-sm!"
1205
2952
  wrapperClassName={clsx(
1206
- 'absolute top-1/2 -translate-y-1/2 -translate-x-1/2',
1207
- dimmed ? 'z-5' : isHistorical ? 'z-5' : 'z-10'
2953
+ 'absolute top-1/2 -translate-y-1/2',
2954
+ markerAnchor(x).anchorClass,
2955
+ isHistorical ? 'z-5' : 'z-10'
1208
2956
  )}
1209
- wrapperStyle={{ left: `${x}%` }}
2957
+ wrapperStyle={{ left: markerAnchor(x).left }}
1210
2958
  >
1211
2959
  <button
2960
+ // Icon-only glyph: the tooltip is mouse-only, so mirror it into an
2961
+ // accessible name (event type · message · time) for screen readers.
2962
+ aria-label={tooltipText}
1212
2963
  className={clsx(
1213
- 'rounded-full transition-all',
1214
- small ? 'w-2.5 h-2.5' : 'w-3 h-3',
1215
- markerClasses,
1216
- selected ? 'ring-2 ring-white ring-offset-2 ring-offset-theme-base scale-150' : 'hover:scale-125'
2964
+ 'flex items-center justify-center transition-transform',
2965
+ selected ? 'scale-150' : 'hover:scale-125'
1217
2966
  )}
2967
+ style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
1218
2968
  onClick={(e) => {
1219
2969
  e.stopPropagation()
1220
2970
  onClick()
1221
2971
  }}
1222
- />
2972
+ >
2973
+ <MarkerGlyph shape={shape} size={size} className={clsx(eventColorClass(event), selected && 'drop-shadow')} />
2974
+ </button>
1223
2975
  </Tooltip>
1224
2976
  )
1225
2977
  }
1226
2978
 
1227
2979
  interface EventDetailPanelProps {
1228
- event: TimelineEvent
2980
+ // The drawer's events. Length 1 = a single marker (today's exact panel);
2981
+ // length > 1 = a cluster pill's members, listed so none is unreachable.
2982
+ events: TimelineEvent[]
2983
+ selectedId: string
2984
+ onSelectId: (id: string) => void
1229
2985
  onClose: () => void
1230
2986
  onResourceClick?: NavigateToResource
2987
+ // Every event in view — powers the ±15-min correlation feed ("what else
2988
+ // happened around this deploy?"). Absent → the correlation section is omitted.
2989
+ allEvents?: TimelineEvent[]
1231
2990
  }
1232
2991
 
1233
- function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelProps) {
1234
- const isChange = isChangeEvent(event)
1235
- const isHistorical = isHistoricalEvent(event)
2992
+ // The ±15-min window a single event's rail pulls correlated neighbours from
2993
+ // (deploy ↔ incident debugging — "what else happened around this moment?").
2994
+ const CORRELATION_WINDOW_MS = 15 * 60_000
2995
+
2996
+ // One row in the drawer's rail: glyph (severity tone via color) +
2997
+ // reason/type + owning resource + time. Reuses the marker glyph vocabulary so the
2998
+ // list reads the same as the track it summarizes.
2999
+ function ClusterEventRow({ event, active, onClick }: { event: TimelineEvent; active: boolean; onClick: () => void }) {
1236
3000
  const isProblematic = isProblematicEvent(event)
3001
+ return (
3002
+ <li>
3003
+ <button
3004
+ type="button"
3005
+ onClick={onClick}
3006
+ aria-current={active}
3007
+ className={clsx(
3008
+ 'w-full flex items-center gap-2 px-3 py-1.5 text-left border-l-2 transition-colors',
3009
+ active
3010
+ ? 'border-accent bg-theme-elevated'
3011
+ : 'border-transparent hover:bg-theme-hover',
3012
+ )}
3013
+ >
3014
+ <span className="inline-flex w-3 h-3 shrink-0 items-center justify-center">
3015
+ <MarkerGlyph shape={eventShape(event)} size={10} className={eventColorClass(event)} />
3016
+ </span>
3017
+ <span className="flex-1 min-w-0">
3018
+ <span className={clsx('block text-xs font-medium truncate', isProblematic ? 'text-amber-700 dark:text-amber-300' : 'text-theme-text-primary')}>
3019
+ {event.reason || event.eventType}
3020
+ </span>
3021
+ <span className="block text-[11px] text-theme-text-tertiary truncate font-mono">{event.name}</span>
3022
+ </span>
3023
+ <span className="text-[11px] text-theme-text-tertiary tabular-nums shrink-0">
3024
+ {formatAxisTime(new Date(event.timestamp))}
3025
+ </span>
3026
+ </button>
3027
+ </li>
3028
+ )
3029
+ }
3030
+
3031
+ export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onResourceClick, allEvents }: EventDetailPanelProps) {
3032
+ // ONE drawer anatomy: rail left, detail right — for one event or
3033
+ // fifty. The shape never changes with count, so muscle memory holds. A single
3034
+ // clicked dot renders as a rail of one plus its ±15-min correlated neighbors,
3035
+ // so "what else happened around this?" is a click away, not a separate feed.
3036
+ const railEvents = useMemo(() => {
3037
+ if (events.length !== 1 || !allEvents) return events
3038
+ const origin = events[0]
3039
+ const t0 = new Date(origin.timestamp).getTime()
3040
+ const neighbors = allEvents
3041
+ .filter((e) => e.id !== origin.id && Math.abs(new Date(e.timestamp).getTime() - t0) <= CORRELATION_WINDOW_MS)
3042
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
3043
+ .slice(0, 30)
3044
+ return [origin, ...neighbors]
3045
+ }, [events, allEvents])
3046
+
3047
+ // Opens just tall enough for one event (header + rail + property grid) —
3048
+ // the timeline above is the main surface, so the drawer defaults out of its
3049
+ // way; drag the top edge when a long message or diff needs room. No backdrop
3050
+ // — the timeline stays interactive, so the next dot is clickable without
3051
+ // closing.
3052
+ const [heightPx, setHeightPx] = useState(272)
3053
+ const resizeRef = useRef<{ startY: number; startH: number } | null>(null)
3054
+ useEffect(() => {
3055
+ const onMove = (e: PointerEvent) => {
3056
+ const r = resizeRef.current
3057
+ if (!r) return
3058
+ const max = Math.round(window.innerHeight * 0.8)
3059
+ setHeightPx(Math.min(max, Math.max(240, r.startH + (r.startY - e.clientY))))
3060
+ }
3061
+ const onUp = () => { resizeRef.current = null }
3062
+ window.addEventListener('pointermove', onMove)
3063
+ window.addEventListener('pointerup', onUp)
3064
+ return () => {
3065
+ window.removeEventListener('pointermove', onMove)
3066
+ window.removeEventListener('pointerup', onUp)
3067
+ }
3068
+ }, [])
3069
+
3070
+ const selected = railEvents.find((e) => e.id === selectedId) ?? events[0]
3071
+ const selIdx = railEvents.findIndex((e) => e.id === selected.id)
3072
+ const step = (dir: 1 | -1) => {
3073
+ const next = railEvents[selIdx + dir]
3074
+ if (next) onSelectId(next.id)
3075
+ }
3076
+ const isProblematic = isProblematicEvent(selected)
3077
+ const isHistorical = isHistoricalEvent(selected)
3078
+ // Index where correlated neighbors start (single-origin rails only) — a
3079
+ // divider separates "the dot you clicked" from "what happened around it".
3080
+ const neighborsFrom = events.length === 1 && railEvents.length > 1 ? 1 : -1
3081
+ const openResource = () =>
3082
+ onResourceClick?.({ kind: kindToPlural(selected.kind), namespace: selected.namespace, name: selected.name, group: apiVersionToGroup(selected.apiVersion) })
1237
3083
 
1238
3084
  return (
1239
- <div className={clsx(
1240
- "fixed bottom-0 left-0 right-0 z-50 border-t p-4 max-h-72 overflow-auto shadow-theme-lg",
1241
- isProblematic ? "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950" : "border-theme-border bg-theme-surface"
1242
- )}>
1243
- <div className="flex items-start justify-between mb-3">
1244
- <div>
1245
- <div className="flex items-center gap-2">
1246
- <span className="badge-sm bg-theme-elevated text-theme-text-secondary">
1247
- {displayKind(event.kind)}
1248
- </span>
3085
+ <div
3086
+ className="fixed bottom-0 left-0 right-0 z-50 flex flex-col border-t border-theme-border bg-theme-surface shadow-theme-lg"
3087
+ style={{ height: heightPx }}
3088
+ >
3089
+ {/* Resize handle — drag the top edge. */}
3090
+ <div
3091
+ onPointerDown={(e) => { e.preventDefault(); resizeRef.current = { startY: e.clientY, startH: heightPx } }}
3092
+ className="group absolute -top-1.5 left-0 right-0 flex h-3 cursor-ns-resize items-center justify-center"
3093
+ aria-label="Drag to resize"
3094
+ >
3095
+ <span className="h-1 w-10 rounded-full bg-theme-border opacity-0 transition-opacity group-hover:opacity-100" />
3096
+ </div>
3097
+
3098
+ {/* Header echoes the lit dot — glyph + reason + resource + time — and
3099
+ ‹ › steps through the rail without touching the timeline. */}
3100
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border px-4 py-2">
3101
+ <div className="flex min-w-0 items-center gap-2 text-sm">
3102
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
3103
+ <MarkerGlyph shape={eventShape(selected)} size={12} className={eventColorClass(selected)} />
3104
+ </span>
3105
+ <span className={clsx('shrink-0 font-medium', isProblematic ? 'text-amber-700 dark:text-amber-300' : 'text-theme-text-primary')}>
3106
+ {selected.reason || selected.eventType}
3107
+ </span>
3108
+ <span className="badge-sm shrink-0 bg-theme-elevated text-theme-text-secondary">{displayKind(selected.kind) || 'Event'}</span>
3109
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">{selected.name}</span>
3110
+ {selected.namespace && <span className="shrink-0 text-xs text-theme-text-tertiary">in {selected.namespace}</span>}
3111
+ <span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{formatFullTime(new Date(selected.timestamp))}</span>
3112
+ {events.length > 1 && (
3113
+ <span className="badge-sm shrink-0 bg-theme-elevated text-theme-text-secondary">{events.length} events</span>
3114
+ )}
3115
+ </div>
3116
+ <div className="flex shrink-0 items-center gap-1">
3117
+ <button
3118
+ onClick={() => step(-1)}
3119
+ disabled={selIdx <= 0}
3120
+ className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-30"
3121
+ aria-label="Previous event"
3122
+ >
3123
+ <ChevronLeft className="h-4 w-4" />
3124
+ </button>
3125
+ <span className="text-xs tabular-nums text-theme-text-tertiary">{`${selIdx + 1} of ${railEvents.length}`}</span>
3126
+ <button
3127
+ onClick={() => step(1)}
3128
+ disabled={selIdx >= railEvents.length - 1}
3129
+ className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-30"
3130
+ aria-label="Next event"
3131
+ >
3132
+ <ChevronRight className="h-4 w-4" />
3133
+ </button>
3134
+ <Tooltip content="Close (Esc)">
1249
3135
  <button
1250
- onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name, group: apiVersionToGroup(event.apiVersion) })}
1251
- className="text-theme-text-primary font-medium hover:text-accent-text"
3136
+ onClick={onClose}
3137
+ aria-label="Close"
3138
+ className="ml-1 rounded p-1.5 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
1252
3139
  >
1253
- {event.name}
3140
+ <X className="h-4 w-4" />
1254
3141
  </button>
1255
- {event.namespace && (
1256
- <span className="text-xs text-theme-text-tertiary">in {event.namespace}</span>
1257
- )}
1258
- {isHistorical && (
1259
- <span className="badge-sm bg-theme-hover text-theme-text-secondary">
1260
- <Clock className="w-3 h-3" />
1261
- historical
1262
- </span>
1263
- )}
1264
- </div>
1265
- <div className="text-xs text-theme-text-tertiary mt-1">
1266
- {formatFullTime(new Date(event.timestamp))}
1267
- {isHistorical && event.reason && (
1268
- <span className="ml-2 text-theme-text-secondary">({event.reason})</span>
1269
- )}
1270
- </div>
3142
+ </Tooltip>
1271
3143
  </div>
1272
- <button
1273
- onClick={onClose}
1274
- className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
1275
- title="Close (Esc)"
1276
- >
1277
- <X className="w-4 h-4" />
1278
- </button>
1279
3144
  </div>
1280
3145
 
1281
- {isChange ? (
1282
- <div className="space-y-2">
1283
- <div className="flex items-center gap-2">
1284
- <span className={clsx('text-sm font-medium', isOperation(event.eventType) && getOperationColor(event.eventType))}>
1285
- {event.eventType}
1286
- </span>
1287
- {event.healthState && event.healthState !== 'unknown' && (
1288
- <span className={clsx('badge-sm', getHealthBadgeColor(event.healthState))}>
1289
- {event.healthState}
1290
- </span>
1291
- )}
1292
- </div>
1293
- {event.diff && <DiffViewer diff={event.diff} />}
1294
- </div>
1295
- ) : (
1296
- <div className="space-y-2">
1297
- <div className="flex items-center gap-2">
1298
- <span className={clsx('text-sm font-medium', isProblematic ? 'text-amber-700 dark:text-amber-300' : 'text-green-700 dark:text-green-300')}>
1299
- {event.reason}
1300
- </span>
1301
- {event.eventType && (
1302
- <span className={clsx('badge-sm', getEventTypeColor(event.eventType))}>
1303
- {event.eventType}
3146
+ <div className="flex min-h-0 flex-1">
3147
+ {/* The rail — always renders, even as a rail of one. */}
3148
+ <ul className="w-72 shrink-0 overflow-auto border-r border-theme-border py-1" aria-label="Drawer events">
3149
+ {railEvents.map((ev, i) => (
3150
+ <Fragment key={ev.id}>
3151
+ {i === neighborsFrom && (
3152
+ <li aria-hidden className="mt-1 border-t border-theme-border px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-[0.07em] text-theme-text-tertiary">
3153
+ Within ±15 min
3154
+ </li>
3155
+ )}
3156
+ <ClusterEventRow event={ev} active={ev.id === selected.id} onClick={() => onSelectId(ev.id)} />
3157
+ </Fragment>
3158
+ ))}
3159
+ </ul>
3160
+
3161
+ {/* Detail pane: fields as properties, then message, diff, actions. */}
3162
+ <div className="min-w-0 flex-1 overflow-auto p-4">
3163
+ <dl className="grid grid-cols-[96px_1fr] items-baseline gap-x-3 gap-y-2 text-sm">
3164
+ <dt className="text-xs text-theme-text-tertiary">Reason</dt>
3165
+ <dd className="flex flex-wrap items-center gap-2">
3166
+ <span className={clsx(
3167
+ 'font-medium',
3168
+ isProblematic ? 'text-amber-700 dark:text-amber-300' : 'text-theme-text-primary',
3169
+ )}>
3170
+ {selected.reason || selected.eventType}
1304
3171
  </span>
3172
+ {!isChangeEvent(selected) && selected.eventType && (
3173
+ <span className={clsx('badge-sm', getEventTypeColor(selected.eventType))}>{selected.eventType}</span>
3174
+ )}
3175
+ {selected.count && selected.count > 1 && (
3176
+ <span className="text-xs text-theme-text-tertiary">×{selected.count}</span>
3177
+ )}
3178
+ </dd>
3179
+ <dt className="text-xs text-theme-text-tertiary">Resource</dt>
3180
+ <dd className="flex min-w-0 flex-wrap items-center gap-1.5">
3181
+ {/* The ONE click target for the resource — the same badge used by
3182
+ every Radar drawer, so it reads as "this navigates". */}
3183
+ <ResourceRefBadge
3184
+ resourceRef={{ kind: selected.kind || 'Event', namespace: selected.namespace ?? '', name: selected.name }}
3185
+ onClick={onResourceClick ? openResource : undefined}
3186
+ />
3187
+ {selected.namespace && <span className="text-xs text-theme-text-tertiary">in {selected.namespace}</span>}
3188
+ </dd>
3189
+ <dt className="text-xs text-theme-text-tertiary">Time</dt>
3190
+ <dd className="flex items-center gap-2 tabular-nums">
3191
+ {formatFullTime(new Date(selected.timestamp))}
3192
+ {isHistorical && (
3193
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">
3194
+ <Clock className="h-3 w-3" />
3195
+ from metadata
3196
+ </span>
3197
+ )}
3198
+ </dd>
3199
+ {selected.healthState && selected.healthState !== 'unknown' && (
3200
+ <>
3201
+ <dt className="text-xs text-theme-text-tertiary">Health</dt>
3202
+ <dd><span className={clsx('badge-sm', getHealthBadgeColor(selected.healthState))}>{selected.healthState}</span></dd>
3203
+ </>
1305
3204
  )}
1306
- {event.count && event.count > 1 && (
1307
- <span className="text-xs text-theme-text-tertiary">x{event.count}</span>
1308
- )}
1309
- </div>
1310
- {event.message && <p className={clsx("text-sm", isProblematic ? "text-amber-700 dark:text-amber-200" : "text-theme-text-secondary")}>{event.message}</p>}
3205
+ </dl>
3206
+ {selected.message && (
3207
+ <p className={clsx('mt-3 text-sm', isProblematic ? 'text-amber-700 dark:text-amber-200' : 'text-theme-text-secondary')}>
3208
+ {selected.message}
3209
+ </p>
3210
+ )}
3211
+ {selected.diff && (
3212
+ <div className="mt-3">
3213
+ <DiffViewer diff={selected.diff} />
3214
+ </div>
3215
+ )}
1311
3216
  </div>
1312
- )}
3217
+ </div>
1313
3218
  </div>
1314
3219
  )
1315
3220
  }