@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.
Files changed (129) hide show
  1. package/package.json +4 -3
  2. package/src/components/dock/BottomDock.tsx +24 -6
  3. package/src/components/dock/DockContext.tsx +9 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +1 -1
  5. package/src/components/dock/NodeTerminalTab.tsx +1 -1
  6. package/src/components/dock/TerminalTab.tsx +2 -2
  7. package/src/components/gitops/GitOpsActions.tsx +12 -11
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +6 -6
  9. package/src/components/logs/LogCore.tsx +8 -8
  10. package/src/components/logs/LogsViewer.tsx +8 -3
  11. package/src/components/logs/WorkloadLogsViewer.tsx +37 -9
  12. package/src/components/logs/useLogStream.ts +20 -0
  13. package/src/components/resources/ResourcesSidebar.tsx +10 -10
  14. package/src/components/resources/ResourcesView.tsx +49 -48
  15. package/src/components/resources/renderers/AlertRenderer.tsx +2 -2
  16. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +2 -2
  17. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
  19. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -3
  20. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +3 -3
  22. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +3 -3
  23. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/EventRenderer.tsx +1 -1
  26. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  27. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  28. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +5 -5
  30. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -1
  31. package/src/components/resources/renderers/GatewayRenderer.tsx +3 -3
  32. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  33. package/src/components/resources/renderers/HPARenderer.tsx +1 -1
  34. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +5 -5
  35. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -1
  36. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
  37. package/src/components/resources/renderers/IngressRenderer.tsx +1 -1
  38. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +10 -10
  39. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -1
  40. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +6 -6
  43. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -5
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +5 -5
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +4 -4
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -3
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -3
  49. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +5 -5
  50. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +5 -5
  52. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +4 -4
  53. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +8 -8
  54. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -1
  56. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +6 -6
  58. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -1
  59. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -4
  60. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -6
  61. package/src/components/resources/renderers/NodeRenderer.tsx +5 -5
  62. package/src/components/resources/renderers/OrderRenderer.tsx +3 -3
  63. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -1
  64. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -1
  65. package/src/components/resources/renderers/PodMonitorRenderer.tsx +3 -3
  66. package/src/components/resources/renderers/PodRenderer.tsx +10 -10
  67. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  68. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -3
  69. package/src/components/resources/renderers/RoleRenderer.tsx +8 -8
  70. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -1
  71. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +2 -2
  72. package/src/components/resources/renderers/SealedSecretRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/SecretRenderer.tsx +3 -3
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -1
  75. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +3 -3
  76. package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
  77. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +5 -5
  78. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -1
  79. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -1
  80. package/src/components/resources/renderers/VPARenderer.tsx +2 -2
  81. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -2
  82. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +6 -6
  83. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +5 -5
  84. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +6 -6
  85. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -1
  86. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +10 -10
  87. package/src/components/resources/renderers/WorkflowRenderer.tsx +2 -2
  88. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +2 -2
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
  90. package/src/components/resources/renderers/argo-cells.tsx +3 -3
  91. package/src/components/resources/renderers/certmanager-cells.tsx +6 -6
  92. package/src/components/resources/renderers/cnpg-cells.tsx +5 -5
  93. package/src/components/resources/renderers/eso-cells.tsx +4 -4
  94. package/src/components/resources/renderers/flux-cells.tsx +6 -6
  95. package/src/components/resources/renderers/istio-cells.tsx +9 -9
  96. package/src/components/resources/renderers/karpenter-cells.tsx +3 -3
  97. package/src/components/resources/renderers/keda-cells.tsx +2 -2
  98. package/src/components/resources/renderers/knative-cells.tsx +2 -2
  99. package/src/components/resources/renderers/kyverno-cells.tsx +3 -3
  100. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  101. package/src/components/resources/renderers/trivy-cells.tsx +4 -4
  102. package/src/components/resources/renderers/velero-cells.tsx +4 -4
  103. package/src/components/shared/EditableYamlView.tsx +1 -1
  104. package/src/components/shared/ResourceActionsBar.tsx +17 -17
  105. package/src/components/shared/ResourceRendererDispatch.tsx +8 -7
  106. package/src/components/timeline/DiffViewer.tsx +131 -0
  107. package/src/components/timeline/TimelineList.tsx +817 -0
  108. package/src/components/timeline/index.ts +2 -0
  109. package/src/components/topology/GroupNode.tsx +189 -79
  110. package/src/components/topology/K8sResourceNode.tsx +5 -5
  111. package/src/components/topology/TopologyControls.tsx +2 -2
  112. package/src/components/topology/TopologyGraph.tsx +362 -62
  113. package/src/components/topology/layout.ts +288 -92
  114. package/src/components/topology/topology.css +112 -1
  115. package/src/components/ui/Badge.tsx +178 -0
  116. package/src/components/ui/HealthRing.tsx +70 -35
  117. package/src/components/ui/MetricsChart.tsx +2 -2
  118. package/src/components/ui/Toast.tsx +74 -27
  119. package/src/components/ui/drawer-components.tsx +12 -31
  120. package/src/components/workload/ResourceDetailDrawer.tsx +4 -4
  121. package/src/components/workload/WorkloadView.tsx +10 -10
  122. package/src/theme/components.css +195 -0
  123. package/src/theme/tailwind-theme.css +13 -13
  124. package/src/theme/variables.css +46 -29
  125. package/src/types/core.ts +41 -0
  126. package/src/utils/badge-colors.ts +54 -205
  127. package/src/utils/download.ts +18 -3
  128. package/src/utils/log-format.ts +0 -8
  129. 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, onNodesChange] = useNodesState([] as Node[])
192
+ const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
172
193
  const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
173
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
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
- // Toggle group collapse
182
- const handleToggleCollapse = useCallback((groupId: string) => {
183
- setCollapsedGroups(prev => {
184
- const next = new Set(prev)
185
- if (next.has(groupId)) {
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
- // Structure key for change detection
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 collapsed = Array.from(collapsedGroups).sort().join(',')
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}|${collapsed}|${expanded}|${groupingMode}|${layoutRetryCount}`
368
- }, [viewMode, workingNodes, collapsedGroups, expandedPodGroups, groupingMode, layoutRetryCount])
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
- handleToggleCollapse,
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 = layoutedNodes.map(node => {
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
- // Build edges with styling (pass node count for animation threshold)
441
- const builtEdges = buildEdges(
442
- workingEdges,
443
- collapsedGroups,
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
- setEdges(builtEdges)
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, handleToggleCollapse, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, setNodes, setEdges, layoutRetryCount])
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 || topology.nodes.length === 0) {
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
- {showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
807
+ <CustomControlButtons
808
+ showExportButton={showExportButton}
809
+ paused={paused}
810
+ onTogglePause={onTogglePause}
811
+ onExportingChange={setIsExporting}
812
+ />
625
813
  </Controls>
626
- <ViewportController structureKey={structureKey} />
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
- <button
794
- className="react-flow__controls-button"
795
- onClick={openDialog}
796
- disabled={exporting}
797
- title="Export as image"
798
- >
799
- {exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
800
- </button>
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 ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
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 ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
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 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
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({ structureKey }: { structureKey: string }) {
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 prevStructureKeyRef = useRef<string>('')
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 when nodes become available or structure changes
982
- // This handles both initial mount and view switching scenarios
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
- if (structureChanged) {
990
- prevStructureKeyRef.current = structureKey
991
- }
1274
+ prevViewModeRef.current = viewMode
1275
+ prevRetryCountRef.current = layoutRetryCount
1276
+ prevFitViewCounterRef.current = fitViewCounter
992
1277
 
993
- // Fit view when:
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
- }, [structureKey, nodes.length, fitView])
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
  }