@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,734 @@
1
+ import type { Node } from '@xyflow/react'
2
+ import type { TopologyNode, GroupingMode, NodeKind } from '../../types'
3
+ import { NODE_DIMENSIONS } from './K8sResourceNode'
4
+
5
+ // Group padding - space for header + internal spacing (must account for border)
6
+ // Top padding accommodates the header at its largest (when zoomed out)
7
+ // Child nodes are translated up dynamically when zoomed in (see TopologyGraph)
8
+ const GROUP_PADDING = {
9
+ top: 100, // Space for group header at max scale
10
+ left: 30,
11
+ bottom: 36,
12
+ right: 30,
13
+ }
14
+
15
+ // Reduced padding when header is hidden (single namespace view)
16
+ const GROUP_PADDING_NO_HEADER = {
17
+ top: 30,
18
+ left: 30,
19
+ bottom: 30,
20
+ right: 30,
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Layout engine abstraction
25
+ //
26
+ // Two execution strategies for ELK layout:
27
+ // 'worker' — Web Worker (Vite/Radar). Keeps the main thread free.
28
+ // 'main-thread' — Inline ELK (webpack/Next.js). Workers can't load the
29
+ // layout.worker.ts file under webpack, so we run ELK
30
+ // directly using elkjs/lib/elk.bundled.js.
31
+ //
32
+ // The active strategy is set once via `setLayoutEngine()` (exported).
33
+ // Defaults to 'worker' — consumers on webpack call setLayoutEngine('main-thread').
34
+ // ---------------------------------------------------------------------------
35
+
36
+ type LayoutEngine = 'worker' | 'main-thread'
37
+ let layoutEngine: LayoutEngine = 'worker'
38
+
39
+ /** Set the ELK layout execution strategy. Call once at app init. */
40
+ export function setLayoutEngine(engine: LayoutEngine) {
41
+ layoutEngine = engine
42
+ }
43
+
44
+ interface LayoutResult {
45
+ groupLayouts: Array<{
46
+ groupId: string
47
+ groupKey: string
48
+ width: number
49
+ height: number
50
+ children: Array<{ id: string; x: number; y: number }>
51
+ isCollapsed: boolean
52
+ }>
53
+ ungroupedNodes: Array<{
54
+ id: string
55
+ width: number
56
+ height: number
57
+ }>
58
+ groupPositions: Array<[string, { x: number; y: number }]>
59
+ error?: string
60
+ }
61
+
62
+ // ELK algorithm options (shared between worker and main-thread engines)
63
+ const elkOptionsGroup = {
64
+ 'elk.algorithm': 'layered',
65
+ 'elk.direction': 'RIGHT',
66
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
67
+ 'elk.spacing.nodeNode': '40',
68
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '85',
69
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '25',
70
+ 'elk.edgeRouting': 'ORTHOGONAL',
71
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
72
+ }
73
+
74
+ const elkOptionsGroupLayout = {
75
+ 'elk.algorithm': 'layered',
76
+ 'elk.direction': 'RIGHT',
77
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
78
+ 'elk.spacing.nodeNode': '80',
79
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '120',
80
+ 'elk.edgeRouting': 'ORTHOGONAL',
81
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Worker engine
86
+ // ---------------------------------------------------------------------------
87
+ let layoutWorker: Worker | null = null
88
+ let requestIdCounter = 0
89
+ const pendingRequests = new Map<number, {
90
+ resolve: (result: LayoutResult) => void
91
+ reject: (error: Error) => void
92
+ }>()
93
+
94
+ function getOrCreateWorker(): Worker {
95
+ if (!layoutWorker) {
96
+ layoutWorker = new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' })
97
+ layoutWorker.onmessage = (e: MessageEvent) => {
98
+ const { requestId, ...result } = e.data
99
+ const pending = pendingRequests.get(requestId)
100
+ if (pending) {
101
+ pendingRequests.delete(requestId)
102
+ if (result.error) {
103
+ pending.reject(new Error(result.error))
104
+ } else {
105
+ pending.resolve(result as LayoutResult)
106
+ }
107
+ }
108
+ }
109
+ layoutWorker.onerror = (e) => {
110
+ console.error('[TopologyLayout] Worker error:', e)
111
+ for (const [, pending] of pendingRequests) {
112
+ pending.reject(new Error('Worker error'))
113
+ }
114
+ pendingRequests.clear()
115
+ }
116
+ }
117
+ return layoutWorker
118
+ }
119
+
120
+ function runLayoutViaWorker(
121
+ elkGraph: ElkGraph,
122
+ groupingMode: GroupingMode,
123
+ hideGroupHeader: boolean,
124
+ padding: typeof GROUP_PADDING
125
+ ): Promise<LayoutResult> {
126
+ return new Promise((resolve, reject) => {
127
+ const worker = getOrCreateWorker()
128
+ const requestId = ++requestIdCounter
129
+ pendingRequests.set(requestId, { resolve, reject })
130
+ worker.postMessage({ type: 'layout', requestId, elkGraph, groupingMode, hideGroupHeader, padding })
131
+ })
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Main-thread engine — same two-phase ELK layout as layout.worker.ts
136
+ // but runs inline using elkjs/lib/elk.bundled.js (no web-worker dependency).
137
+ // ---------------------------------------------------------------------------
138
+ async function runLayoutOnMainThread(
139
+ elkGraph: ElkGraph,
140
+ groupingMode: GroupingMode,
141
+ hideGroupHeader: boolean,
142
+ padding: typeof GROUP_PADDING
143
+ ): Promise<LayoutResult> {
144
+ const ELK = (await import('elkjs/lib/elk.bundled.js')).default
145
+ const elk = new ELK()
146
+
147
+ const groupLayouts: LayoutResult['groupLayouts'] = []
148
+ const ungroupedNodes: LayoutResult['ungroupedNodes'] = []
149
+ const groupNodeIds = new Map<string, Set<string>>()
150
+
151
+ // Phase 1: Layout each group independently
152
+ for (const child of elkGraph.children) {
153
+ const isGroup = child.id.startsWith('group-')
154
+
155
+ if (isGroup && child.children && child.children.length > 0) {
156
+ const groupKey = child.id.replace(`group-${groupingMode}-`, '')
157
+ const minWidth = hideGroupHeader ? 300 : Math.max(500, groupKey.length * 16 + 200)
158
+ const nodeIds = new Set(child.children.map(c => c.id))
159
+ groupNodeIds.set(child.id, nodeIds)
160
+
161
+ const intraGroupEdges = elkGraph.edges.filter(e =>
162
+ nodeIds.has(e.sources[0]) && nodeIds.has(e.targets[0])
163
+ )
164
+
165
+ const layoutResult = await elk.layout({
166
+ id: child.id,
167
+ layoutOptions: {
168
+ ...elkOptionsGroup,
169
+ 'elk.padding': `[left=${padding.left}, top=${padding.top}, right=${padding.right}, bottom=${padding.bottom}]`,
170
+ },
171
+ children: child.children,
172
+ edges: intraGroupEdges,
173
+ }) as any
174
+
175
+ groupLayouts.push({
176
+ groupId: child.id,
177
+ groupKey,
178
+ width: hideGroupHeader
179
+ ? (layoutResult.width || 300)
180
+ : Math.max(layoutResult.width || 300, minWidth),
181
+ height: layoutResult.height || 200,
182
+ children: (layoutResult.children || []).map((c: any) => ({
183
+ id: c.id, x: c.x || 0, y: c.y || 0,
184
+ })),
185
+ isCollapsed: false,
186
+ })
187
+ } else if (isGroup) {
188
+ const groupKey = child.id.replace(`group-${groupingMode}-`, '')
189
+ const minWidth = Math.max(400, groupKey.length * 16 + 180)
190
+ groupLayouts.push({
191
+ groupId: child.id, groupKey,
192
+ width: Math.max(child.width || 280, minWidth),
193
+ height: child.height || 90,
194
+ children: [], isCollapsed: true,
195
+ })
196
+ } else {
197
+ ungroupedNodes.push({ id: child.id, width: child.width || 200, height: child.height || 56 })
198
+ }
199
+ }
200
+
201
+ // Phase 2: Build meta-graph and position groups based on inter-group edges
202
+ const nodeToGroup = new Map<string, string>()
203
+ for (const [groupId, nodeIds] of groupNodeIds) {
204
+ for (const nodeId of nodeIds) nodeToGroup.set(nodeId, groupId)
205
+ }
206
+
207
+ const interGroupEdges: ElkEdge[] = []
208
+ const seen = new Set<string>()
209
+ for (const edge of elkGraph.edges) {
210
+ const sg = nodeToGroup.get(edge.sources[0])
211
+ const tg = nodeToGroup.get(edge.targets[0])
212
+ if (sg && tg && sg !== tg) {
213
+ const key = `${sg}->${tg}`
214
+ if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [sg], targets: [tg] }) }
215
+ } else if ((!sg && tg) || (sg && !tg)) {
216
+ const s = sg || edge.sources[0], t = tg || edge.targets[0], key = `${s}->${t}`
217
+ if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [s], targets: [t] }) }
218
+ }
219
+ }
220
+
221
+ const metaResult = await elk.layout({
222
+ id: 'meta-root',
223
+ layoutOptions: elkOptionsGroupLayout,
224
+ children: [
225
+ ...groupLayouts.map(g => ({ id: g.groupId, width: g.width, height: g.height })),
226
+ ...ungroupedNodes.map(n => ({ id: n.id, width: n.width, height: n.height })),
227
+ ],
228
+ edges: interGroupEdges,
229
+ }) as any
230
+
231
+ const groupPositions: Array<[string, { x: number; y: number }]> =
232
+ (metaResult.children || []).map((c: any) => [c.id, { x: c.x || 0, y: c.y || 0 }])
233
+
234
+ return { groupLayouts, ungroupedNodes, groupPositions }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Dispatcher — routes to the active engine
239
+ // ---------------------------------------------------------------------------
240
+ function runLayout(
241
+ elkGraph: ElkGraph,
242
+ groupingMode: GroupingMode,
243
+ hideGroupHeader: boolean,
244
+ padding: typeof GROUP_PADDING
245
+ ): Promise<LayoutResult> {
246
+ if (layoutEngine === 'main-thread') {
247
+ return runLayoutOnMainThread(elkGraph, groupingMode, hideGroupHeader, padding)
248
+ }
249
+ return runLayoutViaWorker(elkGraph, groupingMode, hideGroupHeader, padding)
250
+ }
251
+
252
+ interface ElkNode {
253
+ id: string
254
+ width?: number
255
+ height?: number
256
+ children?: ElkNode[]
257
+ layoutOptions?: Record<string, string>
258
+ labels?: Array<{ text: string }>
259
+ }
260
+
261
+ interface ElkEdge {
262
+ id: string
263
+ sources: string[]
264
+ targets: string[]
265
+ }
266
+
267
+ interface ElkGraph {
268
+ id: string
269
+ layoutOptions: Record<string, string>
270
+ children: ElkNode[]
271
+ edges: ElkEdge[]
272
+ }
273
+
274
+ // Get app label from a node (if it has one)
275
+ function getAppLabel(node: TopologyNode): string | null {
276
+ const labels = (node.data.labels as Record<string, string>) || {}
277
+ return labels['app.kubernetes.io/name'] || labels['app'] || labels['app.kubernetes.io/instance'] || null
278
+ }
279
+
280
+ // Get group key for a node based on grouping mode
281
+ export function getGroupKey(node: TopologyNode, groupingMode: GroupingMode): string | null {
282
+ if (groupingMode === 'none') return null
283
+
284
+ if (groupingMode === 'namespace') {
285
+ return (node.data.namespace as string) || null
286
+ }
287
+
288
+ if (groupingMode === 'app') {
289
+ return getAppLabel(node)
290
+ }
291
+
292
+ return null
293
+ }
294
+
295
+ // Propagate app labels through connected resources and create groups for unlabeled connected components
296
+ // Returns a map of nodeId -> groupName for all nodes that should be grouped
297
+ function propagateAppLabels(
298
+ nodes: TopologyNode[],
299
+ edges: Array<{ id: string; source: string; target: string; type: string }>
300
+ ): Map<string, string> {
301
+ const nodeMap = new Map<string, TopologyNode>()
302
+ for (const node of nodes) {
303
+ nodeMap.set(node.id, node)
304
+ }
305
+
306
+ // Build adjacency list (bidirectional for propagation) - only within same namespace
307
+ const connections = new Map<string, Set<string>>()
308
+ for (const node of nodes) {
309
+ connections.set(node.id, new Set())
310
+ }
311
+ for (const edge of edges) {
312
+ const sourceNode = nodeMap.get(edge.source)
313
+ const targetNode = nodeMap.get(edge.target)
314
+ // Only connect nodes in the same namespace
315
+ if (sourceNode && targetNode && sourceNode.data.namespace === targetNode.data.namespace) {
316
+ connections.get(edge.source)?.add(edge.target)
317
+ connections.get(edge.target)?.add(edge.source)
318
+ }
319
+ }
320
+
321
+ // Initial pass: find nodes with explicit app labels
322
+ const nodeGroupLabels = new Map<string, string>()
323
+ for (const node of nodes) {
324
+ const appLabel = getAppLabel(node)
325
+ if (appLabel) {
326
+ nodeGroupLabels.set(node.id, appLabel)
327
+ }
328
+ }
329
+
330
+ // Propagate labels through connections (BFS from labeled nodes)
331
+ let changed = true
332
+ const maxIterations = 10
333
+ let iteration = 0
334
+
335
+ while (changed && iteration < maxIterations) {
336
+ changed = false
337
+ iteration++
338
+
339
+ for (const node of nodes) {
340
+ if (nodeGroupLabels.has(node.id)) continue
341
+
342
+ const connectedNodes = connections.get(node.id) || new Set()
343
+ const connectedLabels = new Set<string>()
344
+
345
+ for (const connectedId of connectedNodes) {
346
+ const connectedLabel = nodeGroupLabels.get(connectedId)
347
+ if (connectedLabel) {
348
+ connectedLabels.add(connectedLabel)
349
+ }
350
+ }
351
+
352
+ // If exactly one connected label, inherit it
353
+ if (connectedLabels.size === 1) {
354
+ const [inheritedLabel] = connectedLabels
355
+ nodeGroupLabels.set(node.id, inheritedLabel)
356
+ changed = true
357
+ }
358
+ }
359
+ }
360
+
361
+ // Find connected components among remaining unlabeled nodes
362
+ const unlabeledNodes = nodes.filter(n => !nodeGroupLabels.has(n.id))
363
+ const visited = new Set<string>()
364
+
365
+ for (const startNode of unlabeledNodes) {
366
+ if (visited.has(startNode.id)) continue
367
+
368
+ // BFS to find connected component
369
+ const component: TopologyNode[] = []
370
+ const queue = [startNode.id]
371
+ visited.add(startNode.id)
372
+
373
+ while (queue.length > 0) {
374
+ const nodeId = queue.shift()!
375
+ const node = nodeMap.get(nodeId)
376
+ if (node && !nodeGroupLabels.has(nodeId)) {
377
+ component.push(node)
378
+ }
379
+
380
+ for (const connectedId of connections.get(nodeId) || []) {
381
+ if (!visited.has(connectedId) && !nodeGroupLabels.has(connectedId)) {
382
+ visited.add(connectedId)
383
+ queue.push(connectedId)
384
+ }
385
+ }
386
+ }
387
+
388
+ // Create a group for this connected component (only if more than 1 node)
389
+ // Singletons remain ungrouped
390
+ if (component.length > 1) {
391
+ // Name the group after the most "important" node (prefer Deployment, Service, etc.)
392
+ const groupName = pickGroupName(component)
393
+ for (const node of component) {
394
+ nodeGroupLabels.set(node.id, groupName)
395
+ }
396
+ }
397
+ }
398
+
399
+ return nodeGroupLabels
400
+ }
401
+
402
+ // Pick a representative name for a connected component group
403
+ function pickGroupName(nodes: TopologyNode[]): string {
404
+ // Priority order for picking the group name
405
+ const kindPriority: Record<string, number> = {
406
+ 'Deployment': 1,
407
+ 'Rollout': 1,
408
+ 'StatefulSet': 2,
409
+ 'DaemonSet': 3,
410
+ 'CronJob': 4,
411
+ 'Job': 5,
412
+ 'Service': 6,
413
+ 'Gateway': 7,
414
+ 'HTTPRoute': 6,
415
+ 'GRPCRoute': 6,
416
+ 'TCPRoute': 6,
417
+ 'TLSRoute': 6,
418
+ 'Ingress': 7,
419
+ 'ReplicaSet': 8,
420
+ 'Pod': 9,
421
+ 'PodGroup': 9,
422
+ 'ConfigMap': 10,
423
+ 'Secret': 10,
424
+ 'PersistentVolumeClaim': 10,
425
+ 'HorizontalPodAutoscaler': 10,
426
+ 'KnativeService': 1,
427
+ 'KnativeConfiguration': 3,
428
+ 'KnativeRevision': 4,
429
+ 'KnativeRoute': 2,
430
+ 'Broker': 2,
431
+ 'Channel': 2,
432
+ 'Trigger': 3,
433
+ 'PingSource': 3,
434
+ 'ApiServerSource': 3,
435
+ 'ContainerSource': 3,
436
+ 'SinkBinding': 3,
437
+ 'IngressRoute': 1,
438
+ 'IngressRouteTCP': 1,
439
+ 'IngressRouteUDP': 1,
440
+ 'TraefikService': 2,
441
+ 'Middleware': 3,
442
+ 'MiddlewareTCP': 3,
443
+ 'ServersTransport': 4,
444
+ 'ServersTransportTCP': 4,
445
+ 'TLSOption': 4,
446
+ 'TLSStore': 4,
447
+ }
448
+
449
+ // Sort by priority and pick the first
450
+ const sorted = [...nodes].sort((a, b) => {
451
+ const priorityA = kindPriority[a.kind] || 99
452
+ const priorityB = kindPriority[b.kind] || 99
453
+ return priorityA - priorityB
454
+ })
455
+
456
+ return sorted[0].name
457
+ }
458
+
459
+ // Build hierarchical ELK graph with groups containing children
460
+ export function buildHierarchicalElkGraph(
461
+ topologyNodes: TopologyNode[],
462
+ edges: Array<{ id: string; source: string; target: string; type: string }>,
463
+ groupingMode: GroupingMode,
464
+ collapsedGroups: Set<string>
465
+ ): { elkGraph: ElkGraph; groupMap: Map<string, string[]>; nodeToGroup: Map<string, string> } {
466
+ const groupMap = new Map<string, string[]>()
467
+ const nodeToGroup = new Map<string, string>()
468
+
469
+ // For app grouping, propagate labels through connected resources
470
+ const propagatedAppLabels = groupingMode === 'app'
471
+ ? propagateAppLabels(topologyNodes, edges)
472
+ : null
473
+
474
+ // Group nodes by their group key
475
+ for (const node of topologyNodes) {
476
+ let groupKey: string | null = null
477
+
478
+ if (groupingMode === 'namespace') {
479
+ groupKey = (node.data.namespace as string) || null
480
+ } else if (groupingMode === 'app') {
481
+ // Use propagated label if available, otherwise direct label
482
+ groupKey = propagatedAppLabels?.get(node.id) || getAppLabel(node)
483
+ }
484
+
485
+ if (groupKey) {
486
+ const groupId = `group-${groupingMode}-${groupKey}`
487
+ if (!groupMap.has(groupKey)) {
488
+ groupMap.set(groupKey, [])
489
+ }
490
+ groupMap.get(groupKey)!.push(node.id)
491
+ nodeToGroup.set(node.id, groupId)
492
+ }
493
+ }
494
+
495
+ const children: ElkNode[] = []
496
+ const processedNodes = new Set<string>()
497
+
498
+ if (groupingMode === 'none') {
499
+ // No grouping - all nodes as direct children of root
500
+ for (const node of topologyNodes) {
501
+ const kind = node.kind as NodeKind
502
+ const dims = NODE_DIMENSIONS[kind] || { width: 200, height: 56 }
503
+ children.push({
504
+ id: node.id,
505
+ width: dims.width,
506
+ height: dims.height,
507
+ })
508
+ }
509
+ } else {
510
+ // Create group nodes with children
511
+ for (const [groupKey, memberIds] of groupMap) {
512
+ const groupId = `group-${groupingMode}-${groupKey}`
513
+ const isCollapsed = collapsedGroups.has(groupId)
514
+
515
+ if (isCollapsed) {
516
+ // Collapsed group is a single node - width based on label length
517
+ const collapsedWidth = Math.max(400, groupKey.length * 16 + 180)
518
+ children.push({
519
+ id: groupId,
520
+ width: collapsedWidth,
521
+ height: 90,
522
+ labels: [{ text: groupKey }],
523
+ })
524
+ } else {
525
+ // Expanded group contains its children
526
+ const groupChildren: ElkNode[] = []
527
+ for (const nodeId of memberIds) {
528
+ const node = topologyNodes.find(n => n.id === nodeId)
529
+ if (node) {
530
+ const kind = node.kind as NodeKind
531
+ const dims = NODE_DIMENSIONS[kind] || { width: 200, height: 56 }
532
+ groupChildren.push({
533
+ id: nodeId,
534
+ width: dims.width,
535
+ height: dims.height,
536
+ })
537
+ processedNodes.add(nodeId)
538
+ }
539
+ }
540
+
541
+ // Calculate minimum width based on label length (approx 14px per char for text-4xl + padding)
542
+ const minWidth = Math.max(500, groupKey.length * 16 + 200)
543
+
544
+ children.push({
545
+ id: groupId,
546
+ children: groupChildren,
547
+ layoutOptions: {
548
+ 'elk.padding': `[left=${GROUP_PADDING.left}, top=${GROUP_PADDING.top}, right=${GROUP_PADDING.right}, bottom=${GROUP_PADDING.bottom}]`,
549
+ 'elk.algorithm': 'layered',
550
+ 'elk.direction': 'RIGHT',
551
+ 'elk.spacing.nodeNode': '40',
552
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '85',
553
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '25',
554
+ 'elk.nodeSize.minimum': `(${minWidth}, 100)`,
555
+ },
556
+ labels: [{ text: groupKey }],
557
+ })
558
+ }
559
+
560
+ // Mark all members as processed
561
+ for (const nodeId of memberIds) {
562
+ processedNodes.add(nodeId)
563
+ }
564
+ }
565
+
566
+ // Add ungrouped nodes as direct children
567
+ for (const node of topologyNodes) {
568
+ if (!processedNodes.has(node.id)) {
569
+ const kind = node.kind as NodeKind
570
+ const dims = NODE_DIMENSIONS[kind] || { width: 200, height: 56 }
571
+ children.push({
572
+ id: node.id,
573
+ width: dims.width,
574
+ height: dims.height,
575
+ })
576
+ }
577
+ }
578
+ }
579
+
580
+ // Build edges, redirecting to groups when collapsed
581
+ const elkEdges: ElkEdge[] = []
582
+ const seenEdges = new Set<string>()
583
+
584
+ for (const edge of edges) {
585
+ let source = edge.source
586
+ let target = edge.target
587
+
588
+ // Redirect edges to collapsed groups
589
+ const sourceGroup = nodeToGroup.get(source)
590
+ if (sourceGroup && collapsedGroups.has(sourceGroup)) {
591
+ source = sourceGroup
592
+ }
593
+
594
+ const targetGroup = nodeToGroup.get(target)
595
+ if (targetGroup && collapsedGroups.has(targetGroup)) {
596
+ target = targetGroup
597
+ }
598
+
599
+ // Skip self-loops
600
+ if (source === target) continue
601
+
602
+ // Skip duplicates
603
+ const edgeKey = `${source}->${target}`
604
+ if (seenEdges.has(edgeKey)) continue
605
+ seenEdges.add(edgeKey)
606
+
607
+ elkEdges.push({
608
+ id: edge.id,
609
+ sources: [source],
610
+ targets: [target],
611
+ })
612
+ }
613
+
614
+ return {
615
+ elkGraph: {
616
+ id: 'root',
617
+ layoutOptions: {}, // Root layout options not used - we manually arrange groups
618
+ children,
619
+ edges: elkEdges,
620
+ },
621
+ groupMap,
622
+ nodeToGroup,
623
+ }
624
+ }
625
+
626
+ // Two-phase layout: first layout groups internally, then position groups based on connections
627
+ // Layout is performed in a Web Worker to avoid blocking the main thread
628
+ export async function applyHierarchicalLayout(
629
+ elkGraph: ElkGraph,
630
+ topologyNodes: TopologyNode[],
631
+ groupMap: Map<string, string[]>,
632
+ groupingMode: GroupingMode,
633
+ _collapsedGroups: Set<string>,
634
+ onToggleCollapse: (groupId: string) => void,
635
+ hideGroupHeader: boolean = false
636
+ ): Promise<{ nodes: Node[]; positions: Map<string, { x: number; y: number }>; error?: string }> {
637
+ try {
638
+ const padding = hideGroupHeader ? GROUP_PADDING_NO_HEADER : GROUP_PADDING
639
+
640
+ // Run layout in worker (off main thread)
641
+ const workerResult = await runLayout(elkGraph, groupingMode, hideGroupHeader, padding)
642
+
643
+ if (workerResult.error) {
644
+ return { nodes: [], positions: new Map(), error: workerResult.error }
645
+ }
646
+
647
+ // Build position map from worker result
648
+ const groupPositions = new Map<string, { x: number; y: number }>(workerResult.groupPositions)
649
+
650
+ // Build ReactFlow nodes using positions from worker
651
+ const nodes: Node[] = []
652
+ const positions = new Map<string, { x: number; y: number }>()
653
+
654
+ for (const group of workerResult.groupLayouts) {
655
+ const pos = groupPositions.get(group.groupId) || { x: 0, y: 0 }
656
+ const memberIds = groupMap.get(group.groupKey) || []
657
+
658
+ positions.set(group.groupId, pos)
659
+
660
+ // Add group node
661
+ nodes.push({
662
+ id: group.groupId,
663
+ type: 'group',
664
+ position: pos,
665
+ data: {
666
+ type: groupingMode,
667
+ name: group.groupKey,
668
+ nodeCount: memberIds.length,
669
+ collapsed: group.isCollapsed,
670
+ onToggleCollapse,
671
+ hideHeader: hideGroupHeader,
672
+ },
673
+ style: {
674
+ width: group.width,
675
+ height: group.height,
676
+ },
677
+ zIndex: -1,
678
+ })
679
+
680
+ // Add child nodes with positions relative to group
681
+ for (const child of group.children) {
682
+ const topoNode = topologyNodes.find(n => n.id === child.id)
683
+ if (topoNode) {
684
+ const absX = pos.x + child.x
685
+ const absY = pos.y + child.y
686
+ positions.set(child.id, { x: absX, y: absY })
687
+
688
+ nodes.push({
689
+ id: child.id,
690
+ type: 'k8sResource',
691
+ position: { x: child.x, y: child.y },
692
+ parentId: group.groupId,
693
+ extent: 'parent',
694
+ data: {
695
+ kind: topoNode.kind,
696
+ name: topoNode.name,
697
+ status: topoNode.status,
698
+ nodeData: topoNode.data,
699
+ selected: false,
700
+ },
701
+ })
702
+ }
703
+ }
704
+ }
705
+
706
+ // Add ungrouped nodes
707
+ for (const node of workerResult.ungroupedNodes) {
708
+ const pos = groupPositions.get(node.id) || { x: 0, y: 0 }
709
+ const topoNode = topologyNodes.find(n => n.id === node.id)
710
+ if (topoNode) {
711
+ positions.set(node.id, pos)
712
+
713
+ nodes.push({
714
+ id: node.id,
715
+ type: 'k8sResource',
716
+ position: pos,
717
+ data: {
718
+ kind: topoNode.kind,
719
+ name: topoNode.name,
720
+ status: topoNode.status,
721
+ nodeData: topoNode.data,
722
+ selected: false,
723
+ },
724
+ })
725
+ }
726
+ }
727
+
728
+ return { nodes, positions }
729
+ } catch (err) {
730
+ const errorMessage = err instanceof Error ? err.message : String(err)
731
+ console.error('ELK hierarchical layout error:', err)
732
+ return { nodes: [], positions: new Map(), error: `Layout failed: ${errorMessage}` }
733
+ }
734
+ }