@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.
- package/package.json +63 -0
- package/src/components/dock/BottomDock.tsx +204 -0
- package/src/components/dock/DockContext.tsx +212 -0
- package/src/components/dock/TerminalTab.tsx +333 -0
- package/src/components/dock/index.ts +3 -0
- package/src/components/gitops/GitOpsActions.tsx +227 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
- package/src/components/gitops/ManagedResourcesList.tsx +210 -0
- package/src/components/gitops/SyncCountdown.tsx +146 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/logs/JsonLogLine.tsx +90 -0
- package/src/components/logs/LogCore.tsx +553 -0
- package/src/components/logs/LogToolbarSelects.tsx +74 -0
- package/src/components/logs/LogsViewer.tsx +146 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
- package/src/components/logs/index.ts +13 -0
- package/src/components/logs/useLogBuffer.ts +138 -0
- package/src/components/logs/useLogSearch.ts +151 -0
- package/src/components/logs/useLogStream.ts +61 -0
- package/src/components/resources/ResourcesView.tsx +5494 -0
- package/src/components/resources/index.ts +17 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
- package/src/components/resources/renderers/EventRenderer.tsx +175 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
- package/src/components/resources/renderers/HPARenderer.tsx +164 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
- package/src/components/resources/renderers/JobRenderer.tsx +103 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
- package/src/components/resources/renderers/PodRenderer.tsx +677 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
- package/src/components/resources/renderers/VPARenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
- package/src/components/resources/renderers/argo-cells.tsx +118 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
- package/src/components/resources/renderers/eso-cells.tsx +115 -0
- package/src/components/resources/renderers/flux-cells.tsx +253 -0
- package/src/components/resources/renderers/index.ts +105 -0
- package/src/components/resources/renderers/istio-cells.tsx +210 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
- package/src/components/resources/renderers/keda-cells.tsx +94 -0
- package/src/components/resources/renderers/knative-cells.tsx +396 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
- package/src/components/resources/renderers/traefik-cells.tsx +120 -0
- package/src/components/resources/renderers/trivy-cells.tsx +153 -0
- package/src/components/resources/renderers/trivy-shared.tsx +67 -0
- package/src/components/resources/renderers/velero-cells.tsx +164 -0
- package/src/components/resources/resource-utils-argo.ts +155 -0
- package/src/components/resources/resource-utils-certmanager.ts +198 -0
- package/src/components/resources/resource-utils-cnpg.ts +344 -0
- package/src/components/resources/resource-utils-eso.ts +385 -0
- package/src/components/resources/resource-utils-flux.ts +186 -0
- package/src/components/resources/resource-utils-istio.ts +372 -0
- package/src/components/resources/resource-utils-karpenter.ts +150 -0
- package/src/components/resources/resource-utils-keda.ts +203 -0
- package/src/components/resources/resource-utils-knative.ts +218 -0
- package/src/components/resources/resource-utils-kyverno.ts +224 -0
- package/src/components/resources/resource-utils-prometheus.ts +159 -0
- package/src/components/resources/resource-utils-traefik.ts +180 -0
- package/src/components/resources/resource-utils-trivy.ts +146 -0
- package/src/components/resources/resource-utils-velero.ts +293 -0
- package/src/components/resources/resource-utils.ts +1558 -0
- package/src/components/shared/EditableYamlView.tsx +302 -0
- package/src/components/shared/ResourceActionsBar.tsx +924 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
- package/src/components/shared/index.ts +3 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/shared.tsx +614 -0
- package/src/components/topology/GroupNode.tsx +198 -0
- package/src/components/topology/K8sResourceNode.tsx +440 -0
- package/src/components/topology/TopologyControls.tsx +62 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
- package/src/components/topology/TopologyGraph.tsx +748 -0
- package/src/components/topology/TopologySearch.tsx +304 -0
- package/src/components/topology/index.ts +7 -0
- package/src/components/topology/layout.ts +734 -0
- package/src/components/topology/layout.worker.ts +272 -0
- package/src/components/topology/topology.css +129 -0
- package/src/components/ui/CodeViewer.tsx +451 -0
- package/src/components/ui/ConfirmDialog.tsx +188 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
- package/src/components/ui/HealthRing.tsx +72 -0
- package/src/components/ui/MetricsChart.tsx +259 -0
- package/src/components/ui/ResourceBar.tsx +68 -0
- package/src/components/ui/Toast.tsx +225 -0
- package/src/components/ui/Tooltip.tsx +165 -0
- package/src/components/ui/YamlEditor.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +792 -0
- package/src/components/ui/index.ts +10 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
- package/src/components/workload/WorkloadView.tsx +1109 -0
- package/src/components/workload/index.ts +2 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAnimatedUnmount.ts +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +336 -0
- package/src/hooks/useRefreshAnimation.ts +58 -0
- package/src/index.ts +35 -0
- package/src/types/core.ts +894 -0
- package/src/types/gitops.ts +458 -0
- package/src/types/index.ts +2 -0
- package/src/utils/animation.ts +40 -0
- package/src/utils/api-resources.ts +185 -0
- package/src/utils/badge-colors.ts +399 -0
- package/src/utils/context-name.ts +72 -0
- package/src/utils/download.ts +19 -0
- package/src/utils/format.ts +233 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/log-format.ts +300 -0
- package/src/utils/navigation.ts +119 -0
- package/src/utils/resource-hierarchy.ts +630 -0
- package/src/utils/resource-icons.ts +206 -0
- package/src/vite-env.d.ts +1 -0
- 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
|
+
}
|