@skyhook-io/k8s-ui 1.2.4 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/components/dock/BottomDock.tsx +24 -6
- package/src/components/dock/DockContext.tsx +9 -1
- package/src/components/dock/LocalTerminalTab.tsx +1 -1
- package/src/components/dock/NodeTerminalTab.tsx +1 -1
- package/src/components/dock/TerminalTab.tsx +2 -2
- package/src/components/gitops/GitOpsActions.tsx +12 -11
- package/src/components/gitops/GitOpsStatusBadge.tsx +6 -6
- package/src/components/logs/LogCore.tsx +8 -8
- package/src/components/logs/LogsViewer.tsx +8 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +37 -9
- package/src/components/logs/useLogStream.ts +20 -0
- package/src/components/resources/ResourcesSidebar.tsx +10 -10
- package/src/components/resources/ResourcesView.tsx +49 -48
- package/src/components/resources/renderers/AlertRenderer.tsx +2 -2
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -3
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -2
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +1 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +3 -3
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/HPARenderer.tsx +1 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +10 -10
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +2 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -2
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +6 -6
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -5
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +5 -5
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +4 -4
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -3
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -3
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +5 -5
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +5 -5
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +4 -4
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +8 -8
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +3 -3
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +6 -6
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -4
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -6
- package/src/components/resources/renderers/NodeRenderer.tsx +5 -5
- package/src/components/resources/renderers/OrderRenderer.tsx +3 -3
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodRenderer.tsx +10 -10
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -3
- package/src/components/resources/renderers/RoleRenderer.tsx +8 -8
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +5 -5
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +6 -6
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +5 -5
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +6 -6
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +10 -10
- package/src/components/resources/renderers/WorkflowRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
- package/src/components/resources/renderers/argo-cells.tsx +3 -3
- package/src/components/resources/renderers/certmanager-cells.tsx +6 -6
- package/src/components/resources/renderers/cnpg-cells.tsx +5 -5
- package/src/components/resources/renderers/eso-cells.tsx +4 -4
- package/src/components/resources/renderers/flux-cells.tsx +6 -6
- package/src/components/resources/renderers/istio-cells.tsx +9 -9
- package/src/components/resources/renderers/karpenter-cells.tsx +3 -3
- package/src/components/resources/renderers/keda-cells.tsx +2 -2
- package/src/components/resources/renderers/knative-cells.tsx +2 -2
- package/src/components/resources/renderers/kyverno-cells.tsx +3 -3
- package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
- package/src/components/resources/renderers/trivy-cells.tsx +4 -4
- package/src/components/resources/renderers/velero-cells.tsx +4 -4
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +17 -17
- package/src/components/shared/ResourceRendererDispatch.tsx +8 -7
- package/src/components/timeline/DiffViewer.tsx +131 -0
- package/src/components/timeline/TimelineList.tsx +817 -0
- package/src/components/timeline/index.ts +2 -0
- package/src/components/topology/GroupNode.tsx +189 -79
- package/src/components/topology/K8sResourceNode.tsx +5 -5
- package/src/components/topology/TopologyControls.tsx +2 -2
- package/src/components/topology/TopologyGraph.tsx +362 -62
- package/src/components/topology/layout.ts +288 -92
- package/src/components/topology/topology.css +112 -1
- package/src/components/ui/Badge.tsx +178 -0
- package/src/components/ui/HealthRing.tsx +70 -35
- package/src/components/ui/MetricsChart.tsx +2 -2
- package/src/components/ui/Toast.tsx +74 -27
- package/src/components/ui/drawer-components.tsx +12 -31
- package/src/components/workload/ResourceDetailDrawer.tsx +4 -4
- package/src/components/workload/WorkloadView.tsx +10 -10
- package/src/theme/components.css +195 -0
- package/src/theme/tailwind-theme.css +13 -13
- package/src/theme/variables.css +46 -29
- package/src/types/core.ts +41 -0
- package/src/utils/badge-colors.ts +54 -205
- package/src/utils/download.ts +18 -3
- package/src/utils/log-format.ts +0 -8
- package/tsconfig.json +0 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type Node,
|
|
13
13
|
type Edge,
|
|
14
14
|
type NodeTypes,
|
|
15
|
+
type NodeChange,
|
|
15
16
|
type Viewport,
|
|
16
17
|
BackgroundVariant,
|
|
17
18
|
MarkerType,
|
|
@@ -19,13 +20,14 @@ import {
|
|
|
19
20
|
import '@xyflow/react/dist/style.css'
|
|
20
21
|
import { toCanvas } from 'html-to-image'
|
|
21
22
|
|
|
22
|
-
import { AlertTriangle, Download, Loader2, RotateCw, Shield } from 'lucide-react'
|
|
23
|
+
import { AlertTriangle, Download, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
|
|
24
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
23
25
|
import { useToast } from '../ui/Toast'
|
|
24
26
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
25
27
|
|
|
26
28
|
import { K8sResourceNode } from './K8sResourceNode'
|
|
27
29
|
import { GroupNode } from './GroupNode'
|
|
28
|
-
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey } from './layout'
|
|
30
|
+
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
|
|
29
31
|
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
30
32
|
|
|
31
33
|
// Edge colors by type
|
|
@@ -66,6 +68,9 @@ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boole
|
|
|
66
68
|
// Threshold for disabling edge animations (performance optimization)
|
|
67
69
|
const EDGE_ANIMATION_THRESHOLD = 200
|
|
68
70
|
|
|
71
|
+
// Auto-collapse all namespace groups when cluster has more than this many namespaces
|
|
72
|
+
const LARGE_CLUSTER_NS_THRESHOLD = 5
|
|
73
|
+
|
|
69
74
|
// Build edges, handling collapsed groups
|
|
70
75
|
function buildEdges(
|
|
71
76
|
topologyEdges: { id: string; source: string; target: string; type: string }[],
|
|
@@ -156,6 +161,16 @@ interface TopologyGraphProps {
|
|
|
156
161
|
selectedNodeId?: string
|
|
157
162
|
/** Show image export button in controls. Default: true */
|
|
158
163
|
showExportButton?: boolean
|
|
164
|
+
paused?: boolean
|
|
165
|
+
onTogglePause?: () => void
|
|
166
|
+
/** Called when user clicks "maximize" on a namespace group — sets namespace filter to just that namespace */
|
|
167
|
+
onMaximizeNamespace?: (namespace: string) => void
|
|
168
|
+
/** Shown as a breadcrumb label when viewing a single namespace */
|
|
169
|
+
namespaceBreadcrumb?: string
|
|
170
|
+
/** Called when breadcrumb "back" is clicked to return to all-namespace view */
|
|
171
|
+
onClearNamespace?: () => void
|
|
172
|
+
/** Serialized namespace filter — when this changes, reset groupLevels for fresh smart default */
|
|
173
|
+
namespacesKey?: string
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
export function TopologyGraph({
|
|
@@ -166,31 +181,91 @@ export function TopologyGraph({
|
|
|
166
181
|
onNodeClick,
|
|
167
182
|
selectedNodeId,
|
|
168
183
|
showExportButton = true,
|
|
184
|
+
paused = false,
|
|
185
|
+
onTogglePause,
|
|
186
|
+
onMaximizeNamespace,
|
|
187
|
+
namespaceBreadcrumb,
|
|
188
|
+
onClearNamespace,
|
|
189
|
+
namespacesKey = '',
|
|
169
190
|
}: TopologyGraphProps) {
|
|
170
191
|
const isTrafficView = viewMode === 'traffic'
|
|
171
|
-
const [nodes, setNodes,
|
|
192
|
+
const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
|
|
172
193
|
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
|
|
173
|
-
|
|
194
|
+
|
|
195
|
+
// Wrap onNodesChange to track user-dragged positions so they survive re-layouts.
|
|
196
|
+
const onNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
|
197
|
+
onNodesChangeBase(changes)
|
|
198
|
+
for (const change of changes) {
|
|
199
|
+
if (change.type === 'position' && change.position) {
|
|
200
|
+
savedPositionsRef.current.set(change.id, change.position)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, [onNodesChangeBase])
|
|
204
|
+
// 3-level display: chip (compact) → cardGrid (workload cards) → topology (full graph)
|
|
205
|
+
const [groupLevels, setGroupLevels] = useState<Map<string, GroupDisplayLevel>>(new Map())
|
|
174
206
|
const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
|
|
207
|
+
|
|
208
|
+
// Derive collapsedGroups for ELK/edges: both 'chip' and 'cardGrid' are collapsed
|
|
209
|
+
const collapsedGroups = useMemo(() => {
|
|
210
|
+
const set = new Set<string>()
|
|
211
|
+
for (const [id, level] of groupLevels) {
|
|
212
|
+
if (level !== 'topology') set.add(id)
|
|
213
|
+
}
|
|
214
|
+
return set
|
|
215
|
+
}, [groupLevels])
|
|
175
216
|
const [layoutError, setLayoutError] = useState<string | null>(null)
|
|
176
217
|
const [layoutRetryCount, setLayoutRetryCount] = useState(0)
|
|
218
|
+
const [fitViewCounter, setFitViewCounter] = useState(0)
|
|
177
219
|
const [isExporting, setIsExporting] = useState(false)
|
|
178
220
|
const prevStructureRef = useRef<string>('')
|
|
179
221
|
const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
|
|
222
|
+
// Saved node positions for preservation across topology updates.
|
|
223
|
+
// Prevents every cluster change from re-running a full ELK layout (which shifts all nodes).
|
|
224
|
+
const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
|
225
|
+
const prevRetryCountRef = useRef(0)
|
|
226
|
+
// Stores the current groupMap so collapse-all can reference group IDs without a stale closure
|
|
227
|
+
const groupMapRef = useRef<Map<string, string[]>>(new Map())
|
|
228
|
+
// Tracks whether the one-time smart default (collapse all for large clusters) has fired
|
|
229
|
+
const hasAppliedSmartDefaultRef = useRef(false)
|
|
230
|
+
// After layout completes for a single-group change, stores the group ID so
|
|
231
|
+
// ViewportController can fitView to it (with correct timing — after setNodes)
|
|
232
|
+
const fitToGroupAfterLayoutRef = useRef<string | null>(null)
|
|
233
|
+
|
|
234
|
+
// Reset group display levels when namespace filter changes (instant switching)
|
|
235
|
+
const prevNamespacesKeyRef = useRef(namespacesKey)
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (namespacesKey !== prevNamespacesKeyRef.current) {
|
|
238
|
+
prevNamespacesKeyRef.current = namespacesKey
|
|
239
|
+
setGroupLevels(new Map())
|
|
240
|
+
hasAppliedSmartDefaultRef.current = false
|
|
241
|
+
savedPositionsRef.current.clear()
|
|
242
|
+
setFitViewCounter(c => c + 1)
|
|
243
|
+
}
|
|
244
|
+
}, [namespacesKey])
|
|
180
245
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const next = new
|
|
185
|
-
|
|
186
|
-
next.delete(groupId)
|
|
187
|
-
} else {
|
|
188
|
-
next.add(groupId)
|
|
189
|
-
}
|
|
246
|
+
// Set display level for a single group
|
|
247
|
+
const handleSetLevel = useCallback((groupId: string, level: GroupDisplayLevel) => {
|
|
248
|
+
setGroupLevels(prev => {
|
|
249
|
+
const next = new Map(prev)
|
|
250
|
+
next.set(groupId, level)
|
|
190
251
|
return next
|
|
191
252
|
})
|
|
253
|
+
savedPositionsRef.current.clear()
|
|
254
|
+
// After layout completes, ViewportController will fitView to this group
|
|
255
|
+
fitToGroupAfterLayoutRef.current = groupId
|
|
192
256
|
}, [])
|
|
193
257
|
|
|
258
|
+
// Set all groups to a given level
|
|
259
|
+
const setAllLevels = useCallback((level: GroupDisplayLevel) => {
|
|
260
|
+
const next = new Map<string, GroupDisplayLevel>()
|
|
261
|
+
for (const groupKey of groupMapRef.current.keys()) {
|
|
262
|
+
next.set(`group-${groupingMode}-${groupKey}`, level)
|
|
263
|
+
}
|
|
264
|
+
setGroupLevels(next)
|
|
265
|
+
savedPositionsRef.current.clear()
|
|
266
|
+
setFitViewCounter(c => c + 1)
|
|
267
|
+
}, [groupingMode])
|
|
268
|
+
|
|
194
269
|
// Expand pod group to show individual pods
|
|
195
270
|
const handleExpandPodGroup = useCallback((podGroupId: string) => {
|
|
196
271
|
setExpandedPodGroups(prev => new Set(prev).add(podGroupId))
|
|
@@ -359,13 +434,19 @@ export function TopologyGraph({
|
|
|
359
434
|
return { workingNodes: nodes, workingEdges: edges }
|
|
360
435
|
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
|
|
361
436
|
|
|
362
|
-
//
|
|
437
|
+
// Handle card click in card-grid view — find the topology node and open drawer
|
|
438
|
+
const handleCardClick = useCallback((nodeId: string) => {
|
|
439
|
+
const topoNode = topology?.nodes.find(n => n.id === nodeId) || workingNodes.find(n => n.id === nodeId)
|
|
440
|
+
if (topoNode) onNodeClick(topoNode)
|
|
441
|
+
}, [topology, workingNodes, onNodeClick])
|
|
442
|
+
|
|
443
|
+
// Structure key for change detection — includes groupLevels so chip↔cardGrid triggers relayout
|
|
363
444
|
const structureKey = useMemo(() => {
|
|
364
445
|
const nodeIds = workingNodes.map(n => n.id).sort().join(',')
|
|
365
|
-
const
|
|
446
|
+
const levels = Array.from(groupLevels.entries()).sort().map(([k, v]) => `${k}:${v}`).join(',')
|
|
366
447
|
const expanded = Array.from(expandedPodGroups).sort().join(',')
|
|
367
|
-
return `${viewMode}|${nodeIds}|${
|
|
368
|
-
}, [viewMode, workingNodes,
|
|
448
|
+
return `${viewMode}|${nodeIds}|${levels}|${expanded}|${groupingMode}|${layoutRetryCount}`
|
|
449
|
+
}, [viewMode, workingNodes, groupLevels, expandedPodGroups, groupingMode, layoutRetryCount])
|
|
369
450
|
|
|
370
451
|
// Layout when structure changes - use hierarchical ELK layout
|
|
371
452
|
useEffect(() => {
|
|
@@ -373,6 +454,8 @@ export function TopologyGraph({
|
|
|
373
454
|
setNodes([])
|
|
374
455
|
setEdges([])
|
|
375
456
|
prevStructureRef.current = ''
|
|
457
|
+
savedPositionsRef.current.clear() // Clear on context switch / topology reset
|
|
458
|
+
hasAppliedSmartDefaultRef.current = false
|
|
376
459
|
return
|
|
377
460
|
}
|
|
378
461
|
|
|
@@ -382,8 +465,40 @@ export function TopologyGraph({
|
|
|
382
465
|
return
|
|
383
466
|
}
|
|
384
467
|
|
|
468
|
+
// Detect explicit re-layout request (Retry button) — clear saved positions so
|
|
469
|
+
// ELK computes a fresh layout from scratch instead of preserving old positions.
|
|
470
|
+
const isRetry = layoutRetryCount !== prevRetryCountRef.current
|
|
471
|
+
if (isRetry) {
|
|
472
|
+
prevRetryCountRef.current = layoutRetryCount
|
|
473
|
+
savedPositionsRef.current.clear()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// For the initial layout (no saved positions yet) run ELK for all nodes.
|
|
477
|
+
// For subsequent updates, preserve existing positions — only new nodes get
|
|
478
|
+
// ELK-computed positions. This prevents the whole graph from shifting every
|
|
479
|
+
// time the cluster adds a new resource.
|
|
480
|
+
const isInitialLayout = savedPositionsRef.current.size === 0
|
|
481
|
+
|
|
385
482
|
prevStructureRef.current = structureKey
|
|
386
483
|
|
|
484
|
+
// Smart default: start all namespace groups as chips for large clusters.
|
|
485
|
+
// Fires once per topology lifecycle (reset on context switch). Returns early
|
|
486
|
+
// so the re-render with groupLevels set computes the actual layout.
|
|
487
|
+
if (!hasAppliedSmartDefaultRef.current && groupLevels.size === 0 && groupingMode === 'namespace' && !hideGroupHeader) {
|
|
488
|
+
const uniqueNamespaces = new Set(workingNodes.map(n => n.data.namespace as string).filter(Boolean))
|
|
489
|
+
if (uniqueNamespaces.size > LARGE_CLUSTER_NS_THRESHOLD) {
|
|
490
|
+
hasAppliedSmartDefaultRef.current = true
|
|
491
|
+
savedPositionsRef.current.clear()
|
|
492
|
+
const levels = new Map<string, GroupDisplayLevel>()
|
|
493
|
+
for (const ns of uniqueNamespaces) levels.set(`group-namespace-${ns}`, 'chip')
|
|
494
|
+
setGroupLevels(levels)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
// Don't mark as applied when skipping — topology data may be stale (e.g., still
|
|
498
|
+
// showing single-namespace data during a transition to all-namespaces). The real
|
|
499
|
+
// all-namespace data will arrive and re-trigger this check.
|
|
500
|
+
}
|
|
501
|
+
|
|
387
502
|
// Increment version to invalidate any previous in-flight layout
|
|
388
503
|
const thisLayoutVersion = ++layoutVersionRef.current
|
|
389
504
|
|
|
@@ -392,18 +507,22 @@ export function TopologyGraph({
|
|
|
392
507
|
workingNodes,
|
|
393
508
|
workingEdges,
|
|
394
509
|
groupingMode,
|
|
395
|
-
collapsedGroups
|
|
510
|
+
collapsedGroups,
|
|
511
|
+
groupLevels
|
|
396
512
|
)
|
|
513
|
+
groupMapRef.current = groupMap
|
|
397
514
|
|
|
398
515
|
// Apply layout and get positioned nodes
|
|
399
516
|
applyHierarchicalLayout(
|
|
400
517
|
elkGraph,
|
|
401
518
|
workingNodes,
|
|
519
|
+
workingEdges,
|
|
402
520
|
groupMap,
|
|
403
521
|
groupingMode,
|
|
404
522
|
collapsedGroups,
|
|
405
|
-
|
|
406
|
-
hideGroupHeader
|
|
523
|
+
{ onSetLevel: handleSetLevel, onCardClick: handleCardClick, onMaximizeNamespace },
|
|
524
|
+
hideGroupHeader,
|
|
525
|
+
groupLevels
|
|
407
526
|
).then(({ nodes: layoutedNodes, error }) => {
|
|
408
527
|
// Check if a newer layout has started - if so, discard this stale result
|
|
409
528
|
if (layoutVersionRef.current !== thisLayoutVersion) {
|
|
@@ -418,8 +537,29 @@ export function TopologyGraph({
|
|
|
418
537
|
}
|
|
419
538
|
setLayoutError(null)
|
|
420
539
|
|
|
540
|
+
// Preserve positions for nodes that already have a saved position (i.e. were
|
|
541
|
+
// present in a previous layout). New nodes use the ELK-computed position.
|
|
542
|
+
// This prevents the whole graph from shifting every time the topology changes.
|
|
543
|
+
// isInitialLayout is captured in the outer effect scope.
|
|
544
|
+
const positionedNodes = isInitialLayout
|
|
545
|
+
? layoutedNodes
|
|
546
|
+
: layoutedNodes.map(node => {
|
|
547
|
+
const saved = savedPositionsRef.current.get(node.id)
|
|
548
|
+
return saved ? { ...node, position: saved } : node
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
// Update saved positions: add/overwrite with positions from this layout run.
|
|
552
|
+
// Remove stale entries for nodes no longer in the topology.
|
|
553
|
+
const currentIds = new Set(positionedNodes.map(n => n.id))
|
|
554
|
+
for (const id of savedPositionsRef.current.keys()) {
|
|
555
|
+
if (!currentIds.has(id)) savedPositionsRef.current.delete(id)
|
|
556
|
+
}
|
|
557
|
+
for (const node of positionedNodes) {
|
|
558
|
+
savedPositionsRef.current.set(node.id, node.position)
|
|
559
|
+
}
|
|
560
|
+
|
|
421
561
|
// Add expand/collapse handlers to pod-related nodes
|
|
422
|
-
const nodesWithHandlers =
|
|
562
|
+
const nodesWithHandlers = positionedNodes.map(node => {
|
|
423
563
|
const isPodGroup = node.data?.kind === 'PodGroup'
|
|
424
564
|
const nodeData = node.data?.nodeData as Record<string, unknown> | undefined
|
|
425
565
|
const expandedFromGroup = nodeData?.expandedFromGroup as string | undefined
|
|
@@ -437,23 +577,34 @@ export function TopologyGraph({
|
|
|
437
577
|
|
|
438
578
|
setNodes(nodesWithHandlers)
|
|
439
579
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
groupMap,
|
|
445
|
-
groupingMode,
|
|
446
|
-
isTrafficView,
|
|
447
|
-
nodeToGroup,
|
|
448
|
-
nodesWithHandlers.length
|
|
580
|
+
// Hide edges when all groups are collapsed — inter-namespace edges are noise in the overview.
|
|
581
|
+
// Always show edges in single-namespace view (hideGroupHeader) since there's no group container.
|
|
582
|
+
const hasAnyExpandedGroup = hideGroupHeader || nodesWithHandlers.some(n =>
|
|
583
|
+
n.type === 'group' && (n.data as Record<string, unknown>)?.displayLevel === 'topology'
|
|
449
584
|
)
|
|
450
|
-
|
|
585
|
+
if (!hasAnyExpandedGroup && groupingMode !== 'none') {
|
|
586
|
+
setEdges([])
|
|
587
|
+
} else {
|
|
588
|
+
const builtEdges = buildEdges(
|
|
589
|
+
workingEdges,
|
|
590
|
+
collapsedGroups,
|
|
591
|
+
groupMap,
|
|
592
|
+
groupingMode,
|
|
593
|
+
isTrafficView,
|
|
594
|
+
nodeToGroup,
|
|
595
|
+
nodesWithHandlers.length
|
|
596
|
+
)
|
|
597
|
+
setEdges(builtEdges)
|
|
598
|
+
}
|
|
599
|
+
}).catch((err) => {
|
|
600
|
+
console.error('[TopologyGraph] Layout post-processing error:', err)
|
|
601
|
+
setLayoutError(err instanceof Error ? err.message : String(err))
|
|
451
602
|
})
|
|
452
603
|
|
|
453
604
|
// No cleanup function - we use version-based invalidation instead
|
|
454
605
|
// This prevents React's effect re-runs from canceling in-flight layouts
|
|
455
606
|
// when the actual structure hasn't changed
|
|
456
|
-
}, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups,
|
|
607
|
+
}, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups, groupLevels, handleSetLevel, handleCardClick, onMaximizeNamespace, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, setNodes, setEdges, layoutRetryCount])
|
|
457
608
|
|
|
458
609
|
// Handle node click
|
|
459
610
|
const handleNodeClick = useCallback(
|
|
@@ -499,7 +650,18 @@ export function TopologyGraph({
|
|
|
499
650
|
})
|
|
500
651
|
}, [selectedNodeId, setNodes])
|
|
501
652
|
|
|
502
|
-
if (!topology
|
|
653
|
+
if (!topology) {
|
|
654
|
+
return (
|
|
655
|
+
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
656
|
+
<div className="text-center">
|
|
657
|
+
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 opacity-50" />
|
|
658
|
+
<p className="text-sm">Loading topology...</p>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (topology.nodes.length === 0) {
|
|
503
665
|
return (
|
|
504
666
|
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
505
667
|
<div className="text-center">
|
|
@@ -541,6 +703,25 @@ export function TopologyGraph({
|
|
|
541
703
|
|
|
542
704
|
return (
|
|
543
705
|
<ReactFlowProvider>
|
|
706
|
+
{/* Namespace breadcrumb — shown when viewing a single namespace */}
|
|
707
|
+
{namespaceBreadcrumb && (
|
|
708
|
+
<div className="absolute top-3 left-3 z-10 flex items-center gap-1.5">
|
|
709
|
+
{onClearNamespace && (
|
|
710
|
+
<button
|
|
711
|
+
onClick={onClearNamespace}
|
|
712
|
+
className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
713
|
+
>
|
|
714
|
+
All Namespaces
|
|
715
|
+
</button>
|
|
716
|
+
)}
|
|
717
|
+
{onClearNamespace && (
|
|
718
|
+
<span className="text-xs text-theme-text-tertiary">/</span>
|
|
719
|
+
)}
|
|
720
|
+
<span className="text-xs font-medium text-theme-text-secondary bg-theme-surface/80 backdrop-blur-sm border border-theme-border/50 rounded-md px-2 py-0.5">
|
|
721
|
+
{namespaceBreadcrumb}
|
|
722
|
+
</span>
|
|
723
|
+
</div>
|
|
724
|
+
)}
|
|
544
725
|
{/* Warning banner for partial topology data */}
|
|
545
726
|
{topology?.warnings && topology.warnings.length > 0 && (() => {
|
|
546
727
|
const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
|
|
@@ -620,10 +801,53 @@ export function TopologyGraph({
|
|
|
620
801
|
<Controls
|
|
621
802
|
className="bg-theme-surface border border-theme-border rounded-lg"
|
|
622
803
|
showInteractive={false}
|
|
804
|
+
showZoom={false}
|
|
805
|
+
showFitView={false}
|
|
623
806
|
>
|
|
624
|
-
|
|
807
|
+
<CustomControlButtons
|
|
808
|
+
showExportButton={showExportButton}
|
|
809
|
+
paused={paused}
|
|
810
|
+
onTogglePause={onTogglePause}
|
|
811
|
+
onExportingChange={setIsExporting}
|
|
812
|
+
/>
|
|
625
813
|
</Controls>
|
|
626
|
-
|
|
814
|
+
{/* Level controls — separate group matching per-node icons */}
|
|
815
|
+
{groupingMode !== 'none' && (
|
|
816
|
+
<div className="react-flow__panel react-flow__controls bottom-left bg-theme-surface border border-theme-border rounded-lg" style={{ marginBottom: 0, left: 10, bottom: 'auto', top: namespaceBreadcrumb ? 40 : 10 }}>
|
|
817
|
+
{!hideGroupHeader && (
|
|
818
|
+
<Tooltip content="Collapse all" delay={100} position="right">
|
|
819
|
+
<button
|
|
820
|
+
className="react-flow__controls-button"
|
|
821
|
+
onClick={() => setAllLevels('chip')}
|
|
822
|
+
>
|
|
823
|
+
<Minus className="w-3.5 h-3.5" />
|
|
824
|
+
</button>
|
|
825
|
+
</Tooltip>
|
|
826
|
+
)}
|
|
827
|
+
<Tooltip content="All workload cards" delay={100} position="right">
|
|
828
|
+
<button
|
|
829
|
+
className="react-flow__controls-button"
|
|
830
|
+
onClick={() => setAllLevels('cardGrid')}
|
|
831
|
+
>
|
|
832
|
+
<LayoutGrid className="w-3.5 h-3.5" />
|
|
833
|
+
</button>
|
|
834
|
+
</Tooltip>
|
|
835
|
+
<Tooltip content="Expand all" delay={100} position="right">
|
|
836
|
+
<button
|
|
837
|
+
className="react-flow__controls-button"
|
|
838
|
+
onClick={() => setAllLevels('topology')}
|
|
839
|
+
>
|
|
840
|
+
<Workflow className="w-3.5 h-3.5" />
|
|
841
|
+
</button>
|
|
842
|
+
</Tooltip>
|
|
843
|
+
</div>
|
|
844
|
+
)}
|
|
845
|
+
<ViewportController
|
|
846
|
+
viewMode={viewMode}
|
|
847
|
+
layoutRetryCount={layoutRetryCount}
|
|
848
|
+
fitViewCounter={fitViewCounter}
|
|
849
|
+
fitToGroupAfterLayoutRef={fitToGroupAfterLayoutRef}
|
|
850
|
+
/>
|
|
627
851
|
</ReactFlow>
|
|
628
852
|
</ReactFlowProvider>
|
|
629
853
|
)
|
|
@@ -790,14 +1014,15 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
790
1014
|
|
|
791
1015
|
return (
|
|
792
1016
|
<>
|
|
793
|
-
<
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1017
|
+
<Tooltip content="Export as image" delay={100} position="right">
|
|
1018
|
+
<button
|
|
1019
|
+
className="react-flow__controls-button"
|
|
1020
|
+
onClick={openDialog}
|
|
1021
|
+
disabled={exporting}
|
|
1022
|
+
>
|
|
1023
|
+
{exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
|
1024
|
+
</button>
|
|
1025
|
+
</Tooltip>
|
|
801
1026
|
{showDialog && (
|
|
802
1027
|
<div
|
|
803
1028
|
className="absolute bottom-12 left-0 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-2xl p-3 w-72"
|
|
@@ -818,7 +1043,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
818
1043
|
<button
|
|
819
1044
|
key={mode}
|
|
820
1045
|
onClick={() => setCaptureMode(mode)}
|
|
821
|
-
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? '
|
|
1046
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
822
1047
|
>
|
|
823
1048
|
{mode === 'full' ? 'Entire graph' : 'Visible area'}
|
|
824
1049
|
</button>
|
|
@@ -832,7 +1057,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
832
1057
|
<button
|
|
833
1058
|
key={f}
|
|
834
1059
|
onClick={() => setFormat(f)}
|
|
835
|
-
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? '
|
|
1060
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
|
|
836
1061
|
>
|
|
837
1062
|
{FORMAT_LABELS[f]}
|
|
838
1063
|
</button>
|
|
@@ -880,7 +1105,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
880
1105
|
<button
|
|
881
1106
|
onClick={doExport}
|
|
882
1107
|
disabled={exporting}
|
|
883
|
-
className="flex-1 px-3 py-1.5 text-sm font-medium
|
|
1108
|
+
className="flex-1 px-3 py-1.5 text-sm font-medium btn-brand rounded flex items-center justify-center gap-1.5"
|
|
884
1109
|
>
|
|
885
1110
|
{exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
|
|
886
1111
|
Export
|
|
@@ -899,15 +1124,73 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
|
|
|
899
1124
|
)
|
|
900
1125
|
}
|
|
901
1126
|
|
|
1127
|
+
// Custom control buttons — replaces ReactFlow defaults so we can use our Tooltip component
|
|
1128
|
+
function CustomControlButtons({
|
|
1129
|
+
showExportButton,
|
|
1130
|
+
paused,
|
|
1131
|
+
onTogglePause,
|
|
1132
|
+
onExportingChange,
|
|
1133
|
+
}: {
|
|
1134
|
+
showExportButton: boolean
|
|
1135
|
+
paused: boolean
|
|
1136
|
+
onTogglePause?: () => void
|
|
1137
|
+
onExportingChange: (v: boolean) => void
|
|
1138
|
+
}) {
|
|
1139
|
+
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
|
1140
|
+
const TIP = 100
|
|
1141
|
+
return (
|
|
1142
|
+
<>
|
|
1143
|
+
<Tooltip content="Zoom in" delay={TIP} position="right">
|
|
1144
|
+
<button className="react-flow__controls-button" onClick={() => zoomIn({ duration: 200 })}>
|
|
1145
|
+
<Plus className="w-3 h-3" />
|
|
1146
|
+
</button>
|
|
1147
|
+
</Tooltip>
|
|
1148
|
+
<Tooltip content="Zoom out" delay={TIP} position="right">
|
|
1149
|
+
<button className="react-flow__controls-button" onClick={() => zoomOut({ duration: 200 })}>
|
|
1150
|
+
<Minus className="w-3 h-3" />
|
|
1151
|
+
</button>
|
|
1152
|
+
</Tooltip>
|
|
1153
|
+
<Tooltip content="Fit view" delay={TIP} position="right">
|
|
1154
|
+
<button className="react-flow__controls-button" onClick={() => fitView({ padding: 0.15, duration: 400 })}>
|
|
1155
|
+
<Maximize className="w-3 h-3" />
|
|
1156
|
+
</button>
|
|
1157
|
+
</Tooltip>
|
|
1158
|
+
{showExportButton && <ExportImageButton onExportingChange={onExportingChange} />}
|
|
1159
|
+
{onTogglePause && (
|
|
1160
|
+
<Tooltip content={paused ? 'Resume live updates' : 'Pause live updates'} delay={TIP} position="right">
|
|
1161
|
+
<button
|
|
1162
|
+
className={`react-flow__controls-button ${paused ? 'text-amber-400' : ''}`}
|
|
1163
|
+
onClick={onTogglePause}
|
|
1164
|
+
>
|
|
1165
|
+
{paused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
|
|
1166
|
+
</button>
|
|
1167
|
+
</Tooltip>
|
|
1168
|
+
)}
|
|
1169
|
+
</>
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
902
1173
|
// Animation duration for viewport transitions
|
|
903
1174
|
const VIEWPORT_ANIMATION_DURATION = 400
|
|
904
1175
|
|
|
905
1176
|
// Inner component to handle animated viewport transitions and zoom-based CSS variables
|
|
906
1177
|
// Must be inside ReactFlow to use useReactFlow hook
|
|
907
|
-
function ViewportController({
|
|
1178
|
+
function ViewportController({
|
|
1179
|
+
viewMode,
|
|
1180
|
+
layoutRetryCount,
|
|
1181
|
+
fitViewCounter = 0,
|
|
1182
|
+
fitToGroupAfterLayoutRef,
|
|
1183
|
+
}: {
|
|
1184
|
+
viewMode: string
|
|
1185
|
+
layoutRetryCount: number
|
|
1186
|
+
fitViewCounter?: number
|
|
1187
|
+
fitToGroupAfterLayoutRef?: React.MutableRefObject<string | null>
|
|
1188
|
+
}) {
|
|
908
1189
|
const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
|
|
909
1190
|
const nodes = useNodes() // Reactive hook to watch node changes
|
|
910
|
-
const
|
|
1191
|
+
const prevViewModeRef = useRef<string>(viewMode)
|
|
1192
|
+
const prevRetryCountRef = useRef(layoutRetryCount)
|
|
1193
|
+
const prevFitViewCounterRef = useRef(fitViewCounter)
|
|
911
1194
|
const prevNodesLengthRef = useRef(0)
|
|
912
1195
|
|
|
913
1196
|
// Topology keyboard shortcuts
|
|
@@ -978,34 +1261,51 @@ function ViewportController({ structureKey }: { structureKey: string }) {
|
|
|
978
1261
|
updateZoomOffset(getViewport())
|
|
979
1262
|
}, [updateZoomOffset, getViewport])
|
|
980
1263
|
|
|
981
|
-
// Fit view
|
|
982
|
-
//
|
|
1264
|
+
// Fit view only on intentional changes: initial load, namespace/view switch, explicit retry.
|
|
1265
|
+
// Background topology updates (new pods, status changes) must NOT trigger fitView —
|
|
1266
|
+
// doing so would cause the viewport to zoom/pan every few seconds in active clusters.
|
|
983
1267
|
useEffect(() => {
|
|
984
|
-
const structureChanged = structureKey !== prevStructureKeyRef.current
|
|
985
1268
|
const nodesJustPopulated = prevNodesLengthRef.current === 0 && nodes.length > 0
|
|
1269
|
+
const viewModeChanged = viewMode !== prevViewModeRef.current
|
|
1270
|
+
const retryRequested = layoutRetryCount !== prevRetryCountRef.current
|
|
1271
|
+
const fitViewRequested = fitViewCounter !== prevFitViewCounterRef.current
|
|
986
1272
|
|
|
987
|
-
// Update refs
|
|
988
1273
|
prevNodesLengthRef.current = nodes.length
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1274
|
+
prevViewModeRef.current = viewMode
|
|
1275
|
+
prevRetryCountRef.current = layoutRetryCount
|
|
1276
|
+
prevFitViewCounterRef.current = fitViewCounter
|
|
992
1277
|
|
|
993
|
-
|
|
994
|
-
// 1. Nodes just became available (were 0, now > 0) - handles initial mount/view switch
|
|
995
|
-
// 2. Structure changed AND nodes already exist - handles topology changes
|
|
996
|
-
if (nodesJustPopulated || (structureChanged && nodes.length > 0)) {
|
|
997
|
-
// Small delay to ensure DOM is updated
|
|
1278
|
+
if (nodesJustPopulated || viewModeChanged || retryRequested || fitViewRequested) {
|
|
998
1279
|
const timeoutId = setTimeout(() => {
|
|
999
1280
|
fitView({
|
|
1000
1281
|
padding: 0.15,
|
|
1001
|
-
// No animation when nodes first appear, animate on subsequent structure changes
|
|
1002
1282
|
duration: nodesJustPopulated ? 0 : VIEWPORT_ANIMATION_DURATION,
|
|
1003
1283
|
})
|
|
1004
1284
|
}, 10)
|
|
1005
1285
|
|
|
1006
1286
|
return () => clearTimeout(timeoutId)
|
|
1007
1287
|
}
|
|
1008
|
-
}, [
|
|
1288
|
+
}, [viewMode, layoutRetryCount, fitViewCounter, nodes.length, fitView])
|
|
1289
|
+
|
|
1290
|
+
// After a single-group expand/collapse, fit the viewport to that group.
|
|
1291
|
+
// This effect fires when nodes update (triggered by setNodes after async layout).
|
|
1292
|
+
useEffect(() => {
|
|
1293
|
+
if (!fitToGroupAfterLayoutRef?.current) return
|
|
1294
|
+
const targetGroupId = fitToGroupAfterLayoutRef.current
|
|
1295
|
+
fitToGroupAfterLayoutRef.current = null
|
|
1296
|
+
// Find the group and its children to fit to
|
|
1297
|
+
const targetNodes = nodes.filter(n => n.id === targetGroupId || n.parentId === targetGroupId)
|
|
1298
|
+
if (targetNodes.length > 0) {
|
|
1299
|
+
setTimeout(() => {
|
|
1300
|
+
fitView({
|
|
1301
|
+
nodes: targetNodes.map(n => ({ id: n.id })),
|
|
1302
|
+
padding: 0.2,
|
|
1303
|
+
duration: VIEWPORT_ANIMATION_DURATION,
|
|
1304
|
+
maxZoom: 1.5,
|
|
1305
|
+
})
|
|
1306
|
+
}, 10)
|
|
1307
|
+
}
|
|
1308
|
+
}, [nodes, fitView, fitToGroupAfterLayoutRef])
|
|
1009
1309
|
|
|
1010
1310
|
return null
|
|
1011
1311
|
}
|