@skyhook-io/k8s-ui 1.3.0 → 1.3.2

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.
@@ -20,13 +20,14 @@ import {
20
20
  import '@xyflow/react/dist/style.css'
21
21
  import { toCanvas } from 'html-to-image'
22
22
 
23
- import { AlertTriangle, Download, Loader2, Pause, Play, 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'
24
25
  import { useToast } from '../ui/Toast'
25
26
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
26
27
 
27
28
  import { K8sResourceNode } from './K8sResourceNode'
28
29
  import { GroupNode } from './GroupNode'
29
- import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey } from './layout'
30
+ import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
30
31
  import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
31
32
 
32
33
  // Edge colors by type
@@ -67,6 +68,9 @@ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boole
67
68
  // Threshold for disabling edge animations (performance optimization)
68
69
  const EDGE_ANIMATION_THRESHOLD = 200
69
70
 
71
+ // Auto-collapse all namespace groups when cluster has more than this many namespaces
72
+ const LARGE_CLUSTER_NS_THRESHOLD = 5
73
+
70
74
  // Build edges, handling collapsed groups
71
75
  function buildEdges(
72
76
  topologyEdges: { id: string; source: string; target: string; type: string }[],
@@ -159,6 +163,14 @@ interface TopologyGraphProps {
159
163
  showExportButton?: boolean
160
164
  paused?: boolean
161
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
162
174
  }
163
175
 
164
176
  export function TopologyGraph({
@@ -171,6 +183,10 @@ export function TopologyGraph({
171
183
  showExportButton = true,
172
184
  paused = false,
173
185
  onTogglePause,
186
+ onMaximizeNamespace,
187
+ namespaceBreadcrumb,
188
+ onClearNamespace,
189
+ namespacesKey = '',
174
190
  }: TopologyGraphProps) {
175
191
  const isTrafficView = viewMode === 'traffic'
176
192
  const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
@@ -185,10 +201,21 @@ export function TopologyGraph({
185
201
  }
186
202
  }
187
203
  }, [onNodesChangeBase])
188
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
204
+ // 3-level display: chip (compact) → cardGrid (workload cards) → topology (full graph)
205
+ const [groupLevels, setGroupLevels] = useState<Map<string, GroupDisplayLevel>>(new Map())
189
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])
190
216
  const [layoutError, setLayoutError] = useState<string | null>(null)
191
217
  const [layoutRetryCount, setLayoutRetryCount] = useState(0)
218
+ const [fitViewCounter, setFitViewCounter] = useState(0)
192
219
  const [isExporting, setIsExporting] = useState(false)
193
220
  const prevStructureRef = useRef<string>('')
194
221
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
@@ -196,20 +223,49 @@ export function TopologyGraph({
196
223
  // Prevents every cluster change from re-running a full ELK layout (which shifts all nodes).
197
224
  const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
198
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])
199
245
 
200
- // Toggle group collapse
201
- const handleToggleCollapse = useCallback((groupId: string) => {
202
- setCollapsedGroups(prev => {
203
- const next = new Set(prev)
204
- if (next.has(groupId)) {
205
- next.delete(groupId)
206
- } else {
207
- next.add(groupId)
208
- }
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)
209
251
  return next
210
252
  })
253
+ savedPositionsRef.current.clear()
254
+ // After layout completes, ViewportController will fitView to this group
255
+ fitToGroupAfterLayoutRef.current = groupId
211
256
  }, [])
212
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
+
213
269
  // Expand pod group to show individual pods
214
270
  const handleExpandPodGroup = useCallback((podGroupId: string) => {
215
271
  setExpandedPodGroups(prev => new Set(prev).add(podGroupId))
@@ -378,13 +434,19 @@ export function TopologyGraph({
378
434
  return { workingNodes: nodes, workingEdges: edges }
379
435
  }, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
380
436
 
381
- // 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
382
444
  const structureKey = useMemo(() => {
383
445
  const nodeIds = workingNodes.map(n => n.id).sort().join(',')
384
- const collapsed = Array.from(collapsedGroups).sort().join(',')
446
+ const levels = Array.from(groupLevels.entries()).sort().map(([k, v]) => `${k}:${v}`).join(',')
385
447
  const expanded = Array.from(expandedPodGroups).sort().join(',')
386
- return `${viewMode}|${nodeIds}|${collapsed}|${expanded}|${groupingMode}|${layoutRetryCount}`
387
- }, [viewMode, workingNodes, collapsedGroups, expandedPodGroups, groupingMode, layoutRetryCount])
448
+ return `${viewMode}|${nodeIds}|${levels}|${expanded}|${groupingMode}|${layoutRetryCount}`
449
+ }, [viewMode, workingNodes, groupLevels, expandedPodGroups, groupingMode, layoutRetryCount])
388
450
 
389
451
  // Layout when structure changes - use hierarchical ELK layout
390
452
  useEffect(() => {
@@ -393,6 +455,7 @@ export function TopologyGraph({
393
455
  setEdges([])
394
456
  prevStructureRef.current = ''
395
457
  savedPositionsRef.current.clear() // Clear on context switch / topology reset
458
+ hasAppliedSmartDefaultRef.current = false
396
459
  return
397
460
  }
398
461
 
@@ -418,6 +481,24 @@ export function TopologyGraph({
418
481
 
419
482
  prevStructureRef.current = structureKey
420
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
+
421
502
  // Increment version to invalidate any previous in-flight layout
422
503
  const thisLayoutVersion = ++layoutVersionRef.current
423
504
 
@@ -426,18 +507,22 @@ export function TopologyGraph({
426
507
  workingNodes,
427
508
  workingEdges,
428
509
  groupingMode,
429
- collapsedGroups
510
+ collapsedGroups,
511
+ groupLevels
430
512
  )
513
+ groupMapRef.current = groupMap
431
514
 
432
515
  // Apply layout and get positioned nodes
433
516
  applyHierarchicalLayout(
434
517
  elkGraph,
435
518
  workingNodes,
519
+ workingEdges,
436
520
  groupMap,
437
521
  groupingMode,
438
522
  collapsedGroups,
439
- handleToggleCollapse,
440
- hideGroupHeader
523
+ { onSetLevel: handleSetLevel, onCardClick: handleCardClick, onMaximizeNamespace },
524
+ hideGroupHeader,
525
+ groupLevels
441
526
  ).then(({ nodes: layoutedNodes, error }) => {
442
527
  // Check if a newer layout has started - if so, discard this stale result
443
528
  if (layoutVersionRef.current !== thisLayoutVersion) {
@@ -492,23 +577,34 @@ export function TopologyGraph({
492
577
 
493
578
  setNodes(nodesWithHandlers)
494
579
 
495
- // Build edges with styling (pass node count for animation threshold)
496
- const builtEdges = buildEdges(
497
- workingEdges,
498
- collapsedGroups,
499
- groupMap,
500
- groupingMode,
501
- isTrafficView,
502
- nodeToGroup,
503
- 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'
504
584
  )
505
- 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))
506
602
  })
507
603
 
508
604
  // No cleanup function - we use version-based invalidation instead
509
605
  // This prevents React's effect re-runs from canceling in-flight layouts
510
606
  // when the actual structure hasn't changed
511
- }, [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])
512
608
 
513
609
  // Handle node click
514
610
  const handleNodeClick = useCallback(
@@ -554,7 +650,18 @@ export function TopologyGraph({
554
650
  })
555
651
  }, [selectedNodeId, setNodes])
556
652
 
557
- 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) {
558
665
  return (
559
666
  <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
560
667
  <div className="text-center">
@@ -596,6 +703,25 @@ export function TopologyGraph({
596
703
 
597
704
  return (
598
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
+ )}
599
725
  {/* Warning banner for partial topology data */}
600
726
  {topology?.warnings && topology.warnings.length > 0 && (() => {
601
727
  const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
@@ -675,19 +801,53 @@ export function TopologyGraph({
675
801
  <Controls
676
802
  className="bg-theme-surface border border-theme-border rounded-lg"
677
803
  showInteractive={false}
804
+ showZoom={false}
805
+ showFitView={false}
678
806
  >
679
- {showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
680
- {onTogglePause && (
681
- <button
682
- className={`react-flow__controls-button ${paused ? 'text-amber-400' : ''}`}
683
- onClick={onTogglePause}
684
- title={paused ? 'Resume live updates' : 'Pause live updates'}
685
- >
686
- {paused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
687
- </button>
688
- )}
807
+ <CustomControlButtons
808
+ showExportButton={showExportButton}
809
+ paused={paused}
810
+ onTogglePause={onTogglePause}
811
+ onExportingChange={setIsExporting}
812
+ />
689
813
  </Controls>
690
- <ViewportController viewMode={viewMode} layoutRetryCount={layoutRetryCount} />
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
+ />
691
851
  </ReactFlow>
692
852
  </ReactFlowProvider>
693
853
  )
@@ -854,14 +1014,15 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
854
1014
 
855
1015
  return (
856
1016
  <>
857
- <button
858
- className="react-flow__controls-button"
859
- onClick={openDialog}
860
- disabled={exporting}
861
- title="Export as image"
862
- >
863
- {exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
864
- </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>
865
1026
  {showDialog && (
866
1027
  <div
867
1028
  className="absolute bottom-12 left-0 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-2xl p-3 w-72"
@@ -963,6 +1124,52 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
963
1124
  )
964
1125
  }
965
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
+
966
1173
  // Animation duration for viewport transitions
967
1174
  const VIEWPORT_ANIMATION_DURATION = 400
968
1175
 
@@ -971,14 +1178,19 @@ const VIEWPORT_ANIMATION_DURATION = 400
971
1178
  function ViewportController({
972
1179
  viewMode,
973
1180
  layoutRetryCount,
1181
+ fitViewCounter = 0,
1182
+ fitToGroupAfterLayoutRef,
974
1183
  }: {
975
1184
  viewMode: string
976
1185
  layoutRetryCount: number
1186
+ fitViewCounter?: number
1187
+ fitToGroupAfterLayoutRef?: React.MutableRefObject<string | null>
977
1188
  }) {
978
1189
  const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
979
1190
  const nodes = useNodes() // Reactive hook to watch node changes
980
1191
  const prevViewModeRef = useRef<string>(viewMode)
981
1192
  const prevRetryCountRef = useRef(layoutRetryCount)
1193
+ const prevFitViewCounterRef = useRef(fitViewCounter)
982
1194
  const prevNodesLengthRef = useRef(0)
983
1195
 
984
1196
  // Topology keyboard shortcuts
@@ -1056,12 +1268,14 @@ function ViewportController({
1056
1268
  const nodesJustPopulated = prevNodesLengthRef.current === 0 && nodes.length > 0
1057
1269
  const viewModeChanged = viewMode !== prevViewModeRef.current
1058
1270
  const retryRequested = layoutRetryCount !== prevRetryCountRef.current
1271
+ const fitViewRequested = fitViewCounter !== prevFitViewCounterRef.current
1059
1272
 
1060
1273
  prevNodesLengthRef.current = nodes.length
1061
1274
  prevViewModeRef.current = viewMode
1062
1275
  prevRetryCountRef.current = layoutRetryCount
1276
+ prevFitViewCounterRef.current = fitViewCounter
1063
1277
 
1064
- if (nodesJustPopulated || viewModeChanged || retryRequested) {
1278
+ if (nodesJustPopulated || viewModeChanged || retryRequested || fitViewRequested) {
1065
1279
  const timeoutId = setTimeout(() => {
1066
1280
  fitView({
1067
1281
  padding: 0.15,
@@ -1071,7 +1285,27 @@ function ViewportController({
1071
1285
 
1072
1286
  return () => clearTimeout(timeoutId)
1073
1287
  }
1074
- }, [viewMode, layoutRetryCount, 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])
1075
1309
 
1076
1310
  return null
1077
1311
  }