@skyhook-io/radar-app 0.2.2 → 0.3.1

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