@skyhook-io/k8s-ui 1.3.0 → 1.3.2
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 +2 -2
- package/src/components/dock/BottomDock.tsx +23 -5
- package/src/components/dock/DockContext.tsx +9 -1
- package/src/components/logs/LogsViewer.tsx +8 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -4
- package/src/components/timeline/DiffViewer.tsx +131 -0
- package/src/components/timeline/TimelineList.tsx +817 -0
- package/src/components/timeline/index.ts +2 -0
- package/src/components/topology/GroupNode.tsx +189 -79
- package/src/components/topology/TopologyGraph.tsx +286 -52
- package/src/components/topology/layout.ts +288 -92
- package/src/components/topology/topology.css +111 -0
- package/src/components/ui/Toast.tsx +74 -27
- package/src/theme/variables.css +7 -2
- package/src/types/core.ts +39 -0
- package/src/utils/download.ts +18 -3
- package/tsconfig.json +0 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import { useState, useMemo, useRef, useEffect } from 'react'
|
|
2
|
+
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
|
+
import {
|
|
4
|
+
AlertCircle,
|
|
5
|
+
CheckCircle,
|
|
6
|
+
Clock,
|
|
7
|
+
Search,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Filter,
|
|
11
|
+
Plus,
|
|
12
|
+
Trash2,
|
|
13
|
+
List,
|
|
14
|
+
GanttChart,
|
|
15
|
+
Shield,
|
|
16
|
+
} from 'lucide-react'
|
|
17
|
+
import { clsx } from 'clsx'
|
|
18
|
+
import { DiffViewer, DiffBadge } from './DiffViewer'
|
|
19
|
+
import type { TimelineEvent, TimeRange } from '../../types'
|
|
20
|
+
import { isChangeEvent, isK8sEvent, isHistoricalEvent, isOperation } from '../../types'
|
|
21
|
+
import { getOperationColor, getHealthBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
22
|
+
import { ResourceRefBadge } from '../ui/drawer-components'
|
|
23
|
+
import type { NavigateToResource } from '../../utils/navigation'
|
|
24
|
+
import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
|
|
25
|
+
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
26
|
+
|
|
27
|
+
/** Format resource age (e.g., "3d", "5h", "10m") */
|
|
28
|
+
function formatResourceAge(createdAt: string): string {
|
|
29
|
+
const diff = Date.now() - new Date(createdAt).getTime()
|
|
30
|
+
const mins = Math.floor(diff / 60000)
|
|
31
|
+
if (mins < 1) return '<1m'
|
|
32
|
+
if (mins < 60) return `${mins}m`
|
|
33
|
+
const hours = Math.floor(mins / 60)
|
|
34
|
+
if (hours < 24) return `${hours}h`
|
|
35
|
+
const days = Math.floor(hours / 24)
|
|
36
|
+
if (days < 30) return `${days}d`
|
|
37
|
+
const months = Math.floor(days / 30)
|
|
38
|
+
return `${months}mo`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ActivityTypeFilter = 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy'
|
|
42
|
+
|
|
43
|
+
export interface TimelineListProps {
|
|
44
|
+
events: TimelineEvent[]
|
|
45
|
+
isLoading: boolean
|
|
46
|
+
onRefresh?: () => void
|
|
47
|
+
onQueryChange?: (params: { timeRange: TimeRange; kind?: string }) => void
|
|
48
|
+
hasLimitedAccess?: boolean
|
|
49
|
+
namespaces?: string[]
|
|
50
|
+
onViewChange?: (view: 'list' | 'swimlane') => void
|
|
51
|
+
currentView?: 'list' | 'swimlane'
|
|
52
|
+
onResourceClick?: NavigateToResource
|
|
53
|
+
initialFilter?: ActivityTypeFilter
|
|
54
|
+
initialTimeRange?: TimeRange
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
58
|
+
{ value: '5m', label: '5 min' },
|
|
59
|
+
{ value: '30m', label: '30 min' },
|
|
60
|
+
{ value: '1h', label: '1 hour' },
|
|
61
|
+
{ value: '6h', label: '6 hours' },
|
|
62
|
+
{ value: '24h', label: '24 hours' },
|
|
63
|
+
{ value: 'all', label: 'All' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const RESOURCE_KINDS = [
|
|
67
|
+
'Deployment',
|
|
68
|
+
'Pod',
|
|
69
|
+
'Service',
|
|
70
|
+
'ConfigMap',
|
|
71
|
+
'Ingress',
|
|
72
|
+
'Gateway',
|
|
73
|
+
'HTTPRoute',
|
|
74
|
+
'GRPCRoute',
|
|
75
|
+
'TCPRoute',
|
|
76
|
+
'TLSRoute',
|
|
77
|
+
'ReplicaSet',
|
|
78
|
+
'DaemonSet',
|
|
79
|
+
'StatefulSet',
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
|
|
83
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
84
|
+
const [activityTypeFilter, setActivityTypeFilter] = useState<ActivityTypeFilter>(initialFilter ?? 'all')
|
|
85
|
+
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
|
|
86
|
+
const [kindFilter, setKindFilter] = useState<string>('')
|
|
87
|
+
const [expandedItem, setExpandedItem] = useState<string | null>(null)
|
|
88
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
onQueryChange?.({ timeRange, kind: kindFilter || undefined })
|
|
92
|
+
}, [timeRange, kindFilter, onQueryChange])
|
|
93
|
+
|
|
94
|
+
// Keyboard shortcut: / to focus search
|
|
95
|
+
useRegisterShortcut({
|
|
96
|
+
id: 'timeline-list-search',
|
|
97
|
+
keys: '/',
|
|
98
|
+
description: 'Focus search',
|
|
99
|
+
category: 'Search',
|
|
100
|
+
scope: 'timeline',
|
|
101
|
+
handler: () => searchInputRef.current?.focus(),
|
|
102
|
+
})
|
|
103
|
+
useRegisterShortcut({
|
|
104
|
+
id: 'timeline-list-escape',
|
|
105
|
+
keys: 'Escape',
|
|
106
|
+
description: 'Blur search',
|
|
107
|
+
category: 'Search',
|
|
108
|
+
scope: 'timeline',
|
|
109
|
+
handler: () => searchInputRef.current?.blur(),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
|
|
113
|
+
|
|
114
|
+
// Filter activity
|
|
115
|
+
const filteredActivity = useMemo(() => {
|
|
116
|
+
if (!events) return []
|
|
117
|
+
|
|
118
|
+
return events.filter((item) => {
|
|
119
|
+
// Filter by activity type
|
|
120
|
+
if (activityTypeFilter === 'changes' && !isChangeEvent(item)) return false
|
|
121
|
+
if (activityTypeFilter === 'k8s_events' && !isK8sEvent(item)) return false
|
|
122
|
+
if (activityTypeFilter === 'warnings') {
|
|
123
|
+
// Warnings filter: only K8s Warning events (matches home page count)
|
|
124
|
+
if (item.eventType !== 'Warning') return false
|
|
125
|
+
}
|
|
126
|
+
if (activityTypeFilter === 'unhealthy') {
|
|
127
|
+
// Unhealthy filter: only changes with unhealthy/degraded health state (no K8s events)
|
|
128
|
+
const isUnhealthyChange = isChangeEvent(item) && (item.healthState === 'unhealthy' || item.healthState === 'degraded')
|
|
129
|
+
if (!isUnhealthyChange) return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Filter by search term
|
|
133
|
+
if (searchTerm) {
|
|
134
|
+
const term = searchTerm.toLowerCase()
|
|
135
|
+
const matchesName = item.name.toLowerCase().includes(term)
|
|
136
|
+
const matchesKind = item.kind.toLowerCase().includes(term)
|
|
137
|
+
const matchesNamespace = item.namespace?.toLowerCase().includes(term)
|
|
138
|
+
const matchesReason = item.reason?.toLowerCase().includes(term)
|
|
139
|
+
const matchesMessage = item.message?.toLowerCase().includes(term)
|
|
140
|
+
const matchesSummary = item.diff?.summary?.toLowerCase().includes(term)
|
|
141
|
+
|
|
142
|
+
if (!matchesName && !matchesKind && !matchesNamespace && !matchesReason && !matchesMessage && !matchesSummary) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true
|
|
148
|
+
})
|
|
149
|
+
}, [events, activityTypeFilter, searchTerm])
|
|
150
|
+
|
|
151
|
+
// Aggregated event group type
|
|
152
|
+
type AggregatedItem = {
|
|
153
|
+
type: 'single'
|
|
154
|
+
item: TimelineEvent
|
|
155
|
+
} | {
|
|
156
|
+
type: 'aggregated'
|
|
157
|
+
first: TimelineEvent
|
|
158
|
+
last: TimelineEvent
|
|
159
|
+
count: number
|
|
160
|
+
reason: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Aggregate repeated events for the same resource with the same reason
|
|
164
|
+
const aggregateEvents = (items: TimelineEvent[]): AggregatedItem[] => {
|
|
165
|
+
if (items.length === 0) return []
|
|
166
|
+
|
|
167
|
+
// Group events by resource+reason
|
|
168
|
+
const groups = new Map<string, TimelineEvent[]>()
|
|
169
|
+
const singleEvents: TimelineEvent[] = []
|
|
170
|
+
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
// Only aggregate K8s Warning events or changes with a specific reason
|
|
173
|
+
const reason = item.reason || ''
|
|
174
|
+
const shouldAggregate = (
|
|
175
|
+
item.eventType === 'Warning' ||
|
|
176
|
+
(isChangeEvent(item) && reason && ['OOMKilled', 'CrashLoopBackOff', 'BackOff', 'FailedScheduling', 'Unhealthy'].includes(reason))
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if (shouldAggregate && reason) {
|
|
180
|
+
const key = `${item.kind}:${item.namespace}:${item.name}:${reason}`
|
|
181
|
+
const existing = groups.get(key) || []
|
|
182
|
+
existing.push(item)
|
|
183
|
+
groups.set(key, existing)
|
|
184
|
+
} else {
|
|
185
|
+
singleEvents.push(item)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convert to aggregated items
|
|
190
|
+
const result: AggregatedItem[] = []
|
|
191
|
+
|
|
192
|
+
// Process aggregated groups
|
|
193
|
+
for (const events of groups.values()) {
|
|
194
|
+
if (events.length >= 2) {
|
|
195
|
+
// Sort by time (oldest first)
|
|
196
|
+
events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
197
|
+
result.push({
|
|
198
|
+
type: 'aggregated',
|
|
199
|
+
first: events[0],
|
|
200
|
+
last: events[events.length - 1],
|
|
201
|
+
count: events.length,
|
|
202
|
+
reason: events[0].reason || '',
|
|
203
|
+
})
|
|
204
|
+
} else {
|
|
205
|
+
result.push({ type: 'single', item: events[0] })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Add single events
|
|
210
|
+
for (const item of singleEvents) {
|
|
211
|
+
result.push({ type: 'single', item })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Sort all by most recent (last event time)
|
|
215
|
+
result.sort((a, b) => {
|
|
216
|
+
const timeA = a.type === 'aggregated' ? new Date(a.last.timestamp).getTime() : new Date(a.item.timestamp).getTime()
|
|
217
|
+
const timeB = b.type === 'aggregated' ? new Date(b.last.timestamp).getTime() : new Date(b.item.timestamp).getTime()
|
|
218
|
+
return timeB - timeA
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Group activity by time period
|
|
225
|
+
const groupedActivity = useMemo(() => {
|
|
226
|
+
const groups: { label: string; items: AggregatedItem[] }[] = []
|
|
227
|
+
const now = Date.now()
|
|
228
|
+
|
|
229
|
+
const last5min: TimelineEvent[] = []
|
|
230
|
+
const last30min: TimelineEvent[] = []
|
|
231
|
+
const lastHour: TimelineEvent[] = []
|
|
232
|
+
const today: TimelineEvent[] = []
|
|
233
|
+
const older: TimelineEvent[] = []
|
|
234
|
+
|
|
235
|
+
for (const item of filteredActivity) {
|
|
236
|
+
const itemTime = new Date(item.timestamp).getTime()
|
|
237
|
+
const diffMs = now - itemTime
|
|
238
|
+
const diffMins = diffMs / 60000
|
|
239
|
+
const diffHours = diffMins / 60
|
|
240
|
+
|
|
241
|
+
if (diffMins < 5) {
|
|
242
|
+
last5min.push(item)
|
|
243
|
+
} else if (diffMins < 30) {
|
|
244
|
+
last30min.push(item)
|
|
245
|
+
} else if (diffHours < 1) {
|
|
246
|
+
lastHour.push(item)
|
|
247
|
+
} else if (diffHours < 24) {
|
|
248
|
+
today.push(item)
|
|
249
|
+
} else {
|
|
250
|
+
older.push(item)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (last5min.length > 0) groups.push({ label: 'Last 5 minutes', items: aggregateEvents(last5min) })
|
|
255
|
+
if (last30min.length > 0) groups.push({ label: 'Last 30 minutes', items: aggregateEvents(last30min) })
|
|
256
|
+
if (lastHour.length > 0) groups.push({ label: 'Last hour', items: aggregateEvents(lastHour) })
|
|
257
|
+
if (today.length > 0) groups.push({ label: 'Today', items: aggregateEvents(today) })
|
|
258
|
+
if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
|
|
259
|
+
|
|
260
|
+
return groups
|
|
261
|
+
}, [filteredActivity])
|
|
262
|
+
|
|
263
|
+
// Count stats
|
|
264
|
+
const stats = useMemo(() => {
|
|
265
|
+
if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0 }
|
|
266
|
+
return {
|
|
267
|
+
total: events.length,
|
|
268
|
+
changes: events.filter((e) => isChangeEvent(e)).length,
|
|
269
|
+
warnings: events.filter((e) => e.eventType === 'Warning').length,
|
|
270
|
+
unhealthy: events.filter((e) => isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')).length,
|
|
271
|
+
}
|
|
272
|
+
}, [events])
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="flex flex-col h-full w-full">
|
|
276
|
+
{/* Toolbar */}
|
|
277
|
+
<div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 flex-wrap">
|
|
278
|
+
{/* Search */}
|
|
279
|
+
<div className="flex-1 relative min-w-[200px]">
|
|
280
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
281
|
+
<input
|
|
282
|
+
ref={searchInputRef}
|
|
283
|
+
type="text"
|
|
284
|
+
placeholder="Search... (press /)"
|
|
285
|
+
value={searchTerm}
|
|
286
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
287
|
+
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* Activity type filter */}
|
|
292
|
+
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
293
|
+
<FilterButton
|
|
294
|
+
active={activityTypeFilter === 'all'}
|
|
295
|
+
onClick={() => setActivityTypeFilter('all')}
|
|
296
|
+
icon={<Filter className="w-3 h-3" />}
|
|
297
|
+
label="All"
|
|
298
|
+
tooltip="Show all activity: resource changes and K8s events"
|
|
299
|
+
/>
|
|
300
|
+
<FilterButton
|
|
301
|
+
active={activityTypeFilter === 'changes'}
|
|
302
|
+
onClick={() => setActivityTypeFilter('changes')}
|
|
303
|
+
icon={<RefreshCw className="w-3 h-3" />}
|
|
304
|
+
label="Changes"
|
|
305
|
+
count={stats.changes}
|
|
306
|
+
color="blue"
|
|
307
|
+
tooltip="Resource mutations: creates, updates, deletes detected by watching K8s API"
|
|
308
|
+
/>
|
|
309
|
+
<FilterButton
|
|
310
|
+
active={activityTypeFilter === 'warnings'}
|
|
311
|
+
onClick={() => setActivityTypeFilter('warnings')}
|
|
312
|
+
icon={<AlertCircle className="w-3 h-3" />}
|
|
313
|
+
label="Warning Events"
|
|
314
|
+
count={stats.warnings}
|
|
315
|
+
color="amber"
|
|
316
|
+
tooltip="Native Kubernetes Warning events (e.g., ImagePullBackOff, FailedScheduling)"
|
|
317
|
+
/>
|
|
318
|
+
<FilterButton
|
|
319
|
+
active={activityTypeFilter === 'unhealthy'}
|
|
320
|
+
onClick={() => setActivityTypeFilter('unhealthy')}
|
|
321
|
+
icon={<AlertCircle className="w-3 h-3" />}
|
|
322
|
+
label="Unhealthy"
|
|
323
|
+
count={stats.unhealthy}
|
|
324
|
+
color="red"
|
|
325
|
+
tooltip="Resource changes with unhealthy or degraded health state"
|
|
326
|
+
/>
|
|
327
|
+
<FilterButton
|
|
328
|
+
active={activityTypeFilter === 'k8s_events'}
|
|
329
|
+
onClick={() => setActivityTypeFilter('k8s_events')}
|
|
330
|
+
icon={<CheckCircle className="w-3 h-3" />}
|
|
331
|
+
label="K8s Events"
|
|
332
|
+
tooltip="All native Kubernetes events (Normal + Warning types)"
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* Kind filter */}
|
|
337
|
+
<select
|
|
338
|
+
value={kindFilter}
|
|
339
|
+
onChange={(e) => setKindFilter(e.target.value)}
|
|
340
|
+
className="appearance-none bg-theme-elevated text-theme-text-primary text-sm rounded-lg px-3 py-2 border border-theme-border-light focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
341
|
+
>
|
|
342
|
+
<option value="">All Kinds</option>
|
|
343
|
+
{RESOURCE_KINDS.map((kind) => (
|
|
344
|
+
<option key={kind} value={kind}>
|
|
345
|
+
{kind}
|
|
346
|
+
</option>
|
|
347
|
+
))}
|
|
348
|
+
</select>
|
|
349
|
+
|
|
350
|
+
{/* Time range */}
|
|
351
|
+
<select
|
|
352
|
+
value={timeRange}
|
|
353
|
+
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
|
354
|
+
className="appearance-none bg-theme-elevated text-theme-text-primary text-sm rounded-lg px-3 py-2 border border-theme-border-light focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
355
|
+
>
|
|
356
|
+
{TIME_RANGES.map((range) => (
|
|
357
|
+
<option key={range.value} value={range.value}>
|
|
358
|
+
{range.label}
|
|
359
|
+
</option>
|
|
360
|
+
))}
|
|
361
|
+
</select>
|
|
362
|
+
|
|
363
|
+
{/* View toggle */}
|
|
364
|
+
{onViewChange && (
|
|
365
|
+
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => onViewChange('list')}
|
|
368
|
+
className={clsx(
|
|
369
|
+
'p-2 rounded-md transition-colors',
|
|
370
|
+
currentView === 'list' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
371
|
+
)}
|
|
372
|
+
title="List view"
|
|
373
|
+
>
|
|
374
|
+
<List className="w-4 h-4" />
|
|
375
|
+
</button>
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => onViewChange('swimlane')}
|
|
378
|
+
className={clsx(
|
|
379
|
+
'p-2 rounded-md transition-colors',
|
|
380
|
+
currentView === 'swimlane' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
381
|
+
)}
|
|
382
|
+
title="Swimlane view"
|
|
383
|
+
>
|
|
384
|
+
<GanttChart className="w-4 h-4" />
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
{/* Refresh */}
|
|
390
|
+
{onRefresh && (
|
|
391
|
+
<button
|
|
392
|
+
onClick={handleRefresh}
|
|
393
|
+
disabled={isRefreshAnimating}
|
|
394
|
+
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50"
|
|
395
|
+
title="Refresh"
|
|
396
|
+
>
|
|
397
|
+
<RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
|
|
398
|
+
</button>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Timeline content */}
|
|
403
|
+
<div className="flex-1 overflow-auto">
|
|
404
|
+
{isLoading ? (
|
|
405
|
+
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
406
|
+
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
407
|
+
Loading timeline...
|
|
408
|
+
</div>
|
|
409
|
+
) : filteredActivity.length === 0 ? (
|
|
410
|
+
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
|
|
411
|
+
<Clock className="w-12 h-12 mb-4 opacity-50" />
|
|
412
|
+
<p className="text-lg">No activity found</p>
|
|
413
|
+
<p className="text-sm mt-2">
|
|
414
|
+
{searchTerm || activityTypeFilter !== 'all' || kindFilter
|
|
415
|
+
? 'Try adjusting your filters'
|
|
416
|
+
: 'Activity will appear here when cluster changes occur'}
|
|
417
|
+
</p>
|
|
418
|
+
{hasLimitedAccess && !searchTerm && activityTypeFilter === 'all' && !kindFilter && (
|
|
419
|
+
<p className="flex items-center gap-1 text-sm mt-2 text-amber-400/80">
|
|
420
|
+
<Shield className="w-3.5 h-3.5" />
|
|
421
|
+
Some resource types are not monitored due to RBAC restrictions
|
|
422
|
+
</p>
|
|
423
|
+
)}
|
|
424
|
+
{namespaces && namespaces.length > 0 && (
|
|
425
|
+
<p className="text-sm mt-2 text-theme-text-secondary">
|
|
426
|
+
Filtering by namespace: <span className="font-medium text-theme-text-primary">{namespaces.length === 1 ? namespaces[0] : `${namespaces.length} namespaces`}</span>
|
|
427
|
+
</p>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
) : (
|
|
431
|
+
<div className="p-4 space-y-6">
|
|
432
|
+
{groupedActivity.map((group) => (
|
|
433
|
+
<div key={group.label}>
|
|
434
|
+
{/* Time period header */}
|
|
435
|
+
<div className="flex items-center gap-2 mb-3">
|
|
436
|
+
<Clock className="w-4 h-4 text-theme-text-tertiary" />
|
|
437
|
+
<span className="text-sm font-medium text-theme-text-secondary">{group.label}</span>
|
|
438
|
+
<span className="text-xs text-theme-text-disabled">
|
|
439
|
+
({group.items.length} item{group.items.length !== 1 ? 's' : ''})
|
|
440
|
+
</span>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Activity list */}
|
|
444
|
+
<div className="space-y-2 ml-6 border-l-2 border-theme-border pl-4">
|
|
445
|
+
{group.items.map((aggItem) => (
|
|
446
|
+
aggItem.type === 'aggregated' ? (
|
|
447
|
+
<AggregatedActivityCard
|
|
448
|
+
key={`agg-${aggItem.first.id}-${aggItem.last.id}`}
|
|
449
|
+
first={aggItem.first}
|
|
450
|
+
last={aggItem.last}
|
|
451
|
+
count={aggItem.count}
|
|
452
|
+
reason={aggItem.reason}
|
|
453
|
+
expanded={expandedItem === aggItem.first.id}
|
|
454
|
+
onToggle={() => setExpandedItem(expandedItem === aggItem.first.id ? null : aggItem.first.id)}
|
|
455
|
+
onResourceClick={onResourceClick}
|
|
456
|
+
/>
|
|
457
|
+
) : (
|
|
458
|
+
<ActivityCard
|
|
459
|
+
key={aggItem.item.id}
|
|
460
|
+
item={aggItem.item}
|
|
461
|
+
expanded={expandedItem === aggItem.item.id}
|
|
462
|
+
onToggle={() => setExpandedItem(expandedItem === aggItem.item.id ? null : aggItem.item.id)}
|
|
463
|
+
onResourceClick={onResourceClick}
|
|
464
|
+
/>
|
|
465
|
+
)
|
|
466
|
+
))}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
))}
|
|
470
|
+
</div>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface FilterButtonProps {
|
|
478
|
+
active: boolean
|
|
479
|
+
onClick: () => void
|
|
480
|
+
icon: React.ReactNode
|
|
481
|
+
label: string
|
|
482
|
+
count?: number
|
|
483
|
+
color?: 'blue' | 'amber' | 'green' | 'red'
|
|
484
|
+
tooltip?: string
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function FilterButton({ active, onClick, icon, label, count, color, tooltip }: FilterButtonProps) {
|
|
488
|
+
const colorClasses = {
|
|
489
|
+
blue: SEVERITY_BADGE.info,
|
|
490
|
+
amber: SEVERITY_BADGE.warning,
|
|
491
|
+
green: SEVERITY_BADGE.success,
|
|
492
|
+
red: SEVERITY_BADGE.error,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<button
|
|
497
|
+
onClick={onClick}
|
|
498
|
+
title={tooltip}
|
|
499
|
+
className={clsx(
|
|
500
|
+
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-2',
|
|
501
|
+
active ? (color ? colorClasses[color] : 'bg-theme-hover text-theme-text-primary') : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
502
|
+
)}
|
|
503
|
+
>
|
|
504
|
+
{icon}
|
|
505
|
+
{label}
|
|
506
|
+
{count !== undefined && count > 0 && (
|
|
507
|
+
<span
|
|
508
|
+
className={clsx(
|
|
509
|
+
'text-xs px-1.5 rounded',
|
|
510
|
+
color ? `bg-${color}-500/30` : 'bg-theme-hover/50'
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
{count}
|
|
514
|
+
</span>
|
|
515
|
+
)}
|
|
516
|
+
</button>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
interface ActivityCardProps {
|
|
521
|
+
item: TimelineEvent
|
|
522
|
+
expanded: boolean
|
|
523
|
+
onToggle: () => void
|
|
524
|
+
onResourceClick?: NavigateToResource
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function ActivityCard({ item, expanded, onToggle, onResourceClick }: ActivityCardProps) {
|
|
528
|
+
const isChange = isChangeEvent(item)
|
|
529
|
+
const isHistorical = isHistoricalEvent(item)
|
|
530
|
+
const isWarning = item.eventType === 'Warning'
|
|
531
|
+
const time = formatTime(item.timestamp)
|
|
532
|
+
|
|
533
|
+
// Only expandable if there's a diff to show
|
|
534
|
+
const hasExpandableContent = isChange && !!item.diff
|
|
535
|
+
|
|
536
|
+
// Determine card styling based on type
|
|
537
|
+
const getCardStyle = () => {
|
|
538
|
+
if (isChange) {
|
|
539
|
+
switch (item.eventType) {
|
|
540
|
+
case 'add':
|
|
541
|
+
return 'bg-green-500/5 border-green-500/30 hover:border-green-500/50'
|
|
542
|
+
case 'delete':
|
|
543
|
+
return 'bg-red-500/5 border-red-500/30 hover:border-red-500/50'
|
|
544
|
+
case 'update':
|
|
545
|
+
return 'bg-blue-500/5 border-blue-500/30 hover:border-blue-500/50'
|
|
546
|
+
default:
|
|
547
|
+
return 'bg-theme-surface/50 border-theme-border hover:border-theme-border-light'
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (isWarning) {
|
|
551
|
+
return 'bg-amber-500/5 border-amber-500/30 hover:border-amber-500/50'
|
|
552
|
+
}
|
|
553
|
+
return 'bg-theme-surface/50 border-theme-border hover:border-theme-border-light'
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const getIcon = () => {
|
|
557
|
+
if (isChange) {
|
|
558
|
+
switch (item.eventType) {
|
|
559
|
+
case 'add':
|
|
560
|
+
return <Plus className="w-4 h-4 text-green-400" />
|
|
561
|
+
case 'delete':
|
|
562
|
+
return <Trash2 className="w-4 h-4 text-red-400" />
|
|
563
|
+
case 'update':
|
|
564
|
+
return <RefreshCw className="w-4 h-4 text-blue-400" />
|
|
565
|
+
default:
|
|
566
|
+
return <CheckCircle className="w-4 h-4 text-theme-text-secondary" />
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (isWarning) {
|
|
570
|
+
return <AlertCircle className="w-4 h-4 text-amber-400" />
|
|
571
|
+
}
|
|
572
|
+
return <CheckCircle className="w-4 h-4 text-green-400" />
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<div
|
|
577
|
+
className={clsx('rounded-lg border transition-all', getCardStyle(), hasExpandableContent && 'cursor-pointer')}
|
|
578
|
+
onClick={hasExpandableContent ? onToggle : undefined}
|
|
579
|
+
>
|
|
580
|
+
<div className="p-3">
|
|
581
|
+
{/* Header row */}
|
|
582
|
+
<div className="flex items-start gap-3">
|
|
583
|
+
{/* Icon */}
|
|
584
|
+
<div className="shrink-0 mt-0.5">{getIcon()}</div>
|
|
585
|
+
|
|
586
|
+
{/* Content */}
|
|
587
|
+
<div className="flex-1 min-w-0">
|
|
588
|
+
{/* Resource info */}
|
|
589
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
590
|
+
<button
|
|
591
|
+
onClick={(e) => {
|
|
592
|
+
e.stopPropagation()
|
|
593
|
+
onResourceClick?.({ kind: kindToPlural(item.kind), namespace: item.namespace, name: item.name })
|
|
594
|
+
}}
|
|
595
|
+
className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
|
|
596
|
+
>
|
|
597
|
+
<span className="badge-sm bg-theme-elevated text-theme-text-secondary group-hover:bg-theme-hover">
|
|
598
|
+
{item.kind}
|
|
599
|
+
</span>
|
|
600
|
+
<span className="text-sm font-medium text-theme-text-primary truncate group-hover:text-blue-300">{item.name}</span>
|
|
601
|
+
</button>
|
|
602
|
+
{item.namespace && <span className="text-xs text-theme-text-tertiary">in {item.namespace}</span>}
|
|
603
|
+
{item.owner && (
|
|
604
|
+
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
605
|
+
<span className="text-xs text-theme-text-quaternary">←</span>
|
|
606
|
+
<ResourceRefBadge
|
|
607
|
+
resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
|
|
608
|
+
onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
|
|
609
|
+
/>
|
|
610
|
+
</span>
|
|
611
|
+
)}
|
|
612
|
+
{item.createdAt && (
|
|
613
|
+
<span className="text-xs text-theme-text-quaternary" title={`Created: ${new Date(item.createdAt).toLocaleString()}`}>
|
|
614
|
+
• {formatResourceAge(item.createdAt)} old
|
|
615
|
+
</span>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
{/* Activity details */}
|
|
620
|
+
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
|
621
|
+
{isChange ? (
|
|
622
|
+
<>
|
|
623
|
+
<span className={clsx('text-sm font-medium', isOperation(item.eventType) && getOperationColor(item.eventType))}>
|
|
624
|
+
{isHistorical && item.reason ? item.reason : item.eventType}
|
|
625
|
+
</span>
|
|
626
|
+
{item.diff && <DiffBadge diff={item.diff} />}
|
|
627
|
+
{item.healthState && item.healthState !== 'unknown' && (
|
|
628
|
+
<span className={clsx('badge-sm', getHealthBadgeColor(item.healthState))}>
|
|
629
|
+
{item.healthState}
|
|
630
|
+
</span>
|
|
631
|
+
)}
|
|
632
|
+
{isHistorical && item.message && (
|
|
633
|
+
<span className="text-sm text-theme-text-secondary">
|
|
634
|
+
{item.message}
|
|
635
|
+
</span>
|
|
636
|
+
)}
|
|
637
|
+
</>
|
|
638
|
+
) : (
|
|
639
|
+
<>
|
|
640
|
+
<span className={clsx('text-sm font-medium', isWarning ? 'text-amber-700 dark:text-amber-300' : 'text-theme-text-secondary')}>
|
|
641
|
+
{item.reason}
|
|
642
|
+
</span>
|
|
643
|
+
<span className="text-sm text-theme-text-secondary">
|
|
644
|
+
{item.message}
|
|
645
|
+
</span>
|
|
646
|
+
</>
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
{/* Time and count */}
|
|
652
|
+
<div className="shrink-0 text-right">
|
|
653
|
+
<div className="text-xs text-theme-text-tertiary">{time}</div>
|
|
654
|
+
{item.count && item.count > 1 && (
|
|
655
|
+
<div className="text-xs text-theme-text-disabled mt-1">x{item.count}</div>
|
|
656
|
+
)}
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
{/* Expand indicator - only show if there's content to expand */}
|
|
660
|
+
{hasExpandableContent && (
|
|
661
|
+
<ChevronRight
|
|
662
|
+
className={clsx('w-4 h-4 text-theme-text-disabled transition-transform shrink-0', expanded && 'rotate-90')}
|
|
663
|
+
/>
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
{/* Expanded details - only for items with diffs */}
|
|
668
|
+
{expanded && hasExpandableContent && item.diff && (
|
|
669
|
+
<div className="mt-3 pt-3 border-t-subtle">
|
|
670
|
+
<div className="text-xs text-theme-text-tertiary mb-2">Changes:</div>
|
|
671
|
+
<DiffViewer diff={item.diff} />
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Component for aggregated repeated events (e.g., multiple OOMKilled)
|
|
680
|
+
interface AggregatedActivityCardProps {
|
|
681
|
+
first: TimelineEvent
|
|
682
|
+
last: TimelineEvent
|
|
683
|
+
count: number
|
|
684
|
+
reason: string
|
|
685
|
+
expanded: boolean
|
|
686
|
+
onToggle: () => void
|
|
687
|
+
onResourceClick?: NavigateToResource
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle, onResourceClick }: AggregatedActivityCardProps) {
|
|
691
|
+
const isWarning = first.eventType === 'Warning'
|
|
692
|
+
const firstTime = formatTime(first.timestamp)
|
|
693
|
+
const lastTime = formatTime(last.timestamp)
|
|
694
|
+
|
|
695
|
+
// Card styling - warning/unhealthy style for aggregated events
|
|
696
|
+
const cardStyle = isWarning
|
|
697
|
+
? 'bg-amber-500/5 border-amber-500/30 hover:border-amber-500/50'
|
|
698
|
+
: 'bg-red-500/5 border-red-500/30 hover:border-red-500/50'
|
|
699
|
+
|
|
700
|
+
// Dot color based on severity
|
|
701
|
+
const dotColor = isWarning ? 'bg-amber-500' : 'bg-red-500'
|
|
702
|
+
const textColor = isWarning ? 'text-amber-400' : 'text-red-400'
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<div
|
|
706
|
+
className={clsx('rounded-lg border transition-all cursor-pointer', cardStyle)}
|
|
707
|
+
onClick={onToggle}
|
|
708
|
+
>
|
|
709
|
+
<div className="p-3">
|
|
710
|
+
{/* Header row */}
|
|
711
|
+
<div className="flex items-start gap-3">
|
|
712
|
+
{/* Aggregation visualization: first dot - line - last dot */}
|
|
713
|
+
<div className="flex flex-col items-center shrink-0 mt-0.5">
|
|
714
|
+
{/* First occurrence dot */}
|
|
715
|
+
<div className={clsx('w-2.5 h-2.5 rounded-full', dotColor)} title={`First: ${firstTime}`} />
|
|
716
|
+
{/* Connecting line */}
|
|
717
|
+
<div className={clsx('w-0.5 h-4 my-0.5', isWarning ? 'bg-amber-500/40' : 'bg-red-500/40')} />
|
|
718
|
+
{/* Last occurrence dot */}
|
|
719
|
+
<div className={clsx('w-2.5 h-2.5 rounded-full', dotColor)} title={`Last: ${lastTime}`} />
|
|
720
|
+
</div>
|
|
721
|
+
|
|
722
|
+
{/* Content */}
|
|
723
|
+
<div className="flex-1 min-w-0">
|
|
724
|
+
{/* Resource info */}
|
|
725
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
726
|
+
<button
|
|
727
|
+
onClick={(e) => {
|
|
728
|
+
e.stopPropagation()
|
|
729
|
+
onResourceClick?.({ kind: kindToPlural(first.kind), namespace: first.namespace, name: first.name })
|
|
730
|
+
}}
|
|
731
|
+
className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
|
|
732
|
+
>
|
|
733
|
+
<span className="badge-sm bg-theme-elevated text-theme-text-secondary group-hover:bg-theme-hover">
|
|
734
|
+
{first.kind}
|
|
735
|
+
</span>
|
|
736
|
+
<span className="text-sm font-medium text-theme-text-primary truncate group-hover:text-blue-300">{first.name}</span>
|
|
737
|
+
</button>
|
|
738
|
+
{first.namespace && <span className="text-xs text-theme-text-tertiary">in {first.namespace}</span>}
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
{/* Aggregated event details */}
|
|
742
|
+
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
|
743
|
+
<span className={clsx('text-sm font-medium', textColor)}>
|
|
744
|
+
{reason}
|
|
745
|
+
</span>
|
|
746
|
+
<span className={clsx(
|
|
747
|
+
'badge-sm',
|
|
748
|
+
isWarning ? SEVERITY_BADGE.warning : SEVERITY_BADGE.error
|
|
749
|
+
)}>
|
|
750
|
+
x{count}
|
|
751
|
+
</span>
|
|
752
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
753
|
+
{firstTime} → {lastTime}
|
|
754
|
+
</span>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
|
|
758
|
+
{/* Expand indicator */}
|
|
759
|
+
<ChevronRight
|
|
760
|
+
className={clsx('w-4 h-4 text-theme-text-disabled transition-transform shrink-0', expanded && 'rotate-90')}
|
|
761
|
+
/>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
{/* Expanded details */}
|
|
765
|
+
{expanded && (
|
|
766
|
+
<div className="mt-3 pt-3 border-t-subtle space-y-3">
|
|
767
|
+
{/* First occurrence */}
|
|
768
|
+
<div className="flex items-start gap-2">
|
|
769
|
+
<div className={clsx('w-2 h-2 rounded-full mt-1.5 shrink-0', dotColor)} />
|
|
770
|
+
<div>
|
|
771
|
+
<div className="text-xs text-theme-text-tertiary">First occurrence</div>
|
|
772
|
+
<div className="text-sm text-theme-text-secondary">
|
|
773
|
+
{new Date(first.timestamp).toLocaleString()}
|
|
774
|
+
</div>
|
|
775
|
+
{first.message && (
|
|
776
|
+
<p className="text-xs text-theme-text-tertiary mt-1 whitespace-pre-wrap">
|
|
777
|
+
{first.message}
|
|
778
|
+
</p>
|
|
779
|
+
)}
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
|
|
783
|
+
{/* Last occurrence */}
|
|
784
|
+
<div className="flex items-start gap-2">
|
|
785
|
+
<div className={clsx('w-2 h-2 rounded-full mt-1.5 shrink-0', dotColor)} />
|
|
786
|
+
<div>
|
|
787
|
+
<div className="text-xs text-theme-text-tertiary">Last occurrence ({count}x total)</div>
|
|
788
|
+
<div className="text-sm text-theme-text-secondary">
|
|
789
|
+
{new Date(last.timestamp).toLocaleString()}
|
|
790
|
+
</div>
|
|
791
|
+
{last.message && (
|
|
792
|
+
<p className="text-xs text-theme-text-tertiary mt-1 whitespace-pre-wrap">
|
|
793
|
+
{last.message}
|
|
794
|
+
</p>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
)}
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function formatTime(timestamp: string): string {
|
|
806
|
+
if (!timestamp) return '-'
|
|
807
|
+
const date = new Date(timestamp)
|
|
808
|
+
const now = new Date()
|
|
809
|
+
const diffMs = now.getTime() - date.getTime()
|
|
810
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
811
|
+
const diffHours = Math.floor(diffMins / 60)
|
|
812
|
+
|
|
813
|
+
if (diffMins < 1) return 'just now'
|
|
814
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
815
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
816
|
+
return date.toLocaleDateString()
|
|
817
|
+
}
|