@skyhook-io/k8s-ui 1.8.9 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
43
|
+
import { getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
|
|
44
|
+
import { MiddleEllipsis } from '../ui/MiddleEllipsis'
|
|
36
45
|
import { Tooltip } from '../ui/Tooltip'
|
|
37
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
const [
|
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
958
|
+
grouping,
|
|
959
|
+
appIndex,
|
|
286
960
|
})
|
|
287
961
|
|
|
288
|
-
// Add score breakdown to each lane (specific to swimlanes view)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 =
|
|
1075
|
+
const end = effectiveNow - panOffset
|
|
326
1076
|
const start = end - windowMs
|
|
327
|
-
return { start, end, windowMs, now:
|
|
328
|
-
}, [zoom, panOffset,
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
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
|
-
//
|
|
388
|
-
const
|
|
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
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
513
|
-
{searchTerm && ` (filtered)`}
|
|
1836
|
+
{windowLabel} window
|
|
514
1837
|
</span>
|
|
515
|
-
{
|
|
516
|
-
|
|
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={
|
|
544
|
-
className=
|
|
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
|
-
|
|
558
|
-
Timeline
|
|
1844
|
+
→ Now
|
|
559
1845
|
</button>
|
|
560
|
-
</
|
|
1846
|
+
</Tooltip>
|
|
561
1847
|
)}
|
|
562
1848
|
</div>
|
|
563
|
-
|
|
564
|
-
{/* Legend
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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=
|
|
593
|
-
<span className="text-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
{/*
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
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(
|
|
2018
|
+
style={{ left: `calc(${laneLabelPx}px + (100% - ${laneTrackInsetPx}px) * ${nowX / 100})` }}
|
|
668
2019
|
/>
|
|
669
2020
|
)
|
|
670
2021
|
})()}
|
|
671
|
-
{
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
677
|
-
{
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
883
|
-
{
|
|
884
|
-
|
|
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
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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=
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
//
|
|
913
|
-
function
|
|
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=
|
|
918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
2590
|
+
<Tooltip
|
|
964
2591
|
key={i}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
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
|
|
1207
|
-
|
|
2953
|
+
'absolute top-1/2 -translate-y-1/2',
|
|
2954
|
+
markerAnchor(x).anchorClass,
|
|
2955
|
+
isHistorical ? 'z-5' : 'z-10'
|
|
1208
2956
|
)}
|
|
1209
|
-
wrapperStyle={{ left:
|
|
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
|
-
'
|
|
1214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
|
1240
|
-
"fixed bottom-0 left-0 right-0 z-50 border-t
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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={
|
|
1251
|
-
|
|
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
|
-
|
|
3140
|
+
<X className="h-4 w-4" />
|
|
1254
3141
|
</button>
|
|
1255
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
</
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
)}
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
}
|