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