@skyhook-io/radar-app 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +111 -58
- package/src/api/client.ts +29 -1
- package/src/components/ConnectionErrorView.tsx +2 -2
- package/src/components/helm/ChartBrowser.tsx +7 -3
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +2 -2
- package/src/components/home/ClusterHealthCard.tsx +1 -1
- package/src/components/home/HomeView.tsx +14 -3
- package/src/components/home/MCPSetupDialog.tsx +4 -4
- package/src/components/issues/IssuesPane.tsx +78 -0
- package/src/components/portforward/PortForwardButton.tsx +1 -1
- package/src/components/portforward/PortForwardManager.tsx +1 -1
- package/src/components/resource/PrometheusCharts.tsx +18 -159
- package/src/components/resources/ImageFilesystemModal.tsx +1 -2
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -2
- package/src/components/settings/MyPermissionsDialog.tsx +1 -1
- package/src/components/settings/SettingsDialog.tsx +22 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +8 -1311
- package/src/components/ui/Markdown.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +188 -6
|
@@ -1,1316 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|