@skyhook-io/k8s-ui 1.0.0

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 (193) hide show
  1. package/package.json +63 -0
  2. package/src/components/dock/BottomDock.tsx +204 -0
  3. package/src/components/dock/DockContext.tsx +212 -0
  4. package/src/components/dock/TerminalTab.tsx +333 -0
  5. package/src/components/dock/index.ts +3 -0
  6. package/src/components/gitops/GitOpsActions.tsx +227 -0
  7. package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
  8. package/src/components/gitops/ManagedResourcesList.tsx +210 -0
  9. package/src/components/gitops/SyncCountdown.tsx +146 -0
  10. package/src/components/gitops/index.ts +4 -0
  11. package/src/components/logs/JsonLogLine.tsx +90 -0
  12. package/src/components/logs/LogCore.tsx +553 -0
  13. package/src/components/logs/LogToolbarSelects.tsx +74 -0
  14. package/src/components/logs/LogsViewer.tsx +146 -0
  15. package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
  16. package/src/components/logs/index.ts +13 -0
  17. package/src/components/logs/useLogBuffer.ts +138 -0
  18. package/src/components/logs/useLogSearch.ts +151 -0
  19. package/src/components/logs/useLogStream.ts +61 -0
  20. package/src/components/resources/ResourcesView.tsx +5494 -0
  21. package/src/components/resources/index.ts +17 -0
  22. package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
  23. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
  24. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
  25. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
  26. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
  27. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
  28. package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
  29. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
  30. package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
  31. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
  32. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
  33. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
  37. package/src/components/resources/renderers/EventRenderer.tsx +175 -0
  38. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
  39. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
  40. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
  43. package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
  45. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
  46. package/src/components/resources/renderers/HPARenderer.tsx +164 -0
  47. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
  48. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
  49. package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
  50. package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
  51. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
  52. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
  53. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
  54. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
  55. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
  56. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
  57. package/src/components/resources/renderers/JobRenderer.tsx +103 -0
  58. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
  59. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
  60. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
  61. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
  62. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
  63. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
  64. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
  65. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
  66. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
  67. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
  68. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
  69. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
  70. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
  71. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
  73. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
  74. package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
  75. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
  76. package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
  77. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
  78. package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
  79. package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
  80. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
  81. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
  82. package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +677 -0
  84. package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
  85. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
  86. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
  87. package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
  88. package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
  89. package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
  90. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
  91. package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
  92. package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
  93. package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
  94. package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
  95. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
  96. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
  97. package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
  98. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
  99. package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
  100. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
  101. package/src/components/resources/renderers/VPARenderer.tsx +193 -0
  102. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
  103. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
  104. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
  105. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
  106. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
  107. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
  108. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
  109. package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
  110. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
  111. package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
  112. package/src/components/resources/renderers/argo-cells.tsx +118 -0
  113. package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
  114. package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
  115. package/src/components/resources/renderers/eso-cells.tsx +115 -0
  116. package/src/components/resources/renderers/flux-cells.tsx +253 -0
  117. package/src/components/resources/renderers/index.ts +105 -0
  118. package/src/components/resources/renderers/istio-cells.tsx +210 -0
  119. package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
  120. package/src/components/resources/renderers/keda-cells.tsx +94 -0
  121. package/src/components/resources/renderers/knative-cells.tsx +396 -0
  122. package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
  123. package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
  124. package/src/components/resources/renderers/traefik-cells.tsx +120 -0
  125. package/src/components/resources/renderers/trivy-cells.tsx +153 -0
  126. package/src/components/resources/renderers/trivy-shared.tsx +67 -0
  127. package/src/components/resources/renderers/velero-cells.tsx +164 -0
  128. package/src/components/resources/resource-utils-argo.ts +155 -0
  129. package/src/components/resources/resource-utils-certmanager.ts +198 -0
  130. package/src/components/resources/resource-utils-cnpg.ts +344 -0
  131. package/src/components/resources/resource-utils-eso.ts +385 -0
  132. package/src/components/resources/resource-utils-flux.ts +186 -0
  133. package/src/components/resources/resource-utils-istio.ts +372 -0
  134. package/src/components/resources/resource-utils-karpenter.ts +150 -0
  135. package/src/components/resources/resource-utils-keda.ts +203 -0
  136. package/src/components/resources/resource-utils-knative.ts +218 -0
  137. package/src/components/resources/resource-utils-kyverno.ts +224 -0
  138. package/src/components/resources/resource-utils-prometheus.ts +159 -0
  139. package/src/components/resources/resource-utils-traefik.ts +180 -0
  140. package/src/components/resources/resource-utils-trivy.ts +146 -0
  141. package/src/components/resources/resource-utils-velero.ts +293 -0
  142. package/src/components/resources/resource-utils.ts +1558 -0
  143. package/src/components/shared/EditableYamlView.tsx +302 -0
  144. package/src/components/shared/ResourceActionsBar.tsx +924 -0
  145. package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
  146. package/src/components/shared/index.ts +3 -0
  147. package/src/components/timeline/index.ts +1 -0
  148. package/src/components/timeline/shared.tsx +614 -0
  149. package/src/components/topology/GroupNode.tsx +198 -0
  150. package/src/components/topology/K8sResourceNode.tsx +440 -0
  151. package/src/components/topology/TopologyControls.tsx +62 -0
  152. package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
  153. package/src/components/topology/TopologyGraph.tsx +748 -0
  154. package/src/components/topology/TopologySearch.tsx +304 -0
  155. package/src/components/topology/index.ts +7 -0
  156. package/src/components/topology/layout.ts +734 -0
  157. package/src/components/topology/layout.worker.ts +272 -0
  158. package/src/components/topology/topology.css +129 -0
  159. package/src/components/ui/CodeViewer.tsx +451 -0
  160. package/src/components/ui/ConfirmDialog.tsx +188 -0
  161. package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
  162. package/src/components/ui/HealthRing.tsx +72 -0
  163. package/src/components/ui/MetricsChart.tsx +259 -0
  164. package/src/components/ui/ResourceBar.tsx +68 -0
  165. package/src/components/ui/Toast.tsx +225 -0
  166. package/src/components/ui/Tooltip.tsx +165 -0
  167. package/src/components/ui/YamlEditor.tsx +280 -0
  168. package/src/components/ui/drawer-components.tsx +792 -0
  169. package/src/components/ui/index.ts +10 -0
  170. package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
  171. package/src/components/workload/WorkloadView.tsx +1109 -0
  172. package/src/components/workload/index.ts +2 -0
  173. package/src/hooks/index.ts +9 -0
  174. package/src/hooks/useAnimatedUnmount.ts +30 -0
  175. package/src/hooks/useKeyboardShortcuts.tsx +336 -0
  176. package/src/hooks/useRefreshAnimation.ts +58 -0
  177. package/src/index.ts +35 -0
  178. package/src/types/core.ts +894 -0
  179. package/src/types/gitops.ts +458 -0
  180. package/src/types/index.ts +2 -0
  181. package/src/utils/animation.ts +40 -0
  182. package/src/utils/api-resources.ts +185 -0
  183. package/src/utils/badge-colors.ts +399 -0
  184. package/src/utils/context-name.ts +72 -0
  185. package/src/utils/download.ts +19 -0
  186. package/src/utils/format.ts +233 -0
  187. package/src/utils/index.ts +10 -0
  188. package/src/utils/log-format.ts +300 -0
  189. package/src/utils/navigation.ts +119 -0
  190. package/src/utils/resource-hierarchy.ts +630 -0
  191. package/src/utils/resource-icons.ts +206 -0
  192. package/src/vite-env.d.ts +1 -0
  193. package/tsconfig.json +24 -0
@@ -0,0 +1,1109 @@
1
+ import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
+ import { flushSync } from 'react-dom'
3
+ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
5
+ import { clsx } from 'clsx'
6
+ import {
7
+ ArrowLeft,
8
+ RefreshCw,
9
+ Activity,
10
+ Terminal,
11
+ Layers,
12
+ FileText,
13
+ Copy,
14
+ Check,
15
+ Minimize2,
16
+ Maximize2,
17
+ X,
18
+ BarChart3,
19
+ } from 'lucide-react'
20
+ import type { TimelineEvent, ResourceRef, Relationships, SelectedResource } from '../../types'
21
+ import type { NavigateToResource } from '../../utils/navigation'
22
+ import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
23
+ import { isChangeEvent, isHistoricalEvent } from '../../types'
24
+ import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
25
+ import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
26
+ import {
27
+ ZOOM_LEVELS,
28
+ type ZoomLevel,
29
+ formatAxisTime,
30
+ EventMarker,
31
+ EventDotLegend,
32
+ HealthSpanLegend,
33
+ HealthSpan,
34
+ ZoomControls,
35
+ buildHealthSpans,
36
+ timeToX,
37
+ calculateTimeRange,
38
+ } from '../timeline/shared'
39
+ import { ResourceActionsBar } from '../shared/ResourceActionsBar'
40
+ import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
41
+ import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
42
+ import { getKindColor, formatKindName } from '../ui/drawer-components'
43
+
44
+ type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
45
+
46
+ // ============================================================================
47
+ // MAIN WORKLOAD VIEW — presentation only, data injected via props
48
+ // ============================================================================
49
+
50
+ interface WorkloadViewProps {
51
+ kind: string
52
+ namespace: string
53
+ name: string
54
+ onBack: () => void
55
+ onNavigateToResource?: NavigateToResource
56
+ onCollapseToDrawer?: () => void
57
+ /** false = collapsed drawer mode, true (default) = full expanded mode */
58
+ expanded?: boolean
59
+ /** Close the drawer (collapsed mode) */
60
+ onClose?: () => void
61
+ /** Expand from drawer to full view */
62
+ onExpand?: () => void
63
+ /** Initial view tab — 'yaml' opens YAML directly */
64
+ initialTab?: 'detail' | 'yaml'
65
+ /** API group for CRD resources */
66
+ group?: string
67
+
68
+ // ── Data (injected by wrapper) ──────────────────────────────────────────
69
+ /** The resource data object */
70
+ resource?: any
71
+ /** Resource relationships (pods, owner, config, etc.) */
72
+ relationships?: Relationships
73
+ /** TLS certificate info for secrets */
74
+ certificateInfo?: any
75
+ /** Whether the resource is loading */
76
+ isLoading?: boolean
77
+ /** Function to refetch the resource data */
78
+ refetch?: () => void
79
+
80
+ // ── Timeline data ────────────────────────────────────────────────────────
81
+ /** All timeline events for this resource's namespace */
82
+ allEvents?: TimelineEvent[]
83
+ /** Whether timeline events are loading */
84
+ eventsLoading?: boolean
85
+ /** Topology data for hierarchy building */
86
+ topology?: any
87
+
88
+ // ── Capabilities ─────────────────────────────────────────────────────────
89
+ /** Whether secrets can be updated */
90
+ canUpdateSecrets?: boolean
91
+
92
+ // ── Mutations ────────────────────────────────────────────────────────────
93
+ /** Update a resource from YAML */
94
+ onUpdateResource?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
95
+ /** Whether the resource is being updated */
96
+ isUpdatingResource?: boolean
97
+
98
+ // ── Tab state (optional URL sync) ────────────────────────────────────────
99
+ /** Controlled active tab. If not provided, managed internally. */
100
+ activeTab?: TabType
101
+ /** Called when tab changes (for URL sync etc.) */
102
+ onTabChange?: (tab: TabType) => void
103
+
104
+ // ── Render props for platform-specific content ───────────────────────────
105
+ /** Render the logs tab content */
106
+ renderLogsTab?: (props: {
107
+ kind: string
108
+ apiKind: string
109
+ namespace: string
110
+ name: string
111
+ resource: any
112
+ pods: ResourceRef[]
113
+ selectedPod: string | null
114
+ onSelectPod: (name: string | null) => void
115
+ initialContainer: string | null
116
+ onConsumeInitialContainer: () => void
117
+ }) => ReactNode
118
+ /** Render the metrics tab content */
119
+ renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
120
+ /** Whether metrics are available for this resource kind */
121
+ isMetricsAvailable?: (kind: string, resource: any) => boolean
122
+
123
+ // ── ResourceActionsBar props (passed through) ────────────────────────────
124
+ /** All props for the actions bar (forwarded as-is) */
125
+ actionsBarProps?: Record<string, any>
126
+ }
127
+
128
+ export function WorkloadView({
129
+ kind: kindProp,
130
+ namespace,
131
+ name,
132
+ onBack,
133
+ onNavigateToResource,
134
+ onCollapseToDrawer,
135
+ expanded = true,
136
+ onClose,
137
+ onExpand,
138
+ initialTab,
139
+ group,
140
+ // Data
141
+ resource,
142
+ relationships,
143
+ certificateInfo,
144
+ isLoading: resourceLoading = false,
145
+ refetch: refetchProp,
146
+ // Timeline
147
+ allEvents,
148
+ eventsLoading = false,
149
+ topology,
150
+ // Capabilities
151
+ canUpdateSecrets,
152
+ // Mutations
153
+ onUpdateResource,
154
+ isUpdatingResource,
155
+ // Tab state
156
+ activeTab: controlledTab,
157
+ onTabChange,
158
+ // Render props
159
+ renderLogsTab,
160
+ renderMetricsTab,
161
+ isMetricsAvailable,
162
+ // Actions bar
163
+ actionsBarProps,
164
+ }: WorkloadViewProps) {
165
+ // Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
166
+ const kind = pluralToKind(kindProp)
167
+ const apiKind = kindProp
168
+
169
+ // Tab state — controlled or uncontrolled
170
+ const [internalTab, setInternalTab] = useState<TabType>('overview')
171
+ const activeTab = controlledTab ?? internalTab
172
+ const handleSetTab = useCallback((tab: TabType) => {
173
+ setInternalTab(tab)
174
+ onTabChange?.(tab)
175
+ }, [onTabChange])
176
+
177
+ // Collapsed mode state (YAML toggle for drawer mode)
178
+ const [showYaml, setShowYaml] = useState(initialTab === 'yaml')
179
+ useEffect(() => {
180
+ setShowYaml(initialTab === 'yaml')
181
+ }, [kindProp, namespace, name, initialTab])
182
+
183
+ const switchView = useCallback((yaml: boolean) => {
184
+ const update = () => flushSync(() => setShowYaml(yaml))
185
+ if (document.startViewTransition) {
186
+ document.startViewTransition(update)
187
+ } else {
188
+ setShowYaml(yaml)
189
+ }
190
+ }, [])
191
+
192
+ const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
193
+ const [zoom, setZoom] = useState<ZoomLevel>(1)
194
+ const [selectedPod, setSelectedPod] = useState<string | null>(null)
195
+ const [initialContainer, setInitialContainer] = useState<string | null>(null)
196
+ const [copied, setCopied] = useState<string | null>(null)
197
+ const [saveSuccess, setSaveSuccess] = useState(false)
198
+
199
+ // Refresh animation
200
+ const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(refetchProp ?? (() => {}))
201
+
202
+ // Build resource hierarchy
203
+ const resourceLanes = useMemo(() => {
204
+ if (!allEvents) return []
205
+ return buildResourceHierarchy({
206
+ events: allEvents,
207
+ topology,
208
+ rootResource: { kind, namespace, name },
209
+ groupByApp: true,
210
+ })
211
+ }, [allEvents, topology, kind, namespace, name])
212
+
213
+ // Flatten events from hierarchy
214
+ const resourceEvents = useMemo(() => {
215
+ return getAllEventsFromHierarchy(resourceLanes)
216
+ }, [resourceLanes])
217
+
218
+ // Get pods from relationships and hierarchy
219
+ const childPods = useMemo(() => {
220
+ if (resourceLanes.length === 0) return []
221
+ const rootLane = resourceLanes[0]
222
+ const pods: { name: string; namespace: string; events: TimelineEvent[] }[] = []
223
+ const collectPods = (lane: ResourceLane) => {
224
+ if (lane.kind === 'Pod') {
225
+ pods.push({ name: lane.name, namespace: lane.namespace, events: lane.events })
226
+ }
227
+ lane.children?.forEach(collectPods)
228
+ }
229
+ rootLane.children?.forEach(collectPods)
230
+ if (rootLane.kind === 'Pod') {
231
+ pods.push({ name: rootLane.name, namespace: rootLane.namespace, events: rootLane.events })
232
+ }
233
+ return pods
234
+ }, [resourceLanes])
235
+
236
+ const pods = relationships?.pods || []
237
+ const allPods: ResourceRef[] = useMemo(() => {
238
+ const combined = [
239
+ ...pods,
240
+ ...childPods.map(p => ({ kind: 'Pod' as const, namespace: p.namespace, name: p.name })),
241
+ ]
242
+ const seen = new Set<string>()
243
+ return combined.filter(p => {
244
+ const key = `${p.namespace}/${p.name}`
245
+ if (seen.has(key)) return false
246
+ seen.add(key)
247
+ return true
248
+ })
249
+ }, [pods, childPods])
250
+
251
+ // Metadata
252
+ const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
253
+
254
+ // Copy to clipboard
255
+ const copyToClipboard = useCallback((text: string, key: string) => {
256
+ navigator.clipboard.writeText(text)
257
+ setCopied(key)
258
+ setTimeout(() => setCopied(null), 2000)
259
+ }, [])
260
+
261
+ const handleSaveSecretValue = useCallback(async (yaml: string) => {
262
+ if (!onUpdateResource) return
263
+ try {
264
+ await onUpdateResource({
265
+ kind: apiKind,
266
+ namespace,
267
+ name,
268
+ yaml,
269
+ })
270
+ setTimeout(() => refetch(), 1000)
271
+ } catch {
272
+ // Error handled by mutation (toast)
273
+ }
274
+ }, [onUpdateResource, apiKind, namespace, name, refetch])
275
+
276
+ const handleSaved = useCallback(() => {
277
+ setSaveSuccess(true)
278
+ setTimeout(() => {
279
+ refetch()
280
+ setTimeout(() => setSaveSuccess(false), 2000)
281
+ }, 1000)
282
+ }, [refetch])
283
+
284
+ // Handle "open logs" from container-level buttons (e.g., PodRenderer) — switch to Logs tab with right pod+container
285
+ const handleOpenLogs = useCallback((podName: string, containerName: string) => {
286
+ setSelectedPod(podName)
287
+ setInitialContainer(containerName)
288
+ handleSetTab('logs')
289
+ }, [handleSetTab])
290
+
291
+ // Selected resource object for shared components
292
+ const selectedResource: SelectedResource = useMemo(() => ({
293
+ kind: apiKind,
294
+ namespace,
295
+ name,
296
+ group,
297
+ }), [apiKind, namespace, name, group])
298
+
299
+ // Keyboard shortcuts — different behavior for expanded vs collapsed mode
300
+ useRegisterShortcuts(useMemo(() => [
301
+ {
302
+ id: 'workload-escape',
303
+ keys: 'Escape',
304
+ description: expanded ? 'Go back' : 'Close drawer',
305
+ category: expanded ? 'Navigation' as const : 'Drawer' as const,
306
+ scope: expanded ? 'global' as const : 'drawer' as const,
307
+ handler: expanded ? onBack : () => onClose?.(),
308
+ enabled: true,
309
+ },
310
+ {
311
+ id: 'drawer-yaml',
312
+ keys: 'y',
313
+ description: 'Switch to YAML view',
314
+ category: 'Drawer' as const,
315
+ scope: 'drawer' as const,
316
+ handler: () => switchView(true),
317
+ enabled: !expanded,
318
+ },
319
+ {
320
+ id: 'drawer-detail',
321
+ keys: 'e',
322
+ description: 'Switch to detail view',
323
+ category: 'Drawer' as const,
324
+ scope: 'drawer' as const,
325
+ handler: () => switchView(false),
326
+ enabled: !expanded,
327
+ },
328
+ ], [expanded, onBack, onClose, switchView]))
329
+
330
+ const status = getResourceStatus(apiKind, resource)
331
+
332
+ const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
333
+
334
+ // ── Collapsed (drawer) mode ──────────────────────────────────────────────
335
+ if (!expanded) {
336
+ return (
337
+ <div className="flex flex-col h-full w-full">
338
+ {/* Drawer header */}
339
+ <div className="border-b border-theme-border shrink-0">
340
+ {/* Top row: badges and controls */}
341
+ <div className="flex items-center justify-between px-4 pt-3 pb-2">
342
+ <div className="flex items-center gap-2 flex-wrap">
343
+ <span className={clsx('px-2 py-0.5 text-xs font-medium rounded border', getKindColor(apiKind))}>
344
+ {formatKindName(apiKind)}
345
+ </span>
346
+ {status && (
347
+ <span className={clsx('px-2 py-0.5 text-xs font-medium rounded', status.color)}>
348
+ {status.text}
349
+ </span>
350
+ )}
351
+ </div>
352
+ <div className="flex items-center gap-1">
353
+ {onExpand && (
354
+ <button
355
+ onClick={onExpand}
356
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
357
+ title="Open full view"
358
+ >
359
+ <Maximize2 className="w-4 h-4" />
360
+ </button>
361
+ )}
362
+ <button
363
+ onClick={() => refetch()}
364
+ disabled={isRefreshAnimating}
365
+ className={clsx(
366
+ 'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
367
+ refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
368
+ )}
369
+ title="Refresh"
370
+ >
371
+ {refreshPhase === 'success'
372
+ ? <Check className="w-4 h-4 stroke-[2.5]" />
373
+ : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
374
+ }
375
+ </button>
376
+ {onClose && (
377
+ <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" title="Close (Esc)">
378
+ <X className="w-4 h-4" />
379
+ </button>
380
+ )}
381
+ </div>
382
+ </div>
383
+
384
+ {/* Name and namespace */}
385
+ <div className="px-4 pb-3">
386
+ <div className="flex items-center gap-2">
387
+ <h2 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h2>
388
+ <button
389
+ onClick={() => copyToClipboard(name, 'name')}
390
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
391
+ title="Copy name"
392
+ >
393
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
394
+ </button>
395
+ </div>
396
+ <p className="text-sm text-theme-text-tertiary">{namespace}</p>
397
+ </div>
398
+
399
+ {/* Actions bar */}
400
+ <ResourceActionsBar resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...actionsBarProps} />
401
+ </div>
402
+
403
+ {/* Success animation overlay */}
404
+ {saveSuccess && <SaveSuccessAnimation />}
405
+
406
+ {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
407
+ <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
408
+ {resourceLoading ? (
409
+ <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
410
+ ) : !resource ? (
411
+ <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
412
+ ) : showYaml ? (
413
+ <EditableYamlView
414
+ resource={selectedResource}
415
+ data={resource}
416
+ onCopy={(text) => copyToClipboard(text, 'yaml')}
417
+ copied={copied === 'yaml'}
418
+ onSaved={handleSaved}
419
+ />
420
+ ) : (
421
+ <ResourceRendererDispatch
422
+ resource={selectedResource}
423
+ data={resource}
424
+ relationships={relationships}
425
+ certificateInfo={certificateInfo}
426
+ onCopy={copyToClipboard}
427
+ copied={copied}
428
+ onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
429
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
430
+ isSavingSecret={isUpdatingResource}
431
+ />
432
+ )}
433
+ </div>
434
+ </div>
435
+ )
436
+ }
437
+
438
+ // ── Expanded (full) mode ─────────────────────────────────────────────────
439
+ return (
440
+ <div className="flex flex-col h-full w-full bg-theme-base">
441
+ {/* Header */}
442
+ <div className="shrink-0 border-b border-theme-border bg-theme-surface">
443
+ <div className="px-6 py-3 flex items-start gap-4">
444
+ {/* Back button */}
445
+ <button
446
+ onClick={onBack}
447
+ className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
448
+ title="Go back (Esc)"
449
+ >
450
+ <ArrowLeft className="w-5 h-5" />
451
+ </button>
452
+
453
+ {/* Resource identity */}
454
+ <div className="flex-1 min-w-0">
455
+ <div className="flex items-center gap-3 mb-1">
456
+ <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
457
+ <button
458
+ onClick={() => copyToClipboard(name, 'name')}
459
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
460
+ title="Copy name"
461
+ >
462
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
463
+ </button>
464
+ </div>
465
+ <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
466
+ <span className={clsx('px-2 py-0.5 text-xs font-medium rounded border', getKindColor(apiKind))}>
467
+ {formatKindName(apiKind)}
468
+ </span>
469
+ {status && (
470
+ <span className={clsx('px-2 py-0.5 text-xs font-medium rounded', status.color)}>
471
+ {status.text}
472
+ </span>
473
+ )}
474
+ {namespace && namespace !== '_' && (
475
+ <span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
476
+ )}
477
+ {metadata.find(m => m.label === 'Image') && (
478
+ <span className="truncate max-w-md font-mono text-xs">{metadata.find(m => m.label === 'Image')?.value}</span>
479
+ )}
480
+ {relationships?.owner && (
481
+ <span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
482
+ )}
483
+ </div>
484
+ </div>
485
+
486
+ {/* Refresh */}
487
+ <button
488
+ onClick={() => refetch()}
489
+ disabled={isRefreshAnimating}
490
+ className={clsx(
491
+ 'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
492
+ refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
493
+ )}
494
+ title="Refresh"
495
+ >
496
+ {refreshPhase === 'success'
497
+ ? <Check className="w-5 h-5 stroke-[2.5]" />
498
+ : <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
499
+ }
500
+ </button>
501
+
502
+ {/* Collapse back to drawer */}
503
+ {onCollapseToDrawer && (
504
+ <button
505
+ onClick={onCollapseToDrawer}
506
+ className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
507
+ title="Collapse to drawer"
508
+ >
509
+ <Minimize2 className="w-5 h-5" />
510
+ </button>
511
+ )}
512
+ </div>
513
+
514
+ {/* Tabs (left) + Actions (right) */}
515
+ <div className="px-6 flex items-center border-t border-theme-border">
516
+ <div className="flex gap-1">
517
+ <TabButton active={activeTab === 'overview'} onClick={() => handleSetTab('overview')}>
518
+ <Layers className="w-4 h-4" />
519
+ Overview
520
+ </TabButton>
521
+ <TabButton active={activeTab === 'timeline'} onClick={() => handleSetTab('timeline')}>
522
+ <Activity className="w-4 h-4" />
523
+ Timeline
524
+ {resourceEvents.length > 0 && (
525
+ <span className="ml-1 px-1.5 py-0.5 text-xs bg-theme-elevated rounded">{resourceEvents.length}</span>
526
+ )}
527
+ </TabButton>
528
+ {allPods.length > 0 && renderLogsTab && (
529
+ <TabButton active={activeTab === 'logs'} onClick={() => handleSetTab('logs')}>
530
+ <Terminal className="w-4 h-4" />
531
+ Logs
532
+ </TabButton>
533
+ )}
534
+ {showMetricsTab && renderMetricsTab && (
535
+ <TabButton active={activeTab === 'metrics'} onClick={() => handleSetTab('metrics')}>
536
+ <BarChart3 className="w-4 h-4" />
537
+ Metrics
538
+ </TabButton>
539
+ )}
540
+ <TabButton active={activeTab === 'yaml'} onClick={() => handleSetTab('yaml')}>
541
+ <FileText className="w-4 h-4" />
542
+ YAML
543
+ </TabButton>
544
+ </div>
545
+ <div className="ml-auto">
546
+ <ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />
547
+ </div>
548
+ </div>
549
+ </div>
550
+
551
+ {/* Success animation overlay */}
552
+ {saveSuccess && <SaveSuccessAnimation />}
553
+
554
+ {/* Tab Content */}
555
+ <div className="flex-1 overflow-hidden relative">
556
+ {activeTab === 'overview' && (
557
+ <InfoTab
558
+ resource={resource}
559
+ selectedResource={selectedResource}
560
+ relationships={relationships}
561
+ isLoading={resourceLoading}
562
+ onNavigate={onNavigateToResource}
563
+ onCopy={copyToClipboard}
564
+ copied={copied}
565
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
566
+ isSavingSecret={isUpdatingResource}
567
+ onOpenLogs={handleOpenLogs}
568
+ onSwitchToTimeline={() => handleSetTab('timeline')}
569
+ />
570
+ )}
571
+ {activeTab === 'timeline' && (
572
+ <EventsTab
573
+ events={resourceEvents}
574
+ resourceLanes={resourceLanes}
575
+ isLoading={eventsLoading}
576
+ zoom={zoom}
577
+ onZoomChange={setZoom}
578
+ resourceKind={kind}
579
+ resourceName={name}
580
+ selectedEventId={selectedEventId}
581
+ onSelectEvent={setSelectedEventId}
582
+ />
583
+ )}
584
+ {activeTab === 'logs' && renderLogsTab && (
585
+ renderLogsTab({
586
+ kind,
587
+ apiKind,
588
+ namespace,
589
+ name,
590
+ resource,
591
+ pods: allPods,
592
+ selectedPod,
593
+ onSelectPod: setSelectedPod,
594
+ initialContainer,
595
+ onConsumeInitialContainer: () => setInitialContainer(null),
596
+ })
597
+ )}
598
+ {activeTab === 'metrics' && renderMetricsTab && (
599
+ <div className="h-full overflow-auto p-4">
600
+ {renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
601
+ </div>
602
+ )}
603
+ {activeTab === 'yaml' && (
604
+ <div className="h-full overflow-auto">
605
+ {resourceLoading ? (
606
+ <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
607
+ ) : !resource ? (
608
+ <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
609
+ ) : (
610
+ <EditableYamlView
611
+ resource={selectedResource}
612
+ data={resource}
613
+ onCopy={(text) => copyToClipboard(text, 'yaml')}
614
+ copied={copied === 'yaml'}
615
+ onSaved={handleSaved}
616
+ />
617
+ )}
618
+ </div>
619
+ )}
620
+ </div>
621
+ </div>
622
+ )
623
+ }
624
+
625
+ // ============================================================================
626
+ // HELPER FUNCTIONS
627
+ // ============================================================================
628
+
629
+ function extractMetadata(kind: string, resource: any): { label: string; value: string }[] {
630
+ if (!resource) return []
631
+ const items: { label: string; value: string }[] = []
632
+ const spec = resource.spec || {}
633
+ const status = resource.status || {}
634
+
635
+ switch (kind) {
636
+ case 'Deployment':
637
+ case 'StatefulSet':
638
+ case 'Rollout': {
639
+ const containers = spec.template?.spec?.containers || []
640
+ if (containers[0]?.image) items.push({ label: 'Image', value: containers[0].image })
641
+ break
642
+ }
643
+ case 'DaemonSet': {
644
+ const dsContainers = spec.template?.spec?.containers || []
645
+ if (dsContainers[0]?.image) items.push({ label: 'Image', value: dsContainers[0].image })
646
+ break
647
+ }
648
+ case 'Pod':
649
+ if (status.phase) items.push({ label: 'Phase', value: status.phase })
650
+ if (status.podIP) items.push({ label: 'Pod IP', value: status.podIP })
651
+ break
652
+ case 'CronJob':
653
+ if (spec.schedule) items.push({ label: 'Schedule', value: spec.schedule })
654
+ break
655
+ case 'Job':
656
+ if (status.succeeded !== undefined) items.push({ label: 'Succeeded', value: String(status.succeeded) })
657
+ break
658
+ }
659
+ return items
660
+ }
661
+
662
+ // ============================================================================
663
+ // SUB-COMPONENTS
664
+ // ============================================================================
665
+
666
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
667
+ return (
668
+ <button
669
+ onClick={onClick}
670
+ className={clsx(
671
+ 'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors',
672
+ active
673
+ ? 'text-theme-text-primary border-blue-500'
674
+ : 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light'
675
+ )}
676
+ >
677
+ {children}
678
+ </button>
679
+ )
680
+ }
681
+
682
+ // ============================================================================
683
+ // EVENTS TAB (Swimlane timeline)
684
+ // ============================================================================
685
+
686
+ function EventsTab({
687
+ events,
688
+ resourceLanes,
689
+ isLoading,
690
+ zoom,
691
+ onZoomChange,
692
+ resourceKind,
693
+ resourceName,
694
+ selectedEventId,
695
+ onSelectEvent,
696
+ }: {
697
+ events: TimelineEvent[]
698
+ resourceLanes: ResourceLane[]
699
+ isLoading: boolean
700
+ zoom: ZoomLevel
701
+ onZoomChange: (zoom: ZoomLevel) => void
702
+ resourceKind: string
703
+ resourceName: string
704
+ selectedEventId: string | null
705
+ onSelectEvent: (id: string | null) => void
706
+ }) {
707
+ const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map())
708
+ const tableContainerRef = useRef<HTMLDivElement>(null)
709
+ const [hoveredEventId, setHoveredEventId] = useState<string | null>(null)
710
+ const [visibleRowRange, setVisibleRowRange] = useState<{ first: number; last: number } | null>(null)
711
+
712
+ // Scroll to selected event
713
+ useEffect(() => {
714
+ if (selectedEventId) {
715
+ const eventIndex = events.findIndex(e => e.id === selectedEventId)
716
+ if (eventIndex >= 0) {
717
+ const row = rowRefs.current.get(eventIndex)
718
+ if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
719
+ }
720
+ }
721
+ }, [selectedEventId, events])
722
+
723
+ // Track visible rows via IntersectionObserver
724
+ useEffect(() => {
725
+ if (!tableContainerRef.current || events.length === 0) return
726
+ const visibleIndices = new Set<number>()
727
+ const observer = new IntersectionObserver(
728
+ (entries) => {
729
+ for (const entry of entries) {
730
+ const idx = parseInt(entry.target.getAttribute('data-row-index') || '-1', 10)
731
+ if (idx >= 0) {
732
+ if (entry.isIntersecting) visibleIndices.add(idx)
733
+ else visibleIndices.delete(idx)
734
+ }
735
+ }
736
+ if (visibleIndices.size > 0) {
737
+ const indices = Array.from(visibleIndices)
738
+ setVisibleRowRange({ first: Math.min(...indices), last: Math.max(...indices) })
739
+ } else {
740
+ setVisibleRowRange(null)
741
+ }
742
+ },
743
+ { root: tableContainerRef.current, threshold: 0.1 }
744
+ )
745
+ const timeoutId = setTimeout(() => {
746
+ rowRefs.current.forEach((row) => observer.observe(row))
747
+ }, 100)
748
+ return () => { clearTimeout(timeoutId); observer.disconnect() }
749
+ }, [events])
750
+
751
+ // Visible time range from visible rows
752
+ const visibleTimeRangeFromRows = useMemo(() => {
753
+ if (!visibleRowRange || events.length === 0) return null
754
+ const visibleEvents = events.slice(visibleRowRange.first, visibleRowRange.last + 1)
755
+ if (visibleEvents.length === 0) return null
756
+ const timestamps = visibleEvents.map(e => new Date(e.timestamp).getTime())
757
+ const start = Math.min(...timestamps)
758
+ const end = Math.max(...timestamps)
759
+ const timeSpan = end - start
760
+ const padding = Math.max(timeSpan * 0.1, 60000)
761
+ return { start: start - padding, end: end + padding }
762
+ }, [events, visibleRowRange])
763
+
764
+ const now = Date.now()
765
+ const { start: startTime, windowMs } = calculateTimeRange(zoom, now)
766
+ const zoomIndex = ZOOM_LEVELS.indexOf(zoom)
767
+ const canZoomIn = zoomIndex > 0
768
+ const canZoomOut = zoomIndex < ZOOM_LEVELS.length - 1
769
+ const handleZoomIn = () => { if (canZoomIn) onZoomChange(ZOOM_LEVELS[zoomIndex - 1]) }
770
+ const handleZoomOut = () => { if (canZoomOut) onZoomChange(ZOOM_LEVELS[zoomIndex + 1]) }
771
+ const localTimeToX = (ts: number) => timeToX(ts, startTime, windowMs)
772
+
773
+ // Build swimlanes
774
+ const swimlanes = useMemo(() => {
775
+ type SwimLane = {
776
+ id: string; label: string
777
+ spans: { start: number; end: number; health: string }[]
778
+ events: TimelineEvent[]
779
+ createdAt?: number; createdBeforeWindow: boolean
780
+ }
781
+
782
+ if (resourceLanes.length === 0) {
783
+ const mainResourceEvents = events.filter(e => e.kind === resourceKind && e.name === resourceName)
784
+ const healthResult = buildHealthSpans(mainResourceEvents.filter(e => isChangeEvent(e)), startTime, now, mainResourceEvents)
785
+ return [{ id: 'main', label: `${resourceKind}: ${resourceName}`, spans: healthResult.spans, events: mainResourceEvents, createdAt: healthResult.createdAt, createdBeforeWindow: healthResult.createdBeforeWindow }]
786
+ }
787
+
788
+ const rootLane = resourceLanes[0]
789
+ const lanes: SwimLane[] = []
790
+
791
+ const rootHealthResult = buildHealthSpans(rootLane.events.filter(e => isChangeEvent(e)), startTime, now, rootLane.events)
792
+ lanes.push({
793
+ id: rootLane.id,
794
+ label: `${rootLane.kind}: ${rootLane.name.length > 40 ? rootLane.name.slice(0, 20) + '...' + rootLane.name.slice(-17) : rootLane.name}`,
795
+ spans: rootHealthResult.spans, events: rootLane.events,
796
+ createdAt: rootHealthResult.createdAt, createdBeforeWindow: rootHealthResult.createdBeforeWindow,
797
+ })
798
+
799
+ const flattenChildren = (lane: ResourceLane): ResourceLane[] => {
800
+ const children = lane.children || []
801
+ return children.flatMap(child => [child, ...flattenChildren(child)])
802
+ }
803
+ const allChildren = flattenChildren(rootLane)
804
+
805
+ const kindPriority: Record<string, number> = {
806
+ Service: 1, Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
807
+ ReplicaSet: 3, ConfigMap: 4, Secret: 4, Gateway: 5, HTTPRoute: 4,
808
+ GRPCRoute: 4, TCPRoute: 4, TLSRoute: 4, Ingress: 5, Pod: 6,
809
+ }
810
+ allChildren.sort((a, b) => {
811
+ const aPriority = kindPriority[a.kind] || 10
812
+ const bPriority = kindPriority[b.kind] || 10
813
+ if (aPriority !== bPriority) return aPriority - bPriority
814
+ return b.events.length - a.events.length
815
+ })
816
+
817
+ for (const child of allChildren.slice(0, 6)) {
818
+ const childHealthResult = buildHealthSpans(child.events.filter(e => isChangeEvent(e)), startTime, now, child.events)
819
+ lanes.push({
820
+ id: child.id,
821
+ label: `${child.kind}: ${child.name.length > 40 ? child.name.slice(0, 20) + '...' + child.name.slice(-17) : child.name}`,
822
+ spans: childHealthResult.spans, events: child.events,
823
+ createdAt: childHealthResult.createdAt, createdBeforeWindow: childHealthResult.createdBeforeWindow,
824
+ })
825
+ }
826
+
827
+ return lanes
828
+ }, [resourceLanes, events, resourceKind, resourceName, startTime, now])
829
+
830
+ // Time axis ticks
831
+ const tickCount = 8
832
+ const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
833
+ const t = startTime + (windowMs * i) / tickCount
834
+ return { time: t, label: formatAxisTime(new Date(t)) }
835
+ })
836
+
837
+ const formatTimeRangeDisplay = () => {
838
+ const start = new Date(startTime)
839
+ const end = new Date(now)
840
+ return `${start.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} → ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
841
+ }
842
+
843
+ const nowX = localTimeToX(now)
844
+
845
+ if (isLoading) {
846
+ return (
847
+ <div className="flex items-center justify-center h-full text-theme-text-tertiary">
848
+ <RefreshCw className="w-5 h-5 animate-spin mr-2" />
849
+ Loading events...
850
+ </div>
851
+ )
852
+ }
853
+
854
+ return (
855
+ <div className="h-full flex flex-col overflow-hidden">
856
+ {/* Timeline toolbar */}
857
+ <div className="shrink-0 px-4 py-2 border-b border-theme-border bg-theme-surface/50 flex items-center justify-between">
858
+ <span className="text-sm font-medium text-theme-text-secondary">Events ({events.length})</span>
859
+ <div className="flex items-center gap-3">
860
+ <ZoomControls zoom={zoom} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} canZoomIn={canZoomIn} canZoomOut={canZoomOut} />
861
+ <span className="text-xs text-theme-text-tertiary">{formatTimeRangeDisplay()}</span>
862
+ </div>
863
+ </div>
864
+
865
+ {/* Legend */}
866
+ <div className="shrink-0 px-4 py-1.5 border-b border-theme-border bg-theme-surface/30 flex items-center justify-between">
867
+ <HealthSpanLegend />
868
+ <EventDotLegend />
869
+ </div>
870
+
871
+ {/* Swimlane Timeline */}
872
+ <div className="shrink-0 border-b border-theme-border bg-theme-base relative">
873
+ {/* Scrollable swimlane area — max 4 lanes visible before scrolling */}
874
+ <div className="max-h-[140px] overflow-y-auto relative">
875
+ {nowX >= 0 && nowX <= 100 && (
876
+ <div className="absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-20 pointer-events-none" style={{ left: `calc(280px + (100% - 280px) * ${nowX / 100})` }}>
877
+ <span className="absolute -top-4 left-1/2 -translate-x-1/2 text-xs text-purple-500 font-medium whitespace-nowrap">now</span>
878
+ </div>
879
+ )}
880
+
881
+ {swimlanes.map((lane) => (
882
+ <div key={lane.id} className="flex border-b border-theme-border/50 last:border-b-0">
883
+ <div className="w-[280px] shrink-0 px-3 py-1 bg-theme-surface/50 border-r border-theme-border text-xs font-medium text-theme-text-secondary truncate flex items-center">
884
+ {lane.label}
885
+ </div>
886
+ <div className="flex-1 relative h-7 bg-theme-base">
887
+ {visibleTimeRangeFromRows && (
888
+ <div className="absolute top-0 bottom-0 bg-blue-500/10 border-x border-blue-500/30 pointer-events-none" style={{
889
+ left: `${Math.max(0, localTimeToX(visibleTimeRangeFromRows.start))}%`,
890
+ width: `${Math.max(2, Math.min(100, localTimeToX(visibleTimeRangeFromRows.end)) - Math.max(0, localTimeToX(visibleTimeRangeFromRows.start)))}%`,
891
+ }} />
892
+ )}
893
+ {lane.spans.map((span, i) => {
894
+ const left = Math.max(0, localTimeToX(span.start))
895
+ const right = Math.min(100, localTimeToX(span.end))
896
+ const width = right - left
897
+ const showCreatedBefore = i === 0 && lane.createdBeforeWindow && lane.createdAt
898
+ return (
899
+ <HealthSpan
900
+ key={i}
901
+ health={span.health}
902
+ left={left}
903
+ width={width}
904
+ title={`${span.health} (${new Date(span.start).toLocaleTimeString()} - ${new Date(span.end).toLocaleTimeString()})`}
905
+ createdBefore={showCreatedBefore ? new Date(lane.createdAt!) : undefined}
906
+ />
907
+ )
908
+ })}
909
+ {lane.events.map((evt, i) => {
910
+ const x = localTimeToX(new Date(evt.timestamp).getTime())
911
+ if (x < 0 || x > 100) return null
912
+ return (
913
+ <EventMarker
914
+ key={`${evt.id}-${i}`}
915
+ event={evt}
916
+ x={x}
917
+ selected={selectedEventId === evt.id}
918
+ onClick={() => onSelectEvent(selectedEventId === evt.id ? null : evt.id)}
919
+ small
920
+ />
921
+ )
922
+ })}
923
+ </div>
924
+ </div>
925
+ ))}
926
+ </div>
927
+
928
+ {/* Time axis */}
929
+ <div className="flex">
930
+ <div className="w-[280px] shrink-0 bg-theme-surface/50 border-r border-theme-border" />
931
+ <div className="flex-1 relative h-5 bg-theme-elevated/30">
932
+ {ticks.map((tick, i) => {
933
+ const x = localTimeToX(tick.time)
934
+ return (
935
+ <div key={i} className="absolute top-0 flex flex-col items-center" style={{ left: `${x}%`, transform: 'translateX(-50%)' }}>
936
+ <div className="h-1.5 w-px bg-theme-border" />
937
+ <span className="text-[10px] text-theme-text-tertiary">{tick.label}</span>
938
+ </div>
939
+ )
940
+ })}
941
+ </div>
942
+ </div>
943
+ </div>
944
+
945
+ {/* Events table */}
946
+ <div ref={tableContainerRef} className="flex-1 overflow-auto">
947
+ <table className="w-full text-sm">
948
+ <thead className="sticky top-0 bg-theme-surface border-b border-theme-border z-10">
949
+ <tr className="text-left text-xs text-theme-text-tertiary">
950
+ <th className="px-4 py-2 font-medium w-32">Event Type</th>
951
+ <th className="px-4 py-2 font-medium">Summary</th>
952
+ <th className="px-4 py-2 font-medium w-40">Time</th>
953
+ <th className="px-4 py-2 font-medium w-32">Resource</th>
954
+ <th className="px-4 py-2 font-medium w-24">Status</th>
955
+ </tr>
956
+ </thead>
957
+ <tbody className="table-divide-subtle">
958
+ {events.length === 0 ? (
959
+ <tr>
960
+ <td colSpan={5} className="px-4 py-8 text-center text-theme-text-tertiary">
961
+ No events in this time range
962
+ </td>
963
+ </tr>
964
+ ) : (
965
+ events.map((evt, evtIdx) => {
966
+ const isSelected = selectedEventId === evt.id
967
+ const isHovered = hoveredEventId === evt.id
968
+ const isWarning = isProblematicEvent(evt)
969
+ return (
970
+ <tr
971
+ key={`${evt.id}-${evtIdx}`}
972
+ ref={(el) => { if (el) rowRefs.current.set(evtIdx, el); else rowRefs.current.delete(evtIdx) }}
973
+ data-row-index={evtIdx}
974
+ onClick={() => onSelectEvent(isSelected ? null : evt.id)}
975
+ onMouseEnter={() => setHoveredEventId(evt.id)}
976
+ onMouseLeave={() => setHoveredEventId(null)}
977
+ className={clsx(
978
+ 'cursor-pointer transition-colors',
979
+ isSelected ? 'bg-blue-500/10' : isHovered ? 'bg-blue-500/5' : 'hover:bg-theme-surface/50'
980
+ )}
981
+ >
982
+ <td className="px-4 py-3">
983
+ <div className="flex items-center gap-2">
984
+ <EventDot event={evt} />
985
+ <span className={clsx('font-medium', isWarning ? 'text-amber-500' : 'text-theme-text-primary')}>
986
+ {isHistoricalEvent(evt) && evt.reason ? evt.reason : isChangeEvent(evt) ? evt.eventType : evt.reason}
987
+ </span>
988
+ </div>
989
+ </td>
990
+ <td className="px-4 py-3 text-theme-text-secondary">
991
+ {evt.message || evt.diff?.summary || '-'}
992
+ </td>
993
+ <td className="px-4 py-3 text-theme-text-tertiary">
994
+ {new Date(evt.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
995
+ </td>
996
+ <td className="px-4 py-3">
997
+ <span className={clsx('text-xs px-1.5 py-0.5 rounded', getKindBadgeColor(evt.kind))}>
998
+ {evt.kind}
999
+ </span>
1000
+ </td>
1001
+ <td className="px-4 py-3">
1002
+ {isWarning ? (
1003
+ <span className="text-xs px-2 py-0.5 rounded bg-amber-500/20 text-amber-500">Active</span>
1004
+ ) : evt.healthState ? (
1005
+ <span className={clsx('text-xs px-2 py-0.5 rounded', getHealthBadgeColor(evt.healthState))}>{evt.healthState}</span>
1006
+ ) : null}
1007
+ </td>
1008
+ </tr>
1009
+ )
1010
+ })
1011
+ )}
1012
+ </tbody>
1013
+ </table>
1014
+ </div>
1015
+ </div>
1016
+ )
1017
+ }
1018
+
1019
+ function EventDot({ event }: { event: TimelineEvent }) {
1020
+ const isWarning = isProblematicEvent(event)
1021
+ const isDelete = event.eventType === 'delete'
1022
+ const isAdd = event.eventType === 'add'
1023
+ return (
1024
+ <div className={clsx(
1025
+ 'w-3 h-3 rounded-full shrink-0',
1026
+ isWarning ? 'bg-amber-500' : isDelete ? 'bg-red-500' : isAdd ? 'bg-green-500' : 'bg-blue-500'
1027
+ )} />
1028
+ )
1029
+ }
1030
+
1031
+ // ============================================================================
1032
+ // INFO TAB — uses ResourceRendererDispatch for kind-specific rendering
1033
+ // ============================================================================
1034
+
1035
+ function InfoTab({
1036
+ resource,
1037
+ selectedResource,
1038
+ relationships,
1039
+ isLoading,
1040
+ onNavigate,
1041
+ onCopy,
1042
+ copied,
1043
+ onSaveSecretValue,
1044
+ isSavingSecret,
1045
+ onOpenLogs,
1046
+ onSwitchToTimeline,
1047
+ }: {
1048
+ resource: any
1049
+ selectedResource: SelectedResource
1050
+ relationships?: Relationships
1051
+ isLoading: boolean
1052
+ onNavigate?: NavigateToResource
1053
+ onCopy: (text: string, key: string) => void
1054
+ copied: string | null
1055
+ onSaveSecretValue?: (yaml: string) => Promise<void>
1056
+ isSavingSecret?: boolean
1057
+ onOpenLogs?: (podName: string, containerName: string) => void
1058
+ onSwitchToTimeline?: () => void
1059
+ }) {
1060
+ if (isLoading) {
1061
+ return (
1062
+ <div className="flex items-center justify-center h-full text-theme-text-tertiary">
1063
+ <RefreshCw className="w-5 h-5 animate-spin mr-2" />
1064
+ Loading...
1065
+ </div>
1066
+ )
1067
+ }
1068
+
1069
+ if (!resource) {
1070
+ return (
1071
+ <div className="flex items-center justify-center h-full text-theme-text-tertiary">
1072
+ Resource not found
1073
+ </div>
1074
+ )
1075
+ }
1076
+
1077
+ return (
1078
+ <div className="h-full overflow-auto">
1079
+ <ResourceRendererDispatch
1080
+ resource={selectedResource}
1081
+ data={resource}
1082
+ relationships={relationships}
1083
+ onCopy={onCopy}
1084
+ copied={copied}
1085
+ onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
1086
+ onSaveSecretValue={onSaveSecretValue}
1087
+ isSavingSecret={isSavingSecret}
1088
+ showCommonSections={true}
1089
+ showMetrics={false}
1090
+ onOpenLogs={onOpenLogs}
1091
+ eventsHint={onSwitchToTimeline && (
1092
+ <button
1093
+ onClick={onSwitchToTimeline}
1094
+ className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
1095
+ >
1096
+ These are events for this resource only. Switch to the <span className="underline">Timeline</span> tab to see events across all related resources.
1097
+ </button>
1098
+ )}
1099
+ renderSidebar={(sidebarSections) => (
1100
+ <div className="lg:w-[35%] lg:shrink-0 lg:border-l border-theme-border">
1101
+ <div className="p-4 space-y-4">
1102
+ {sidebarSections}
1103
+ </div>
1104
+ </div>
1105
+ )}
1106
+ />
1107
+ </div>
1108
+ )
1109
+ }