@skyhook-io/k8s-ui 1.7.7 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsTableView.tsx +1 -2
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  7. package/src/components/issues/IssuesView.tsx +352 -0
  8. package/src/components/issues/index.ts +24 -0
  9. package/src/components/issues/issues.test.ts +100 -0
  10. package/src/components/issues/severity.ts +125 -0
  11. package/src/components/issues/types.ts +194 -0
  12. package/src/components/resources/ResourcesView.tsx +2 -19
  13. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  14. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  15. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  19. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  26. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  27. package/src/components/shared/DetailShell.tsx +107 -0
  28. package/src/components/shared/ManagedByChip.tsx +149 -16
  29. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  30. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  31. package/src/components/shared/index.ts +2 -1
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  33. package/src/components/timeline/index.ts +1 -0
  34. package/src/components/topology/K8sResourceNode.tsx +60 -60
  35. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  36. package/src/components/topology/TopologyGraph.tsx +168 -52
  37. package/src/components/topology/TopologySearch.tsx +17 -8
  38. package/src/components/topology/topology-search-match.test.ts +1 -1
  39. package/src/components/topology/topology.css +18 -0
  40. package/src/components/ui/Tooltip.tsx +37 -2
  41. package/src/components/workload/WorkloadView.tsx +118 -112
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +16 -0
  44. package/src/types/core.ts +3 -2
  45. package/src/utils/env-from.ts +3 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/replica-scalers.ts +9 -0
  48. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -0,0 +1,1320 @@
1
+ import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import {
4
+ AlertCircle,
5
+ AlertTriangle,
6
+ RefreshCw,
7
+ ZoomIn,
8
+ ZoomOut,
9
+ ChevronRight,
10
+ Search,
11
+ X,
12
+ List,
13
+ GanttChart,
14
+ ArrowUpDown,
15
+ Clock,
16
+ MemoryStick,
17
+ Package,
18
+ Ban,
19
+ Box,
20
+ Gauge,
21
+ HardDrive,
22
+ Timer,
23
+ RotateCcw,
24
+ Shield,
25
+ } from 'lucide-react'
26
+ import type { TimelineEvent, Topology } from '../../types'
27
+ import type { NavigateToResource } from '../../utils/navigation'
28
+ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
29
+ import { PaneLoader } from '../ui/PaneLoader'
30
+ import { pluralize } from '../../utils/pluralize'
31
+ import { gitOpsRouteForKind } from '../../utils/gitops-route'
32
+ import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
33
+ import { DiffViewer } from './DiffViewer'
34
+ import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
35
+ import { Tooltip } from '../ui/Tooltip'
36
+ import { buildResourceHierarchy, isProblematicEvent, type ResourceLane as BaseResourceLane } from '../../utils/resource-hierarchy'
37
+ import {
38
+ formatAxisTime,
39
+ formatFullTime,
40
+ buildHealthSpans,
41
+ HealthSpan,
42
+ timeToX as sharedTimeToX,
43
+ } from './shared'
44
+ import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
45
+
46
+ export interface TimelineSwimlanesProps {
47
+ events: TimelineEvent[]
48
+ isLoading?: boolean
49
+ onResourceClick?: NavigateToResource
50
+ viewMode?: 'list' | 'swimlane'
51
+ onViewModeChange?: (mode: 'list' | 'swimlane') => void
52
+ topology?: Topology
53
+ namespaces?: string[]
54
+ // RBAC capability flag (was a radar/web context); host passes it. Default false.
55
+ hasLimitedAccess?: boolean
56
+ // GitOps lane labels deep-link to a controller path; the host decides how to
57
+ // navigate (radar router push, or cross-SPA href). When omitted, GitOps lanes
58
+ // fall back to onResourceClick (the resource drawer).
59
+ onNavigatePath?: (path: string) => void
60
+ }
61
+
62
+ interface ResourceLane extends BaseResourceLane {
63
+ scoreBreakdown?: ScoreBreakdown // Debug: interestingness score breakdown
64
+ }
65
+
66
+ // Score breakdown for debugging
67
+ interface ScoreBreakdown {
68
+ total: number
69
+ kind: number
70
+ problematic: number
71
+ variety: number
72
+ addDelete: number
73
+ children: number
74
+ empty: number
75
+ systemNs: number
76
+ recent5m: number
77
+ recent30m: number
78
+ noisy: number
79
+ details: string
80
+ }
81
+
82
+ // Calculate "interestingness" score for sorting lanes
83
+ // Higher score = more interesting = should appear higher in list
84
+ function calculateInterestingness(lane: ResourceLane): number {
85
+ return calculateInterestingnessWithBreakdown(lane).total
86
+ }
87
+
88
+ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdown {
89
+ const allEvents = [...lane.events, ...(lane.children?.flatMap(c => c.events) || [])]
90
+ const breakdown: ScoreBreakdown = {
91
+ total: 0, kind: 0, problematic: 0, variety: 0, addDelete: 0,
92
+ children: 0, empty: 0, systemNs: 0, recent5m: 0, recent30m: 0, noisy: 0, details: ''
93
+ }
94
+
95
+ // 1. Base: Kind priority (tiebreaker, lower values than before)
96
+ const kindScores: Record<string, number> = {
97
+ // GitOps controllers - top priority
98
+ Application: 55, // ArgoCD Application
99
+ Kustomization: 55, HelmRelease: 55, // FluxCD controllers
100
+ GitRepository: 52, OCIRepository: 52, HelmRepository: 52, // FluxCD sources
101
+ // Core workloads
102
+ Deployment: 50, Rollout: 50, StatefulSet: 50, DaemonSet: 50,
103
+ Service: 45, Ingress: 45, Gateway: 45,
104
+ HTTPRoute: 42, GRPCRoute: 42, TCPRoute: 42, TLSRoute: 42,
105
+ Job: 40, CronJob: 40, Workflow: 40, CronWorkflow: 40,
106
+ Pod: 30,
107
+ HorizontalPodAutoscaler: 25,
108
+ ReplicaSet: 20,
109
+ ConfigMap: 10, Secret: 10, PersistentVolumeClaim: 10,
110
+ }
111
+ breakdown.kind = kindScores[lane.kind] || 15
112
+
113
+ // 2. Primary: Recency (dominates) - events in last 5 minutes
114
+ const now = Date.now()
115
+ const fiveMinutesAgo = now - 5 * 60 * 1000
116
+ const thirtyMinutesAgo = now - 30 * 60 * 1000
117
+
118
+ const eventsLast5m = allEvents.filter(e => new Date(e.timestamp).getTime() > fiveMinutesAgo)
119
+ const eventsLast30m = allEvents.filter(e => {
120
+ const t = new Date(e.timestamp).getTime()
121
+ return t > thirtyMinutesAgo && t <= fiveMinutesAgo
122
+ })
123
+
124
+ breakdown.recent5m = Math.min(eventsLast5m.length * 30, 150)
125
+ breakdown.recent30m = Math.min(eventsLast30m.length * 10, 50)
126
+
127
+ // 3. Secondary: Problems (important signal) - +40 each, max 200
128
+ const problematicCount = allEvents.filter(e => isProblematicEvent(e)).length
129
+ breakdown.problematic = Math.min(problematicCount * 40, 200)
130
+
131
+ // 4. Tertiary: Activity type
132
+ const operations = new Set(allEvents.map(e => e.eventType).filter(t => isOperation(t as any)))
133
+ breakdown.variety = operations.size * 10 // Up to 30 for all three types
134
+
135
+ // Add/delete with caps
136
+ const addCount = allEvents.filter(e => e.eventType === 'add').length
137
+ const deleteCount = allEvents.filter(e => e.eventType === 'delete').length
138
+ breakdown.addDelete = Math.min(addCount * 3, 30) + Math.min(deleteCount * 5, 30)
139
+
140
+ // 5. Children bonus (flat, just organizational)
141
+ if (lane.children && lane.children.length > 0) {
142
+ breakdown.children = 10
143
+ }
144
+
145
+ // 6. Empty lane penalty (parent with 0 own events)
146
+ if (lane.events.length === 0) {
147
+ breakdown.empty = -30
148
+ }
149
+
150
+ // 7. System namespaces penalty
151
+ const systemNamespaces = ['kube-system', 'kube-public', 'kube-node-lease', 'gke-managed-system']
152
+ if (systemNamespaces.includes(lane.namespace)) {
153
+ breakdown.systemNs = -30
154
+ }
155
+
156
+ // 8. Noisy penalty (many updates with no variety)
157
+ const updateCount = allEvents.filter(e => e.eventType === 'update').length
158
+ if (updateCount > 10 && operations.size === 1) {
159
+ breakdown.noisy = -Math.min(updateCount, 40)
160
+ }
161
+
162
+ breakdown.total = breakdown.kind + breakdown.problematic + breakdown.variety +
163
+ breakdown.addDelete + breakdown.children + breakdown.empty + breakdown.systemNs +
164
+ breakdown.recent5m + breakdown.recent30m + breakdown.noisy
165
+
166
+ // Build details string
167
+ const parts: string[] = []
168
+ parts.push(`kind:${breakdown.kind}`)
169
+ if (breakdown.recent5m) parts.push(`5m:${breakdown.recent5m}`)
170
+ if (breakdown.recent30m) parts.push(`30m:${breakdown.recent30m}`)
171
+ if (breakdown.problematic) parts.push(`warn:${breakdown.problematic}`)
172
+ if (breakdown.variety) parts.push(`var:${breakdown.variety}`)
173
+ if (breakdown.addDelete) parts.push(`a/d:${breakdown.addDelete}`)
174
+ if (breakdown.children) parts.push(`child:${breakdown.children}`)
175
+ if (breakdown.empty) parts.push(`empty:${breakdown.empty}`)
176
+ if (breakdown.systemNs) parts.push(`sys:${breakdown.systemNs}`)
177
+ if (breakdown.noisy) parts.push(`noisy:${breakdown.noisy}`)
178
+ breakdown.details = parts.join(' ')
179
+
180
+ return breakdown
181
+ }
182
+
183
+ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath }: TimelineSwimlanesProps) {
184
+ // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
185
+ // deep-link to GitOps detail rather than the resource drawer — the lane is
186
+ // already telling the user "this controller had changes/events"; the GitOps
187
+ // tab is the right place to investigate further.
188
+ const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string) => {
189
+ const gitOpsPath = gitOpsRouteForKind(kind, namespace, name)
190
+ if (gitOpsPath && onNavigatePath) {
191
+ onNavigatePath(gitOpsPath)
192
+ return
193
+ }
194
+ onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
195
+ }, [onNavigatePath, onResourceClick])
196
+ const containerRef = useRef<HTMLDivElement>(null)
197
+ const searchInputRef = useRef<HTMLInputElement>(null)
198
+ const [zoom, setZoom] = useState(1)
199
+ const [panOffset, setPanOffset] = useState(0)
200
+ const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
201
+ const [isDragging, setIsDragging] = useState(false)
202
+ const [dragStart, setDragStart] = useState({ x: 0, offset: 0 })
203
+ const [searchTerm, setSearchTerm] = useState('')
204
+ const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
205
+ const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
206
+ const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
207
+
208
+ // Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
209
+ const laneOrderRef = useRef<Map<string, number>>(new Map())
210
+ const [sortVersion, setSortVersion] = useState(0) // Increment to re-sort lanes
211
+
212
+ // Stable "now" time - captured once on mount, only changes when user interacts
213
+ // This prevents the time window from auto-shifting and causing re-renders
214
+ const [stableNow] = useState(() => Date.now())
215
+
216
+ // Auto-adjust zoom based on event distribution (only once on initial load)
217
+ useEffect(() => {
218
+ if (hasAutoZoomed || events.length === 0) return
219
+
220
+ const now = Date.now()
221
+ const timestamps = events.map(e => new Date(e.timestamp).getTime())
222
+ const oldestEvent = Math.min(...timestamps)
223
+ const eventAge = now - oldestEvent
224
+
225
+ // Zoom levels: 0.25 (15m), 0.5 (30m), 1 (1h), 2 (2h), etc.
226
+ // Pick the smallest zoom that fits all events with some margin
227
+ let optimalZoom = 1
228
+ if (eventAge < 10 * 60 * 1000) { // < 10 minutes
229
+ optimalZoom = 0.25 // 15m window
230
+ } else if (eventAge < 20 * 60 * 1000) { // < 20 minutes
231
+ optimalZoom = 0.5 // 30m window
232
+ } else if (eventAge < 45 * 60 * 1000) { // < 45 minutes
233
+ optimalZoom = 1 // 1h window
234
+ } else if (eventAge < 90 * 60 * 1000) { // < 90 minutes
235
+ optimalZoom = 2 // 2h window
236
+ }
237
+ // else keep default 1h
238
+
239
+ setZoom(optimalZoom)
240
+ setHasAutoZoomed(true)
241
+ }, [events, hasAutoZoomed])
242
+
243
+ // Keyboard shortcuts
244
+ useRegisterShortcut({
245
+ id: 'swimlane-search',
246
+ keys: '/',
247
+ description: 'Focus search',
248
+ category: 'Search',
249
+ scope: 'timeline',
250
+ handler: () => searchInputRef.current?.focus(),
251
+ })
252
+ useRegisterShortcut({
253
+ id: 'swimlane-escape',
254
+ keys: 'Escape',
255
+ description: 'Close detail / blur search',
256
+ category: 'Timeline',
257
+ scope: 'timeline',
258
+ handler: () => {
259
+ if (selectedEvent) setSelectedEvent(null)
260
+ else searchInputRef.current?.blur()
261
+ },
262
+ })
263
+
264
+ // Filter events by search term
265
+ const filteredEvents = useMemo(() => {
266
+ if (!searchTerm) return events
267
+
268
+ const term = searchTerm.toLowerCase()
269
+ return events.filter(e =>
270
+ e.name.toLowerCase().includes(term) ||
271
+ e.kind.toLowerCase().includes(term) ||
272
+ e.namespace?.toLowerCase().includes(term) ||
273
+ e.reason?.toLowerCase().includes(term) ||
274
+ e.message?.toLowerCase().includes(term)
275
+ )
276
+ }, [events, searchTerm])
277
+
278
+ // Build hierarchical lanes using owner references + topology edges
279
+ // Uses the shared utility from utils/resource-hierarchy.ts
280
+ const lanes = useMemo(() => {
281
+ // Build the hierarchy using the shared utility
282
+ const baseLanes = buildResourceHierarchy({
283
+ events: filteredEvents,
284
+ topology,
285
+ groupByApp,
286
+ })
287
+
288
+ // Add score breakdown to each lane (specific to swimlanes view)
289
+ const lanesWithScores: ResourceLane[] = baseLanes.map(lane => ({
290
+ ...lane,
291
+ scoreBreakdown: calculateInterestingnessWithBreakdown(lane),
292
+ }))
293
+
294
+ // Sort by interestingness score (highest first)
295
+ return lanesWithScores.sort((a, b) => {
296
+ const aScore = a.scoreBreakdown?.total ?? calculateInterestingness(a)
297
+ const bScore = b.scoreBreakdown?.total ?? calculateInterestingness(b)
298
+ return bScore - aScore
299
+ })
300
+ }, [filteredEvents, topology, sortVersion, groupByApp])
301
+
302
+ // Re-sort lanes by interestingness score
303
+ const handleRefreshSort = useCallback(() => {
304
+ // Reset lane order to force re-sort by interestingness
305
+ laneOrderRef.current = new Map()
306
+ setSortVersion(v => v + 1)
307
+ }, [])
308
+
309
+ // Toggle lane expansion
310
+ const toggleLane = useCallback((laneId: string) => {
311
+ setExpandedLanes(prev => {
312
+ const next = new Set(prev)
313
+ if (next.has(laneId)) {
314
+ next.delete(laneId)
315
+ } else {
316
+ next.add(laneId)
317
+ }
318
+ return next
319
+ })
320
+ }, [])
321
+
322
+ // Calculate visible time range
323
+ const visibleTimeRange = useMemo(() => {
324
+ const windowMs = zoom * 60 * 60 * 1000
325
+ const end = stableNow - panOffset
326
+ const start = end - windowMs
327
+ return { start, end, windowMs, now: stableNow }
328
+ }, [zoom, panOffset, stableNow])
329
+
330
+ // Filter out lanes with no events in the visible time window
331
+ const visibleLanes = useMemo(() => {
332
+ const { start, end } = visibleTimeRange
333
+ return lanes.filter(lane => {
334
+ const allLaneEvents = lane.allEventsSorted || []
335
+ return allLaneEvents.some(e => {
336
+ const t = new Date(e.timestamp).getTime()
337
+ return t >= start && t <= end
338
+ })
339
+ })
340
+ }, [lanes, visibleTimeRange])
341
+
342
+ // Generate time axis ticks
343
+ const axisTicks = useMemo(() => {
344
+ const { start, end } = visibleTimeRange
345
+ const ticks: { time: number; label: string }[] = []
346
+
347
+ let intervalMs: number
348
+ if (zoom <= 0.25) {
349
+ intervalMs = 2 * 60 * 1000 // 2 min intervals for 15m window
350
+ } else if (zoom <= 0.5) {
351
+ intervalMs = 5 * 60 * 1000 // 5 min intervals for 30m window
352
+ } else if (zoom <= 1) {
353
+ intervalMs = 10 * 60 * 1000
354
+ } else if (zoom <= 3) {
355
+ intervalMs = 30 * 60 * 1000
356
+ } else if (zoom <= 6) {
357
+ intervalMs = 60 * 60 * 1000
358
+ } else if (zoom <= 24) {
359
+ intervalMs = 2 * 60 * 60 * 1000 // 2 hour intervals
360
+ } else if (zoom <= 72) {
361
+ intervalMs = 6 * 60 * 60 * 1000 // 6 hour intervals for up to 3 days
362
+ } else {
363
+ intervalMs = 24 * 60 * 60 * 1000 // 1 day intervals for larger windows
364
+ }
365
+
366
+ const firstTick = Math.ceil(start / intervalMs) * intervalMs
367
+
368
+ for (let t = firstTick; t <= end; t += intervalMs) {
369
+ ticks.push({
370
+ time: t,
371
+ label: formatAxisTime(new Date(t)),
372
+ })
373
+ }
374
+
375
+ return ticks
376
+ }, [visibleTimeRange, zoom])
377
+
378
+ // Convert timestamp to X position (0-100%)
379
+ const timeToX = useCallback(
380
+ (timestamp: number): number => {
381
+ const { start, windowMs } = visibleTimeRange
382
+ return ((timestamp - start) / windowMs) * 100
383
+ },
384
+ [visibleTimeRange]
385
+ )
386
+
387
+ // Predefined zoom levels (in hours): 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 2d, 3d, 7d
388
+ const ZOOM_LEVELS = [0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168]
389
+
390
+ // Zoom handlers - snap to predefined levels
391
+ const handleZoomIn = () => setZoom((z) => {
392
+ const idx = ZOOM_LEVELS.findIndex(level => level >= z)
393
+ return ZOOM_LEVELS[Math.max(0, idx - 1)]
394
+ })
395
+ const handleZoomOut = () => setZoom((z) => {
396
+ const idx = ZOOM_LEVELS.findIndex(level => level > z)
397
+ return ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx === -1 ? ZOOM_LEVELS.length - 1 : idx)]
398
+ })
399
+
400
+ // Pan with mouse drag
401
+ const handleMouseDown = (e: React.MouseEvent) => {
402
+ if (e.button !== 0) return
403
+ setIsDragging(true)
404
+ setDragStart({ x: e.clientX, offset: panOffset })
405
+ }
406
+
407
+ const handleMouseMove = useCallback(
408
+ (e: MouseEvent) => {
409
+ if (!isDragging || !containerRef.current) return
410
+
411
+ const containerWidth = containerRef.current.clientWidth
412
+ const dx = e.clientX - dragStart.x
413
+ const { windowMs } = visibleTimeRange
414
+
415
+ const timePerPixel = windowMs / containerWidth
416
+ const newOffset = dragStart.offset - dx * timePerPixel
417
+
418
+ setPanOffset(Math.max(0, newOffset))
419
+ },
420
+ [isDragging, dragStart, visibleTimeRange]
421
+ )
422
+
423
+ const handleMouseUp = useCallback(() => {
424
+ setIsDragging(false)
425
+ }, [])
426
+
427
+ useEffect(() => {
428
+ if (isDragging) {
429
+ window.addEventListener('mousemove', handleMouseMove)
430
+ window.addEventListener('mouseup', handleMouseUp)
431
+ return () => {
432
+ window.removeEventListener('mousemove', handleMouseMove)
433
+ window.removeEventListener('mouseup', handleMouseUp)
434
+ }
435
+ }
436
+ }, [isDragging, handleMouseMove, handleMouseUp])
437
+
438
+ // Wheel zoom - snap to predefined levels
439
+ const handleWheel = useCallback((e: React.WheelEvent) => {
440
+ if (e.ctrlKey || e.metaKey) {
441
+ e.preventDefault()
442
+ setZoom((z) => {
443
+ const currentIdx = ZOOM_LEVELS.findIndex(level => level >= z)
444
+ const idx = currentIdx === -1 ? ZOOM_LEVELS.length - 1 : currentIdx
445
+ if (e.deltaY > 0) {
446
+ // Zoom out - go to next larger level
447
+ return ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)]
448
+ } else {
449
+ // Zoom in - go to next smaller level
450
+ return ZOOM_LEVELS[Math.max(0, idx - 1)]
451
+ }
452
+ })
453
+ }
454
+ }, [])
455
+
456
+ if (isLoading) {
457
+ return <PaneLoader label="Loading timeline…" className="h-full w-full" />
458
+ }
459
+
460
+ // Compute empty state info (but don't early return - we need the toolbar visible)
461
+ const hasFilteredEvents = visibleLanes.length === 0 && events.length > 0 && filteredEvents.length === 0
462
+
463
+ return (
464
+ <div className="flex flex-col h-full w-full">
465
+ {/* Toolbar with search and zoom */}
466
+ <div className="border-b border-theme-border bg-theme-surface/30 overflow-hidden">
467
+ <div className="flex items-center justify-between px-4 py-2">
468
+ <div className="flex items-center gap-4">
469
+ {/* Search */}
470
+ <div className="relative">
471
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
472
+ <input
473
+ ref={searchInputRef}
474
+ type="text"
475
+ value={searchTerm}
476
+ onChange={(e) => setSearchTerm(e.target.value)}
477
+ placeholder="Search... (press /)"
478
+ className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
479
+ />
480
+ {searchTerm && (
481
+ <button
482
+ onClick={() => setSearchTerm('')}
483
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
484
+ >
485
+ <X className="w-4 h-4" />
486
+ </button>
487
+ )}
488
+ </div>
489
+ {/* Zoom controls */}
490
+ <div className="flex items-center gap-2">
491
+ <button
492
+ onClick={handleZoomIn}
493
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
494
+ title="Zoom in (Ctrl+scroll)"
495
+ >
496
+ <ZoomIn className="w-4 h-4" />
497
+ </button>
498
+ <button
499
+ onClick={handleZoomOut}
500
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
501
+ title="Zoom out (Ctrl+scroll)"
502
+ >
503
+ <ZoomOut className="w-4 h-4" />
504
+ </button>
505
+ <span className="text-xs text-theme-text-tertiary">
506
+ {zoom < 1 ? `${Math.round(zoom * 60)}m` : zoom >= 24 ? `${Math.round(zoom / 24)}d` : `${zoom}h`} window
507
+ </span>
508
+ {panOffset > 0 && (
509
+ <button
510
+ onClick={() => setPanOffset(0)}
511
+ className="px-2 py-1 text-xs text-accent-text hover:underline hover:bg-theme-elevated rounded"
512
+ title="Jump to current time"
513
+ >
514
+ → Now
515
+ </button>
516
+ )}
517
+ </div>
518
+ {/* Sort by latest */}
519
+ <button
520
+ onClick={handleRefreshSort}
521
+ 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"
522
+ title="Re-sort by importance"
523
+ >
524
+ <ArrowUpDown className="w-3.5 h-3.5" />
525
+ Sort
526
+ </button>
527
+ </div>
528
+ <div className="flex items-center gap-4">
529
+ <span className="text-xs text-theme-text-tertiary">
530
+ {pluralize(visibleLanes.length, 'resource')} · {pluralize(filteredEvents.length, 'event')}
531
+ {searchTerm && ` (filtered)`}
532
+ </span>
533
+ {/* Group by app toggle */}
534
+ <Tooltip content="Group related resources (Deployment, Service, Pod) by their app.kubernetes.io/name label" position="bottom">
535
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
536
+ <input
537
+ type="checkbox"
538
+ checked={groupByApp}
539
+ onChange={(e) => setGroupByApp(e.target.checked)}
540
+ className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
541
+ />
542
+ <span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
543
+ </label>
544
+ </Tooltip>
545
+ {/* View toggle */}
546
+ {onViewModeChange && (
547
+ <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
548
+ <button
549
+ onClick={() => onViewModeChange('list')}
550
+ className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded-md transition-colors ${
551
+ viewMode === 'list' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
552
+ }`}
553
+ >
554
+ <List className="w-3.5 h-3.5" />
555
+ List
556
+ </button>
557
+ <button
558
+ onClick={() => onViewModeChange('swimlane')}
559
+ className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded-md transition-colors ${
560
+ viewMode === 'swimlane' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
561
+ }`}
562
+ >
563
+ <GanttChart className="w-3.5 h-3.5" />
564
+ Timeline
565
+ </button>
566
+ </div>
567
+ )}
568
+ </div>
569
+ </div>
570
+ {/* Legend */}
571
+ <div className="flex flex-wrap items-center gap-3 px-4 pb-2 text-xs text-theme-text-secondary">
572
+ <LegendItem color="bg-green-500" label="created" description="Resource was created" />
573
+ <LegendItem color="bg-blue-500" label="modified" description="Resource was updated/changed" />
574
+ <LegendItem color="bg-red-500" label="deleted" description="Resource was removed" />
575
+ <LegendItem color="bg-amber-500" label="warning" description="Warning event (CrashLoopBackOff, Failed, etc.)" />
576
+ <LegendItem color="bg-theme-text-tertiary" label="historical" description="Inferred from resource metadata (creation time, etc.)" dashed />
577
+ <span className="w-px h-3 bg-theme-border-light mx-1" />
578
+ <HealthBarLegendItem color="bg-green-500/60 dark:bg-green-600/60" label="healthy" description="Resource is fully operational" />
579
+ <HealthBarLegendItem color="bg-blue-500/60 dark:bg-blue-500/60" label="rolling" description="Expected degradation during deployment rollout" />
580
+ <HealthBarLegendItem color="bg-amber-500/60 dark:bg-[#b8861e]" label="degraded" description="Unexpected partial availability" />
581
+ <HealthBarLegendItem color="bg-red-500/60 dark:bg-red-500/60" label="unhealthy" description="Resource is failing or not ready" />
582
+ </div>
583
+ </div>
584
+
585
+ {/* Timeline container */}
586
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
587
+ <div
588
+ ref={containerRef}
589
+ className="min-w-full"
590
+ onMouseDown={handleMouseDown}
591
+ onWheel={handleWheel}
592
+ style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
593
+ >
594
+ {/* Time axis header */}
595
+ <div className="sticky top-0 z-30 bg-theme-surface border-b border-theme-border">
596
+ <div className="flex">
597
+ <div className="w-80 shrink-0 border-r border-theme-border px-3 py-2">
598
+ <span className="text-xs font-medium text-theme-text-secondary">Resource</span>
599
+ </div>
600
+ <div className="flex-1 relative h-8 mr-8">
601
+ {axisTicks.map((tick) => {
602
+ const x = timeToX(tick.time)
603
+ if (x < 0 || x > 100) return null
604
+ return (
605
+ <div
606
+ key={tick.time}
607
+ className="absolute top-0 bottom-0 flex flex-col items-center"
608
+ style={{ left: `${x}%` }}
609
+ >
610
+ <div className="h-2 w-px bg-theme-hover" />
611
+ <span className="text-xs text-theme-text-tertiary mt-0.5">{tick.label}</span>
612
+ </div>
613
+ )
614
+ })}
615
+ {/* "Now" marker in header */}
616
+ {(() => {
617
+ const nowX = timeToX(visibleTimeRange.now)
618
+ if (nowX < 0 || nowX > 100) return null
619
+ return (
620
+ <div
621
+ className="absolute top-0 bottom-0 flex flex-col items-center z-20"
622
+ style={{ left: `${nowX}%` }}
623
+ >
624
+ <div className="h-2 w-0.5 bg-purple-500" />
625
+ <span className="text-xs text-purple-500 font-medium mt-0.5">Now</span>
626
+ </div>
627
+ )
628
+ })()}
629
+ </div>
630
+ </div>
631
+ </div>
632
+
633
+ {/* Swimlanes or empty state */}
634
+ {visibleLanes.length === 0 ? (
635
+ <div className="flex flex-col items-center justify-center h-64 text-theme-text-tertiary">
636
+ <AlertCircle className="w-12 h-12 mb-4 opacity-50" />
637
+ {hasFilteredEvents ? (
638
+ <>
639
+ <p className="text-lg">No matching events</p>
640
+ <p className="text-sm mt-1">
641
+ {searchTerm ? `No results for "${searchTerm}"` : 'Try adjusting your filters'}
642
+ </p>
643
+ {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>}
644
+ </>
645
+ ) : (
646
+ <>
647
+ <p className="text-lg">No events yet</p>
648
+ <p className="text-sm mt-1">Events will appear here as resources change</p>
649
+ {namespaces && namespaces.length > 0 && (
650
+ <p className="text-sm mt-2 text-theme-text-secondary">
651
+ Filtering by namespace: <span className="font-medium text-theme-text-primary">{namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</span>
652
+ </p>
653
+ )}
654
+ {hasLimitedAccess && (
655
+ <p className="flex items-center gap-1 text-sm mt-2 text-amber-400/80">
656
+ <Shield className="w-3.5 h-3.5" />
657
+ Some resource types are not monitored due to RBAC restrictions
658
+ </p>
659
+ )}
660
+ </>
661
+ )}
662
+ </div>
663
+ ) : (
664
+ <div className="relative">
665
+ {/* "Now" line through swimlanes */}
666
+ {(() => {
667
+ const nowX = timeToX(visibleTimeRange.now)
668
+ if (nowX < 0 || nowX > 100) return null
669
+ return (
670
+ <div
671
+ className="absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-10 pointer-events-none"
672
+ style={{ left: `calc(320px + (100% - 320px - 32px) * ${nowX / 100})` }}
673
+ />
674
+ )
675
+ })()}
676
+ {visibleLanes.map((lane) => {
677
+ const isExpanded = expandedLanes.has(lane.id)
678
+ const hasChildren = lane.children && lane.children.length > 0
679
+
680
+ return (
681
+ <div key={lane.id}>
682
+ {/* Parent lane */}
683
+ <div className="border-b-subtle">
684
+ <div className="flex">
685
+ {/* Lane label */}
686
+ <div className="w-80 shrink-0 border-r border-theme-border px-3 py-2 flex items-center gap-1">
687
+ {/* Expand/collapse button */}
688
+ {hasChildren ? (
689
+ <button
690
+ onClick={() => toggleLane(lane.id)}
691
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
692
+ >
693
+ <ChevronRight className={clsx(
694
+ 'w-3 h-3 transition-transform',
695
+ isExpanded && 'rotate-90'
696
+ )} />
697
+ </button>
698
+ ) : (
699
+ <div className="w-4" />
700
+ )}
701
+ <div
702
+ className="flex-1 min-w-0 cursor-pointer hover:bg-theme-surface/30 rounded px-1 -mx-1 group"
703
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
704
+ >
705
+ <div className="flex items-center gap-1">
706
+ <span className={clsx(
707
+ 'text-xs px-1 py-0.5 rounded',
708
+ lane.isWorkload ? 'bg-accent-muted text-accent-text' : 'bg-theme-elevated text-theme-text-secondary'
709
+ )}>
710
+ {displayKind(lane.kind)}
711
+ </span>
712
+ {hasChildren && (
713
+ <span className="text-xs text-theme-text-tertiary">
714
+ +{lane.children!.length}
715
+ </span>
716
+ )}
717
+ {/* Issue count badge */}
718
+ {(() => {
719
+ const allEvents = lane.allEventsSorted || []
720
+ const issueCount = allEvents.filter(e => isCriticalIssue(e)).length
721
+ if (issueCount === 0) return null
722
+ return (
723
+ <Tooltip content={`${pluralize(issueCount, 'critical issue')} (OOMKilled, CrashLoopBackOff, etc.)`} position="top">
724
+ <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">
725
+ <AlertTriangle className="w-3 h-3" />
726
+ {issueCount}
727
+ </span>
728
+ </Tooltip>
729
+ )
730
+ })()}
731
+ </div>
732
+ <div className="text-sm text-theme-text-primary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
733
+ {lane.name}
734
+ </div>
735
+ <div className="text-xs text-theme-text-tertiary">{lane.namespace}</div>
736
+ </div>
737
+ </div>
738
+
739
+ {/* Events track - ALWAYS shows all events (summary view) */}
740
+ <div className="flex-1 relative h-12 mr-8">
741
+ {/* Health bar background layer */}
742
+ <HealthBarTrack
743
+ events={lane.allEventsSorted || []}
744
+ startTime={visibleTimeRange.start}
745
+ windowMs={visibleTimeRange.windowMs}
746
+ now={visibleTimeRange.now}
747
+ />
748
+ {/* Event markers layer (on top of health bars) */}
749
+ <div className="absolute inset-0 z-10">
750
+ {/* All events combined: own + children, pre-sorted in memo so important events render on top */}
751
+ {(lane.allEventsSorted || []).map((event, eventIdx) => {
752
+ const x = timeToX(new Date(event.timestamp).getTime())
753
+ if (x < 0 || x > 100) return null
754
+ return (
755
+ <EventMarker
756
+ key={`summary-${event.id}-${eventIdx}`}
757
+ event={event}
758
+ x={x}
759
+ selected={selectedEvent?.id === event.id}
760
+ onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
761
+ />
762
+ )
763
+ })}
764
+ </div>
765
+ </div>
766
+ </div>
767
+ </div>
768
+
769
+ {/* Child lanes (when expanded) - includes parent as first row */}
770
+ {isExpanded && hasChildren && (
771
+ <div
772
+ className="border-l-2 border-accent/40 ml-3 bg-theme-surface/30"
773
+ style={{ animation: 'swimlane-expand 250ms ease-out both' }}
774
+ >
775
+ {/* Parent's own events as first row (only if it has events) */}
776
+ {lane.events.length > 0 && (
777
+ <div className="border-b-subtle">
778
+ <div className="flex">
779
+ <div
780
+ 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"
781
+ onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
782
+ >
783
+ <div className="flex-1 min-w-0">
784
+ <div className="flex items-center gap-1">
785
+ <span className="text-xs px-1 py-0.5 rounded bg-accent-muted text-accent-text">
786
+ {displayKind(lane.kind)}
787
+ </span>
788
+ </div>
789
+ <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
790
+ {lane.name}
791
+ </div>
792
+ </div>
793
+ </div>
794
+ <div className="flex-1 relative h-10 mr-8">
795
+ {/* Health bar background layer */}
796
+ <HealthBarTrack
797
+ events={lane.events}
798
+ startTime={visibleTimeRange.start}
799
+ windowMs={visibleTimeRange.windowMs}
800
+ now={visibleTimeRange.now}
801
+ />
802
+ {/* Event markers layer */}
803
+ <div className="absolute inset-0 z-10">
804
+ {lane.events.map((event, eventIdx) => {
805
+ const x = timeToX(new Date(event.timestamp).getTime())
806
+ if (x < 0 || x > 100) return null
807
+ return (
808
+ <EventMarker
809
+ key={`expanded-${event.id}-${eventIdx}`}
810
+ event={event}
811
+ x={x}
812
+ selected={selectedEvent?.id === event.id}
813
+ onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
814
+ small
815
+ />
816
+ )
817
+ })}
818
+ </div>
819
+ </div>
820
+ </div>
821
+ </div>
822
+ )}
823
+ {/* Children */}
824
+ {lane.children!.map((child, idx) => (
825
+ <div key={child.id} className={clsx(
826
+ 'border-b-subtle',
827
+ idx === lane.children!.length - 1 && 'border-b-0'
828
+ )}>
829
+ <div className="flex">
830
+ {/* Child lane label - indented */}
831
+ <div
832
+ 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"
833
+ onClick={() => handleLaneOpen(child.kind, child.namespace, child.name, child.group)}
834
+ >
835
+ <div className="flex-1 min-w-0">
836
+ <div className="flex items-center gap-1">
837
+ <span className="text-xs px-1 py-0.5 rounded bg-theme-elevated/50 text-theme-text-secondary">
838
+ {displayKind(child.kind)}
839
+ </span>
840
+ </div>
841
+ <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
842
+ {child.name}
843
+ </div>
844
+ </div>
845
+ </div>
846
+
847
+ {/* Child events track */}
848
+ <div className="flex-1 relative h-10 mr-8">
849
+ {/* Health bar background layer */}
850
+ <HealthBarTrack
851
+ events={child.events}
852
+ startTime={visibleTimeRange.start}
853
+ windowMs={visibleTimeRange.windowMs}
854
+ now={visibleTimeRange.now}
855
+ />
856
+ {/* Event markers layer */}
857
+ <div className="absolute inset-0 z-10">
858
+ {child.events.map((event, eventIdx) => {
859
+ const x = timeToX(new Date(event.timestamp).getTime())
860
+ if (x < 0 || x > 100) return null
861
+ return (
862
+ <EventMarker
863
+ key={`${child.id}-${event.id}-${eventIdx}`}
864
+ event={event}
865
+ x={x}
866
+ selected={selectedEvent?.id === event.id}
867
+ onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)}
868
+ small
869
+ />
870
+ )
871
+ })}
872
+ </div>
873
+ </div>
874
+ </div>
875
+ </div>
876
+ ))}
877
+ </div>
878
+ )}
879
+ </div>
880
+ )
881
+ })}
882
+ </div>
883
+ )}
884
+ </div>
885
+ </div>
886
+
887
+ {/* Event detail panel */}
888
+ {selectedEvent && (
889
+ <EventDetailPanel event={selectedEvent} onClose={() => setSelectedEvent(null)} onResourceClick={onResourceClick} />
890
+ )}
891
+ </div>
892
+ )
893
+ }
894
+
895
+ // Legend item with hover tooltip
896
+ interface LegendItemProps {
897
+ color: string
898
+ label: string
899
+ description: string
900
+ dashed?: boolean
901
+ }
902
+
903
+ function LegendItem({ color, label, description, dashed }: LegendItemProps) {
904
+ return (
905
+ <Tooltip content={description} position="top">
906
+ <span className="flex items-center gap-1 cursor-help">
907
+ <span className={clsx(
908
+ 'w-2 h-2 rounded-full',
909
+ dashed ? 'border border-dashed border-current bg-transparent' : color
910
+ )} />
911
+ <span>{label}</span>
912
+ </span>
913
+ </Tooltip>
914
+ )
915
+ }
916
+
917
+ // Health bar legend item - shows a bar instead of a dot
918
+ function HealthBarLegendItem({ color, label, description }: LegendItemProps) {
919
+ return (
920
+ <Tooltip content={description} position="top">
921
+ <span className="flex items-center gap-1 cursor-help">
922
+ <span className={clsx('w-4 h-2 rounded-sm', color)} />
923
+ <span>{label}</span>
924
+ </span>
925
+ </Tooltip>
926
+ )
927
+ }
928
+
929
+ // Health bar track component that renders health spans as background
930
+ interface HealthBarTrackProps {
931
+ events: TimelineEvent[]
932
+ startTime: number
933
+ windowMs: number
934
+ now: number
935
+ }
936
+
937
+ function HealthBarTrack({ events, startTime, windowMs, now }: HealthBarTrackProps) {
938
+ // Filter to change events for health state computation
939
+ const changeEvents = events.filter(e => isChangeEvent(e))
940
+
941
+ // Build health spans from events
942
+ const { spans, createdAt, createdBeforeWindow } = buildHealthSpans(
943
+ changeEvents,
944
+ startTime,
945
+ now,
946
+ events // All events for createdAt extraction
947
+ )
948
+
949
+ if (spans.length === 0) return null
950
+
951
+ return (
952
+ <div className="absolute inset-0 z-0">
953
+ {spans.map((span, i) => {
954
+ const left = sharedTimeToX(span.start, startTime, windowMs)
955
+ const right = sharedTimeToX(span.end, startTime, windowMs)
956
+ const width = right - left
957
+
958
+ // Skip spans outside visible range
959
+ if (right < 0 || left > 100) return null
960
+
961
+ // Clamp to visible range
962
+ const clampedLeft = Math.max(0, left)
963
+ const clampedWidth = Math.min(100 - clampedLeft, width - (clampedLeft - left))
964
+
965
+ if (clampedWidth <= 0) return null
966
+
967
+ return (
968
+ <HealthSpan
969
+ key={i}
970
+ health={span.health}
971
+ left={clampedLeft}
972
+ width={clampedWidth}
973
+ createdBefore={createdBeforeWindow && i === 0 ? new Date(createdAt!) : undefined}
974
+ />
975
+ )
976
+ })}
977
+ </div>
978
+ )
979
+ }
980
+
981
+ // Critical issue reasons that should be prominently highlighted with icons
982
+ // This should align with PROBLEMATIC_REASONS in resource-hierarchy.ts
983
+ const CRITICAL_ISSUE_REASONS = new Set([
984
+ // Container state issues
985
+ 'BackOff', 'CrashLoopBackOff', 'Failed', 'Error',
986
+ 'OOMKilling', 'OOMKilled',
987
+ 'CreateContainerConfigError', 'CreateContainerError', 'RunContainerError',
988
+ 'InvalidImageName', 'ErrImagePull', 'ImagePullBackOff',
989
+ 'ContainerStatusUnknown',
990
+
991
+ // Pod scheduling/lifecycle issues
992
+ 'FailedScheduling', 'FailedMount', 'FailedAttachVolume',
993
+ 'FailedCreate', 'FailedDelete', 'Unhealthy', 'Killing', 'Evicted',
994
+ 'FailedSync', 'FailedValidation',
995
+ 'FailedPreStopHook', 'FailedPostStartHook',
996
+ 'HostPortConflict', 'InsufficientMemory', 'InsufficientCPU',
997
+
998
+ // Node conditions
999
+ 'NodeNotReady', 'NetworkNotReady', 'KubeletNotReady',
1000
+ 'MemoryPressure', 'DiskPressure', 'PIDPressure',
1001
+ 'NodeStatusUnknown',
1002
+
1003
+ // Deployment/workload issues
1004
+ 'ProgressDeadlineExceeded', 'ReplicaFailure',
1005
+ 'MinimumReplicasUnavailable',
1006
+
1007
+ // HPA issues
1008
+ 'FailedGetScale', 'FailedRescale', 'FailedUpdateScale',
1009
+ 'FailedGetResourceMetric', 'FailedComputeMetricsReplicas',
1010
+
1011
+ // PVC/storage issues
1012
+ 'ProvisioningFailed', 'FailedBinding', 'VolumeFailedDelete',
1013
+
1014
+ // Job issues
1015
+ 'DeadlineExceeded', 'BackoffLimitExceeded',
1016
+ ])
1017
+
1018
+ // Get the appropriate icon for a critical issue
1019
+ function getIssueIcon(reason: string | undefined): React.ComponentType<{ className?: string }> | null {
1020
+ if (!reason) return null
1021
+
1022
+ // Memory issues (OOM)
1023
+ if (reason === 'OOMKilled' || reason === 'OOMKilling' ||
1024
+ reason === 'InsufficientMemory' || reason === 'MemoryPressure') return MemoryStick
1025
+
1026
+ // Crash/restart issues
1027
+ if (reason === 'CrashLoopBackOff' || reason === 'BackOff') return RefreshCw
1028
+
1029
+ // Image pull issues
1030
+ if (reason === 'ImagePullBackOff' || reason === 'ErrImagePull' || reason === 'InvalidImageName') return Package
1031
+
1032
+ // Container creation/runtime errors
1033
+ if (reason === 'CreateContainerConfigError' || reason === 'CreateContainerError' ||
1034
+ reason === 'RunContainerError' || reason === 'ContainerStatusUnknown') return Box
1035
+
1036
+ // Scheduling/mount/node issues
1037
+ if (reason === 'FailedScheduling' || reason === 'FailedMount' || reason === 'FailedAttachVolume' ||
1038
+ reason === 'NodeNotReady' || reason === 'NetworkNotReady' || reason === 'KubeletNotReady' ||
1039
+ reason === 'NodeStatusUnknown' || reason === 'HostPortConflict') return Ban
1040
+
1041
+ // Resource pressure (disk, CPU, PID)
1042
+ if (reason === 'DiskPressure' || reason === 'PIDPressure' || reason === 'InsufficientCPU') return Gauge
1043
+
1044
+ // Deployment rollout issues
1045
+ if (reason === 'ProgressDeadlineExceeded' || reason === 'ReplicaFailure' ||
1046
+ reason === 'MinimumReplicasUnavailable') return RotateCcw
1047
+
1048
+ // HPA scaling issues
1049
+ if (reason === 'FailedGetScale' || reason === 'FailedRescale' || reason === 'FailedUpdateScale' ||
1050
+ reason === 'FailedGetResourceMetric' || reason === 'FailedComputeMetricsReplicas') return Gauge
1051
+
1052
+ // PVC/storage issues
1053
+ if (reason === 'ProvisioningFailed' || reason === 'FailedBinding' || reason === 'VolumeFailedDelete') return HardDrive
1054
+
1055
+ // Job timeout issues
1056
+ if (reason === 'DeadlineExceeded' || reason === 'BackoffLimitExceeded') return Timer
1057
+
1058
+ // Probe failures and general unhealthy
1059
+ if (reason === 'Unhealthy') return AlertTriangle
1060
+
1061
+ // General failures - use warning circle
1062
+ if (reason.startsWith('Failed') || reason === 'Evicted' || reason === 'Killing' || reason === 'Error') return AlertCircle
1063
+
1064
+ return null
1065
+ }
1066
+
1067
+ // Check if event is a critical issue that deserves special highlighting
1068
+ function isCriticalIssue(event: TimelineEvent): boolean {
1069
+ return !!(event.reason && CRITICAL_ISSUE_REASONS.has(event.reason))
1070
+ }
1071
+
1072
+ interface EventMarkerProps {
1073
+ event: TimelineEvent
1074
+ x: number
1075
+ selected?: boolean
1076
+ onClick: () => void
1077
+ dimmed?: boolean // For aggregated child events
1078
+ small?: boolean // For child lane events
1079
+ }
1080
+
1081
+ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarkerProps) {
1082
+ const isChange = isChangeEvent(event)
1083
+ const isProblematic = isProblematicEvent(event) // Includes warnings + problematic reasons like BackOff
1084
+ const isHistorical = isHistoricalEvent(event)
1085
+ const isCritical = isCriticalIssue(event)
1086
+ const IssueIcon = getIssueIcon(event.reason)
1087
+
1088
+ const getMarkerStyle = () => {
1089
+ // Historical events use outline style (border instead of fill)
1090
+ // Non-historical use solid fill
1091
+ if (isHistorical) {
1092
+ // Outline style for historical - visible border, subtle background
1093
+ if (isProblematic) {
1094
+ return 'bg-amber-500/20 border-2 border-dashed border-amber-500/60'
1095
+ }
1096
+ if (isChange) {
1097
+ switch (event.eventType) {
1098
+ case 'add':
1099
+ return 'bg-green-500/20 border-2 border-dashed border-green-500/60'
1100
+ case 'delete':
1101
+ return 'bg-red-500/20 border-2 border-dashed border-red-500/60'
1102
+ case 'update':
1103
+ return 'bg-skyhook-500/20 border-2 border-dashed border-skyhook-500/60'
1104
+ }
1105
+ }
1106
+ return 'bg-theme-hover/30 border-2 border-dashed border-theme-border-light'
1107
+ }
1108
+
1109
+ // Critical issues get red background to stand out
1110
+ if (isCritical) {
1111
+ return 'bg-red-500'
1112
+ }
1113
+
1114
+ // Solid fill for real-time events.
1115
+ // Problematic events (warnings, BackOff, etc.) are always amber/orange.
1116
+ if (isProblematic) {
1117
+ return dimmed ? 'bg-amber-500/50' : 'bg-amber-500'
1118
+ }
1119
+ if (isChange) {
1120
+ switch (event.eventType) {
1121
+ case 'add':
1122
+ return dimmed ? 'bg-green-500/50' : 'bg-green-500'
1123
+ case 'delete':
1124
+ return dimmed ? 'bg-red-500/50' : 'bg-red-500'
1125
+ case 'update':
1126
+ return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
1127
+ }
1128
+ }
1129
+ return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
1130
+ }
1131
+
1132
+ const markerClasses = getMarkerStyle()
1133
+
1134
+ // Build tooltip text - focus on what happened, explain the color meaning
1135
+ const getRelativeTime = (timestamp: string) => {
1136
+ const diff = Date.now() - new Date(timestamp).getTime()
1137
+ const mins = Math.floor(diff / 60000)
1138
+ if (mins < 1) return 'just now'
1139
+ if (mins < 60) return `${mins}m ago`
1140
+ const hours = Math.floor(mins / 60)
1141
+ if (hours < 24) return `${hours}h ago`
1142
+ return `${Math.floor(hours / 24)}d ago`
1143
+ }
1144
+
1145
+ // Get human-readable operation label with color indicator
1146
+ const getOperationLabel = () => {
1147
+ if (isProblematic) {
1148
+ return `⚠ ${event.reason || 'Warning'}`
1149
+ }
1150
+ if (isChange) {
1151
+ switch (event.eventType) {
1152
+ case 'add': return '● Created'
1153
+ case 'delete': return '● Deleted'
1154
+ case 'update': return '● Modified'
1155
+ default: return '● Changed'
1156
+ }
1157
+ }
1158
+ if (event.reason) {
1159
+ return `● ${event.reason}`
1160
+ }
1161
+ return '● Event'
1162
+ }
1163
+
1164
+ const tooltipLines: string[] = []
1165
+ tooltipLines.push(getOperationLabel())
1166
+ if (event.message) {
1167
+ // Truncate long messages
1168
+ const msg = event.message.length > 60 ? event.message.slice(0, 60) + '...' : event.message
1169
+ tooltipLines.push(msg)
1170
+ }
1171
+ tooltipLines.push(getRelativeTime(event.timestamp))
1172
+ if (isHistoricalEvent(event)) tooltipLines.push('(from metadata)')
1173
+
1174
+ const tooltipText = tooltipLines.join(' · ')
1175
+
1176
+ // Critical issues get larger markers with icons
1177
+ if (isCritical && IssueIcon && !small) {
1178
+ return (
1179
+ <Tooltip
1180
+ content={tooltipText}
1181
+ position="top"
1182
+ delay={100}
1183
+ wrapperClassName="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-20"
1184
+ wrapperStyle={{ left: `${x}%` }}
1185
+ >
1186
+ <button
1187
+ className={clsx(
1188
+ 'rounded-full transition-all flex items-center justify-center',
1189
+ 'w-5 h-5',
1190
+ markerClasses,
1191
+ selected ? 'ring-2 ring-white ring-offset-2 ring-offset-theme-base scale-125' : 'hover:scale-110',
1192
+ 'shadow-sm'
1193
+ )}
1194
+ onClick={(e) => {
1195
+ e.stopPropagation()
1196
+ onClick()
1197
+ }}
1198
+ >
1199
+ <IssueIcon className="w-3 h-3 text-white" />
1200
+ </button>
1201
+ </Tooltip>
1202
+ )
1203
+ }
1204
+
1205
+ return (
1206
+ <Tooltip
1207
+ content={tooltipText}
1208
+ position="top"
1209
+ delay={100}
1210
+ wrapperClassName={clsx(
1211
+ 'absolute top-1/2 -translate-y-1/2 -translate-x-1/2',
1212
+ dimmed ? 'z-5' : isHistorical ? 'z-5' : 'z-10'
1213
+ )}
1214
+ wrapperStyle={{ left: `${x}%` }}
1215
+ >
1216
+ <button
1217
+ className={clsx(
1218
+ 'rounded-full transition-all',
1219
+ small ? 'w-2.5 h-2.5' : 'w-3 h-3',
1220
+ markerClasses,
1221
+ selected ? 'ring-2 ring-white ring-offset-2 ring-offset-theme-base scale-150' : 'hover:scale-125'
1222
+ )}
1223
+ onClick={(e) => {
1224
+ e.stopPropagation()
1225
+ onClick()
1226
+ }}
1227
+ />
1228
+ </Tooltip>
1229
+ )
1230
+ }
1231
+
1232
+ interface EventDetailPanelProps {
1233
+ event: TimelineEvent
1234
+ onClose: () => void
1235
+ onResourceClick?: NavigateToResource
1236
+ }
1237
+
1238
+ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelProps) {
1239
+ const isChange = isChangeEvent(event)
1240
+ const isHistorical = isHistoricalEvent(event)
1241
+ const isProblematic = isProblematicEvent(event)
1242
+
1243
+ return (
1244
+ <div className={clsx(
1245
+ "fixed bottom-0 left-0 right-0 z-50 border-t p-4 max-h-72 overflow-auto shadow-theme-lg",
1246
+ isProblematic ? "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950" : "border-theme-border bg-theme-surface"
1247
+ )}>
1248
+ <div className="flex items-start justify-between mb-3">
1249
+ <div>
1250
+ <div className="flex items-center gap-2">
1251
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary">
1252
+ {displayKind(event.kind)}
1253
+ </span>
1254
+ <button
1255
+ onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name, group: apiVersionToGroup(event.apiVersion) })}
1256
+ className="text-theme-text-primary font-medium hover:text-accent-text"
1257
+ >
1258
+ {event.name}
1259
+ </button>
1260
+ {event.namespace && (
1261
+ <span className="text-xs text-theme-text-tertiary">in {event.namespace}</span>
1262
+ )}
1263
+ {isHistorical && (
1264
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">
1265
+ <Clock className="w-3 h-3" />
1266
+ historical
1267
+ </span>
1268
+ )}
1269
+ </div>
1270
+ <div className="text-xs text-theme-text-tertiary mt-1">
1271
+ {formatFullTime(new Date(event.timestamp))}
1272
+ {isHistorical && event.reason && (
1273
+ <span className="ml-2 text-theme-text-secondary">({event.reason})</span>
1274
+ )}
1275
+ </div>
1276
+ </div>
1277
+ <button
1278
+ onClick={onClose}
1279
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
1280
+ title="Close (Esc)"
1281
+ >
1282
+ <X className="w-4 h-4" />
1283
+ </button>
1284
+ </div>
1285
+
1286
+ {isChange ? (
1287
+ <div className="space-y-2">
1288
+ <div className="flex items-center gap-2">
1289
+ <span className={clsx('text-sm font-medium', isOperation(event.eventType) && getOperationColor(event.eventType))}>
1290
+ {event.eventType}
1291
+ </span>
1292
+ {event.healthState && event.healthState !== 'unknown' && (
1293
+ <span className={clsx('badge-sm', getHealthBadgeColor(event.healthState))}>
1294
+ {event.healthState}
1295
+ </span>
1296
+ )}
1297
+ </div>
1298
+ {event.diff && <DiffViewer diff={event.diff} />}
1299
+ </div>
1300
+ ) : (
1301
+ <div className="space-y-2">
1302
+ <div className="flex items-center gap-2">
1303
+ <span className={clsx('text-sm font-medium', isProblematic ? 'text-amber-700 dark:text-amber-300' : 'text-green-700 dark:text-green-300')}>
1304
+ {event.reason}
1305
+ </span>
1306
+ {event.eventType && (
1307
+ <span className={clsx('badge-sm', getEventTypeColor(event.eventType))}>
1308
+ {event.eventType}
1309
+ </span>
1310
+ )}
1311
+ {event.count && event.count > 1 && (
1312
+ <span className="text-xs text-theme-text-tertiary">x{event.count}</span>
1313
+ )}
1314
+ </div>
1315
+ {event.message && <p className={clsx("text-sm", isProblematic ? "text-amber-700 dark:text-amber-200" : "text-theme-text-secondary")}>{event.message}</p>}
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ )
1320
+ }