@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,748 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ ReactFlow,
4
+ ReactFlowProvider,
5
+ Background,
6
+ Controls,
7
+ useNodesState,
8
+ useEdgesState,
9
+ useReactFlow,
10
+ useOnViewportChange,
11
+ useNodes,
12
+ type Node,
13
+ type Edge,
14
+ type NodeTypes,
15
+ type Viewport,
16
+ BackgroundVariant,
17
+ MarkerType,
18
+ } from '@xyflow/react'
19
+ import '@xyflow/react/dist/style.css'
20
+
21
+ import { AlertTriangle, RotateCw, Scissors, Shield } from 'lucide-react'
22
+ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
23
+
24
+ import { K8sResourceNode } from './K8sResourceNode'
25
+ import { GroupNode } from './GroupNode'
26
+ import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey } from './layout'
27
+ import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
28
+
29
+ // Edge colors by type
30
+ const EDGE_COLORS = {
31
+ 'routes-to': '#22c55e', // Green for traffic flow
32
+ 'exposes': '#3b82f6', // Blue for service exposure
33
+ 'manages': '#64748b', // Gray for management relationships
34
+ 'configures': '#f59e0b', // Amber for config
35
+ 'uses': '#ec4899', // Pink for HPA
36
+ } as const
37
+
38
+ function getEdgeColor(type: string, isTrafficView: boolean): string {
39
+ if (isTrafficView) {
40
+ // In traffic view, use green for all edges
41
+ return '#22c55e'
42
+ }
43
+ return EDGE_COLORS[type as keyof typeof EDGE_COLORS] || '#64748b'
44
+ }
45
+
46
+ // Memoized edge style cache to avoid creating new objects on every render
47
+ const edgeStyleCache = new Map<string, React.CSSProperties>()
48
+
49
+ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean): React.CSSProperties {
50
+ const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}`
51
+ let style = edgeStyleCache.get(cacheKey)
52
+ if (!style) {
53
+ const edgeColor = getEdgeColor(type, isTrafficView)
54
+ style = {
55
+ stroke: edgeColor,
56
+ strokeWidth: isTrafficView ? 2 : 1.5,
57
+ strokeDasharray: isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
58
+ }
59
+ edgeStyleCache.set(cacheKey, style)
60
+ }
61
+ return style
62
+ }
63
+
64
+ // Threshold for disabling edge animations (performance optimization)
65
+ const EDGE_ANIMATION_THRESHOLD = 200
66
+
67
+ // Build edges, handling collapsed groups
68
+ function buildEdges(
69
+ topologyEdges: { id: string; source: string; target: string; type: string }[],
70
+ collapsedGroups: Set<string>,
71
+ groupMap: Map<string, string[]>,
72
+ groupingMode: GroupingMode,
73
+ isTrafficView: boolean,
74
+ nodeToGroup?: Map<string, string>,
75
+ nodeCount?: number
76
+ ): Edge[] {
77
+ const edges: Edge[] = []
78
+ const seenEdgeIds = new Set<string>() // O(1) duplicate detection
79
+
80
+ // Disable animations for large graphs (performance optimization)
81
+ const enableAnimations = (nodeCount ?? 0) < EDGE_ANIMATION_THRESHOLD
82
+
83
+ // Build reverse lookup if not provided
84
+ const nodeGroupMap = nodeToGroup || new Map<string, string>()
85
+ if (!nodeToGroup) {
86
+ for (const [groupKey, memberIds] of groupMap) {
87
+ const groupId = `group-${groupingMode}-${groupKey}`
88
+ for (const nodeId of memberIds) {
89
+ nodeGroupMap.set(nodeId, groupId)
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const edge of topologyEdges) {
95
+ let source = edge.source
96
+ let target = edge.target
97
+
98
+ // If source is in a collapsed group, point to the group instead
99
+ const sourceGroup = nodeGroupMap.get(source)
100
+ if (sourceGroup && collapsedGroups.has(sourceGroup)) {
101
+ source = sourceGroup
102
+ }
103
+
104
+ // If target is in a collapsed group, point to the group instead
105
+ const targetGroup = nodeGroupMap.get(target)
106
+ if (targetGroup && collapsedGroups.has(targetGroup)) {
107
+ target = targetGroup
108
+ }
109
+
110
+ // Skip self-loops (both ends in same collapsed group)
111
+ if (source === target) continue
112
+
113
+ // Skip duplicate edges (O(1) with Set)
114
+ const edgeId = `${source}-${target}-${edge.type}`
115
+ if (seenEdgeIds.has(edgeId)) continue
116
+ seenEdgeIds.add(edgeId)
117
+
118
+ const edgeColor = getEdgeColor(edge.type, isTrafficView)
119
+ const isTrafficEdge = edge.type === 'routes-to' || edge.type === 'exposes'
120
+ const animated = enableAnimations && isTrafficView && isTrafficEdge
121
+
122
+ edges.push({
123
+ id: edgeId,
124
+ source,
125
+ target,
126
+ type: 'smoothstep',
127
+ animated,
128
+ markerEnd: {
129
+ type: MarkerType.ArrowClosed,
130
+ color: edgeColor,
131
+ width: 12,
132
+ height: 12,
133
+ },
134
+ style: getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated),
135
+ })
136
+ }
137
+
138
+ return edges
139
+ }
140
+
141
+ // Custom node types
142
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
143
+ const nodeTypes: NodeTypes = {
144
+ k8sResource: K8sResourceNode as any,
145
+ group: GroupNode as any,
146
+ }
147
+
148
+ interface TopologyGraphProps {
149
+ topology: Topology | null
150
+ viewMode: ViewMode
151
+ groupingMode: GroupingMode
152
+ hideGroupHeader?: boolean
153
+ onNodeClick: (node: TopologyNode) => void
154
+ selectedNodeId?: string
155
+ }
156
+
157
+ export function TopologyGraph({
158
+ topology,
159
+ viewMode,
160
+ groupingMode,
161
+ hideGroupHeader = false,
162
+ onNodeClick,
163
+ selectedNodeId,
164
+ }: TopologyGraphProps) {
165
+ const isTrafficView = viewMode === 'traffic'
166
+ const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[])
167
+ const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
168
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
169
+ const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
170
+ const [layoutError, setLayoutError] = useState<string | null>(null)
171
+ const [layoutRetryCount, setLayoutRetryCount] = useState(0)
172
+ const prevStructureRef = useRef<string>('')
173
+ const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
174
+
175
+ // Toggle group collapse
176
+ const handleToggleCollapse = useCallback((groupId: string) => {
177
+ setCollapsedGroups(prev => {
178
+ const next = new Set(prev)
179
+ if (next.has(groupId)) {
180
+ next.delete(groupId)
181
+ } else {
182
+ next.add(groupId)
183
+ }
184
+ return next
185
+ })
186
+ }, [])
187
+
188
+ // Expand pod group to show individual pods
189
+ const handleExpandPodGroup = useCallback((podGroupId: string) => {
190
+ setExpandedPodGroups(prev => new Set(prev).add(podGroupId))
191
+ }, [])
192
+
193
+ // Collapse pod group back
194
+ const handleCollapsePodGroup = useCallback((podGroupId: string) => {
195
+ setExpandedPodGroups(prev => {
196
+ const next = new Set(prev)
197
+ next.delete(podGroupId)
198
+ return next
199
+ })
200
+ }, [])
201
+
202
+ // Expand PodGroup to individual pods
203
+ const expandPodGroup = useCallback((
204
+ topoNodes: TopologyNode[],
205
+ topoEdges: TopologyEdge[],
206
+ podGroupId: string
207
+ ): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
208
+ const podGroupNode = topoNodes.find(n => n.id === podGroupId && n.kind === 'PodGroup')
209
+ if (!podGroupNode || !podGroupNode.data.pods) {
210
+ return { nodes: topoNodes, edges: topoEdges }
211
+ }
212
+
213
+ const pods = podGroupNode.data.pods as Array<{
214
+ name: string
215
+ namespace: string
216
+ phase: string
217
+ restarts: number
218
+ containers: number
219
+ }>
220
+
221
+ // Find edges pointing to this pod group
222
+ const edgesToGroup = topoEdges.filter(e => e.target === podGroupId)
223
+ const sourceIds = edgesToGroup.map(e => e.source)
224
+
225
+ // Remove the PodGroup node and its edges
226
+ const newNodes = topoNodes.filter(n => n.id !== podGroupId)
227
+ const newEdges = topoEdges.filter(e => e.target !== podGroupId)
228
+
229
+ // Add individual pod nodes
230
+ for (const pod of pods) {
231
+ const podId = `pod/${pod.namespace}/${pod.name}`
232
+ newNodes.push({
233
+ id: podId,
234
+ kind: 'Pod',
235
+ name: pod.name,
236
+ status: pod.phase === 'Running' ? 'healthy' : pod.phase === 'Pending' ? 'degraded' : 'unhealthy',
237
+ data: {
238
+ namespace: pod.namespace,
239
+ phase: pod.phase,
240
+ restarts: pod.restarts,
241
+ containers: pod.containers,
242
+ expandedFromGroup: podGroupId, // Track which group this came from
243
+ },
244
+ })
245
+
246
+ // Add edges from all sources to this pod
247
+ for (const sourceId of sourceIds) {
248
+ newEdges.push({
249
+ id: `${sourceId}-to-${podId}`,
250
+ source: sourceId,
251
+ target: podId,
252
+ type: 'routes-to' as const,
253
+ })
254
+ }
255
+ }
256
+
257
+ return { nodes: newNodes, edges: newEdges }
258
+ }, [])
259
+
260
+ // Transform to per-group Internet nodes in traffic view with grouping
261
+ const createPerGroupInternetNodes = useCallback((
262
+ nodes: TopologyNode[],
263
+ edges: TopologyEdge[],
264
+ groupMode: GroupingMode
265
+ ): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
266
+ if (groupMode === 'none') {
267
+ return { nodes, edges }
268
+ }
269
+
270
+ // Find the single Internet node
271
+ const internetNode = nodes.find(n => n.kind === 'Internet')
272
+ if (!internetNode) {
273
+ return { nodes, edges }
274
+ }
275
+
276
+ // Find all ingresses/gateways and group them
277
+ const ingresses = nodes.filter(n => n.kind === 'Ingress' || n.kind === 'Gateway')
278
+ const groupsWithIngresses = new Map<string, TopologyNode[]>()
279
+
280
+ for (const ingress of ingresses) {
281
+ const groupKey = getGroupKey(ingress, groupMode)
282
+ if (groupKey) {
283
+ if (!groupsWithIngresses.has(groupKey)) {
284
+ groupsWithIngresses.set(groupKey, [])
285
+ }
286
+ groupsWithIngresses.get(groupKey)!.push(ingress)
287
+ }
288
+ }
289
+
290
+ // If no groups with ingresses, keep original
291
+ if (groupsWithIngresses.size === 0) {
292
+ return { nodes, edges }
293
+ }
294
+
295
+ // Remove original Internet node and its edges
296
+ const newNodes = nodes.filter(n => n.id !== internetNode.id)
297
+ const newEdges = edges.filter(e => e.source !== internetNode.id)
298
+
299
+ // Create per-group Internet nodes
300
+ for (const [groupKey, groupIngresses] of groupsWithIngresses) {
301
+ const internetId = `internet-${groupMode}-${groupKey}`
302
+
303
+ // Add Internet node for this group with group metadata
304
+ newNodes.push({
305
+ id: internetId,
306
+ kind: 'Internet',
307
+ name: 'Internet',
308
+ status: 'healthy',
309
+ data: {
310
+ // Add group metadata so it gets grouped with its ingresses
311
+ namespace: groupMode === 'namespace' ? groupKey : groupIngresses[0]?.data?.namespace,
312
+ labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
313
+ },
314
+ })
315
+
316
+ // Add edges from this Internet node to its ingresses
317
+ for (const ingress of groupIngresses) {
318
+ newEdges.push({
319
+ id: `${internetId}-to-${ingress.id}`,
320
+ source: internetId,
321
+ target: ingress.id,
322
+ type: 'routes-to',
323
+ })
324
+ }
325
+ }
326
+
327
+ return { nodes: newNodes, edges: newEdges }
328
+ }, [])
329
+
330
+ // Prepare topology data with expanded pod groups
331
+ const { workingNodes, workingEdges } = useMemo(() => {
332
+ if (!topology) {
333
+ return { workingNodes: [] as TopologyNode[], workingEdges: [] as TopologyEdge[] }
334
+ }
335
+
336
+ let nodes = [...topology.nodes]
337
+ let edges = [...topology.edges]
338
+
339
+ // Expand pod groups
340
+ for (const podGroupId of expandedPodGroups) {
341
+ const result = expandPodGroup(nodes, edges, podGroupId)
342
+ nodes = result.nodes
343
+ edges = result.edges
344
+ }
345
+
346
+ // In traffic view with grouping, create per-group Internet nodes
347
+ if (isTrafficView && groupingMode !== 'none') {
348
+ const result = createPerGroupInternetNodes(nodes, edges, groupingMode)
349
+ nodes = result.nodes
350
+ edges = result.edges
351
+ }
352
+
353
+ return { workingNodes: nodes, workingEdges: edges }
354
+ }, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
355
+
356
+ // Structure key for change detection
357
+ const structureKey = useMemo(() => {
358
+ const nodeIds = workingNodes.map(n => n.id).sort().join(',')
359
+ const collapsed = Array.from(collapsedGroups).sort().join(',')
360
+ const expanded = Array.from(expandedPodGroups).sort().join(',')
361
+ return `${viewMode}|${nodeIds}|${collapsed}|${expanded}|${groupingMode}|${layoutRetryCount}`
362
+ }, [viewMode, workingNodes, collapsedGroups, expandedPodGroups, groupingMode, layoutRetryCount])
363
+
364
+ // Layout when structure changes - use hierarchical ELK layout
365
+ useEffect(() => {
366
+ if (workingNodes.length === 0) {
367
+ setNodes([])
368
+ setEdges([])
369
+ prevStructureRef.current = ''
370
+ return
371
+ }
372
+
373
+ const structureChanged = structureKey !== prevStructureRef.current
374
+
375
+ if (!structureChanged) {
376
+ return
377
+ }
378
+
379
+ prevStructureRef.current = structureKey
380
+
381
+ // Increment version to invalidate any previous in-flight layout
382
+ const thisLayoutVersion = ++layoutVersionRef.current
383
+
384
+ // Build hierarchical ELK graph
385
+ const { elkGraph, groupMap, nodeToGroup } = buildHierarchicalElkGraph(
386
+ workingNodes,
387
+ workingEdges,
388
+ groupingMode,
389
+ collapsedGroups
390
+ )
391
+
392
+ // Apply layout and get positioned nodes
393
+ applyHierarchicalLayout(
394
+ elkGraph,
395
+ workingNodes,
396
+ groupMap,
397
+ groupingMode,
398
+ collapsedGroups,
399
+ handleToggleCollapse,
400
+ hideGroupHeader
401
+ ).then(({ nodes: layoutedNodes, error }) => {
402
+ // Check if a newer layout has started - if so, discard this stale result
403
+ if (layoutVersionRef.current !== thisLayoutVersion) {
404
+ return
405
+ }
406
+
407
+ // Handle layout errors
408
+ if (error) {
409
+ console.error('Layout error:', error)
410
+ setLayoutError(error)
411
+ return
412
+ }
413
+ setLayoutError(null)
414
+
415
+ // Add expand/collapse handlers to pod-related nodes
416
+ const nodesWithHandlers = layoutedNodes.map(node => {
417
+ const isPodGroup = node.data?.kind === 'PodGroup'
418
+ const nodeData = node.data?.nodeData as Record<string, unknown> | undefined
419
+ const expandedFromGroup = nodeData?.expandedFromGroup as string | undefined
420
+
421
+ return {
422
+ ...node,
423
+ data: {
424
+ ...node.data,
425
+ onExpand: isPodGroup ? handleExpandPodGroup : undefined,
426
+ onCollapse: expandedFromGroup ? handleCollapsePodGroup : undefined,
427
+ isExpanded: isPodGroup ? expandedPodGroups.has(node.id) : undefined,
428
+ },
429
+ }
430
+ })
431
+
432
+ setNodes(nodesWithHandlers)
433
+
434
+ // Build edges with styling (pass node count for animation threshold)
435
+ const builtEdges = buildEdges(
436
+ workingEdges,
437
+ collapsedGroups,
438
+ groupMap,
439
+ groupingMode,
440
+ isTrafficView,
441
+ nodeToGroup,
442
+ nodesWithHandlers.length
443
+ )
444
+ setEdges(builtEdges)
445
+ })
446
+
447
+ // No cleanup function - we use version-based invalidation instead
448
+ // This prevents React's effect re-runs from canceling in-flight layouts
449
+ // when the actual structure hasn't changed
450
+ }, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups, handleToggleCollapse, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, setNodes, setEdges, layoutRetryCount])
451
+
452
+ // Handle node click
453
+ const handleNodeClick = useCallback(
454
+ (_event: React.MouseEvent, node: Node) => {
455
+ // Ignore clicks on group nodes
456
+ if (node.type === 'group') return
457
+
458
+ // First try to find in original topology
459
+ let topologyNode = topology?.nodes.find(n => n.id === node.id)
460
+
461
+ // If not found, check workingNodes (for expanded pods from PodGroup)
462
+ if (!topologyNode) {
463
+ topologyNode = workingNodes.find(n => n.id === node.id)
464
+ }
465
+
466
+ if (topologyNode) {
467
+ onNodeClick(topologyNode)
468
+ }
469
+ },
470
+ [topology, workingNodes, onNodeClick]
471
+ )
472
+
473
+ // Update selected state - only update nodes that actually changed
474
+ useEffect(() => {
475
+ setNodes(nds => {
476
+ let changed = false
477
+ const updated = nds.map(node => {
478
+ const shouldBeSelected = node.id === selectedNodeId
479
+ const isCurrentlySelected = node.data?.selected ?? false
480
+ if (shouldBeSelected !== isCurrentlySelected) {
481
+ changed = true
482
+ return {
483
+ ...node,
484
+ data: {
485
+ ...node.data,
486
+ selected: shouldBeSelected,
487
+ },
488
+ }
489
+ }
490
+ return node // Return same reference if unchanged
491
+ })
492
+ return changed ? updated : nds // Return same array if nothing changed
493
+ })
494
+ }, [selectedNodeId, setNodes])
495
+
496
+ if (!topology || topology.nodes.length === 0) {
497
+ return (
498
+ <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
499
+ <div className="text-center">
500
+ <p className="text-lg">No resources found</p>
501
+ <p className="text-sm mt-2">
502
+ Select a namespace or check your cluster connection
503
+ </p>
504
+ </div>
505
+ </div>
506
+ )
507
+ }
508
+
509
+ // Show layout error if we have topology data but layout failed
510
+ if (layoutError && nodes.length === 0) {
511
+ return (
512
+ <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
513
+ <div className="text-center max-w-md">
514
+ <p className="text-lg text-amber-400">Layout Error</p>
515
+ <p className="text-sm mt-2">
516
+ Failed to compute topology layout. The graph has {topology.nodes.length} nodes.
517
+ </p>
518
+ <p className="text-xs mt-2 text-theme-text-tertiary font-mono bg-theme-surface-secondary p-2 rounded">
519
+ {layoutError}
520
+ </p>
521
+ <button
522
+ onClick={() => {
523
+ setLayoutError(null)
524
+ setLayoutRetryCount(c => c + 1)
525
+ }}
526
+ className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-surface hover:bg-theme-elevated border border-theme-border rounded-lg transition-colors"
527
+ >
528
+ <RotateCw className="w-4 h-4" />
529
+ Retry Layout
530
+ </button>
531
+ </div>
532
+ </div>
533
+ )
534
+ }
535
+
536
+ return (
537
+ <ReactFlowProvider>
538
+ {/* Truncation banner - shown when topology has too many nodes */}
539
+ {topology?.truncated && (
540
+ <div className="absolute top-2 left-2 right-2 z-10 bg-blue-500/10 border border-blue-500/30 rounded-lg p-2 backdrop-blur-sm">
541
+ <div className="flex items-center gap-2">
542
+ <Scissors className="w-4 h-4 text-blue-400 shrink-0" />
543
+ <div className="text-sm">
544
+ <span className="font-medium text-blue-400">Large cluster:</span>
545
+ <span className="text-theme-text-secondary ml-1">
546
+ Showing {topology.nodes.length} of {topology.totalNodes} nodes.
547
+ Select a namespace for better performance.
548
+ </span>
549
+ </div>
550
+ </div>
551
+ </div>
552
+ )}
553
+ {/* Warning banner for partial topology data */}
554
+ {topology?.warnings && topology.warnings.length > 0 && !topology.truncated && (() => {
555
+ const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
556
+ const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted'))
557
+ const isAllRbac = otherWarnings.length === 0
558
+ return (
559
+ <div className={`absolute top-2 left-2 right-2 z-10 ${isAllRbac ? 'bg-amber-500/10 border-amber-500/20' : 'bg-amber-500/10 border-amber-500/30'} border rounded-lg p-2 backdrop-blur-sm`}>
560
+ <div className="flex items-start gap-2">
561
+ {isAllRbac ? (
562
+ <Shield className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
563
+ ) : (
564
+ <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
565
+ )}
566
+ <div className="text-sm">
567
+ <span className="font-medium text-amber-400">
568
+ {isAllRbac ? 'Limited Access:' : 'Warning:'}
569
+ </span>
570
+ <span className="text-theme-text-secondary ml-1">
571
+ {isAllRbac
572
+ ? `${rbacWarnings.length} resource type${rbacWarnings.length > 1 ? 's' : ''} not accessible due to RBAC restrictions.`
573
+ : 'Some resources failed to load. Data may be incomplete.'}
574
+ </span>
575
+ <details className="mt-1">
576
+ <summary className="text-xs text-amber-400/80 hover:text-amber-400">
577
+ Show details ({topology.warnings.length})
578
+ </summary>
579
+ <ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
580
+ {rbacWarnings.length > 0 && otherWarnings.length > 0 && (
581
+ <li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
582
+ )}
583
+ {rbacWarnings.map((w, i) => (
584
+ <li key={`rbac-${i}`} className="font-mono">{w}</li>
585
+ ))}
586
+ {otherWarnings.length > 0 && rbacWarnings.length > 0 && (
587
+ <li className="text-amber-400/60 font-medium mt-1">Other warnings:</li>
588
+ )}
589
+ {otherWarnings.map((w, i) => (
590
+ <li key={`other-${i}`} className="font-mono">{w}</li>
591
+ ))}
592
+ </ul>
593
+ </details>
594
+ </div>
595
+ </div>
596
+ </div>
597
+ )
598
+ })()}
599
+ {/* Layout error banner - shown even when stale nodes exist */}
600
+ {layoutError && nodes.length > 0 && (
601
+ <div className="absolute top-2 left-2 right-2 z-10 bg-red-500/10 border border-red-500/30 rounded-lg p-2 backdrop-blur-sm">
602
+ <div className="flex items-start gap-2">
603
+ <AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
604
+ <div className="text-sm">
605
+ <span className="font-medium text-red-400">Layout Error:</span>
606
+ <span className="text-theme-text-secondary ml-1">
607
+ Failed to update layout. Showing previous view.
608
+ </span>
609
+ <p className="mt-1 text-xs text-theme-text-tertiary font-mono">{layoutError}</p>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ )}
614
+ <ReactFlow
615
+ nodes={nodes}
616
+ edges={edges}
617
+ onNodesChange={onNodesChange}
618
+ onEdgesChange={onEdgesChange}
619
+ onNodeClick={handleNodeClick}
620
+ nodeTypes={nodeTypes}
621
+ fitView
622
+ fitViewOptions={{ padding: 0.2 }}
623
+ minZoom={0.1}
624
+ maxZoom={2}
625
+ proOptions={{ hideAttribution: true }}
626
+ onlyRenderVisibleElements
627
+ >
628
+ <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
629
+ <Controls
630
+ className="bg-theme-surface border border-theme-border rounded-lg"
631
+ showInteractive={false}
632
+ />
633
+ <ViewportController structureKey={structureKey} />
634
+ </ReactFlow>
635
+ </ReactFlowProvider>
636
+ )
637
+ }
638
+
639
+ // Animation duration for viewport transitions
640
+ const VIEWPORT_ANIMATION_DURATION = 400
641
+
642
+ // Inner component to handle animated viewport transitions and zoom-based CSS variables
643
+ // Must be inside ReactFlow to use useReactFlow hook
644
+ function ViewportController({ structureKey }: { structureKey: string }) {
645
+ const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
646
+ const nodes = useNodes() // Reactive hook to watch node changes
647
+ const prevStructureKeyRef = useRef<string>('')
648
+ const prevNodesLengthRef = useRef(0)
649
+
650
+ // Topology keyboard shortcuts
651
+ useRegisterShortcuts([
652
+ {
653
+ id: 'topology-fit-view',
654
+ keys: 'f',
655
+ description: 'Fit graph to screen',
656
+ category: 'Topology',
657
+ scope: 'topology',
658
+ handler: () => fitView({ padding: 0.15, duration: VIEWPORT_ANIMATION_DURATION }),
659
+ },
660
+ {
661
+ id: 'topology-zoom-in',
662
+ keys: '+',
663
+ description: 'Zoom in',
664
+ category: 'Topology',
665
+ scope: 'topology',
666
+ handler: () => zoomIn({ duration: 200 }),
667
+ },
668
+ {
669
+ id: 'topology-zoom-in-equals',
670
+ keys: '=',
671
+ description: 'Zoom in',
672
+ category: 'Topology',
673
+ scope: 'topology',
674
+ handler: () => zoomIn({ duration: 200 }),
675
+ },
676
+ {
677
+ id: 'topology-zoom-out',
678
+ keys: '-',
679
+ description: 'Zoom out',
680
+ category: 'Topology',
681
+ scope: 'topology',
682
+ handler: () => zoomOut({ duration: 200 }),
683
+ },
684
+ {
685
+ id: 'topology-reset-zoom',
686
+ keys: '0',
687
+ description: 'Reset zoom',
688
+ category: 'Topology',
689
+ scope: 'topology',
690
+ handler: () => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 200 }),
691
+ },
692
+ ])
693
+
694
+ // Update CSS variables for header offset and scale based on zoom
695
+ // This allows child nodes to move up when header shrinks (zoomed in)
696
+ // and allows GroupNode to use CSS var instead of useViewport() (prevents re-renders)
697
+ const updateZoomOffset = useCallback((viewport: Viewport) => {
698
+ const { zoom } = viewport
699
+ // Match the headerScale formula from GroupNode
700
+ // Min 0.5 = header never shrinks below 50%, formula 0.7/zoom = less aggressive scaling
701
+ const headerScale = Math.max(0.5, Math.min(1, 0.7 / zoom))
702
+ // At scale 1.0, offset is 0. At scale 0.5, offset is ~35px (header shrinks by ~35px)
703
+ const headerOffset = (1 - headerScale) * 70
704
+ document.documentElement.style.setProperty('--group-header-offset', `${-headerOffset}px`)
705
+ document.documentElement.style.setProperty('--group-header-scale', String(headerScale))
706
+ }, [])
707
+
708
+ // Use ReactFlow's viewport change hook instead of polling
709
+ useOnViewportChange({
710
+ onChange: updateZoomOffset,
711
+ })
712
+
713
+ // Update on initial mount
714
+ useEffect(() => {
715
+ updateZoomOffset(getViewport())
716
+ }, [updateZoomOffset, getViewport])
717
+
718
+ // Fit view when nodes become available or structure changes
719
+ // This handles both initial mount and view switching scenarios
720
+ useEffect(() => {
721
+ const structureChanged = structureKey !== prevStructureKeyRef.current
722
+ const nodesJustPopulated = prevNodesLengthRef.current === 0 && nodes.length > 0
723
+
724
+ // Update refs
725
+ prevNodesLengthRef.current = nodes.length
726
+ if (structureChanged) {
727
+ prevStructureKeyRef.current = structureKey
728
+ }
729
+
730
+ // Fit view when:
731
+ // 1. Nodes just became available (were 0, now > 0) - handles initial mount/view switch
732
+ // 2. Structure changed AND nodes already exist - handles topology changes
733
+ if (nodesJustPopulated || (structureChanged && nodes.length > 0)) {
734
+ // Small delay to ensure DOM is updated
735
+ const timeoutId = setTimeout(() => {
736
+ fitView({
737
+ padding: 0.15,
738
+ // No animation when nodes first appear, animate on subsequent structure changes
739
+ duration: nodesJustPopulated ? 0 : VIEWPORT_ANIMATION_DURATION,
740
+ })
741
+ }, 10)
742
+
743
+ return () => clearTimeout(timeoutId)
744
+ }
745
+ }, [structureKey, nodes.length, fitView])
746
+
747
+ return null
748
+ }