@skyhook-io/radar-app 1.3.1 → 1.3.3

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