@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.
@@ -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
+ }