@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.
- package/package.json +2 -2
- package/src/components/dock/BottomDock.tsx +23 -5
- package/src/components/dock/DockContext.tsx +9 -1
- package/src/components/logs/LogsViewer.tsx +8 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -4
- 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/TopologyGraph.tsx +286 -52
- package/src/components/topology/layout.ts +288 -92
- package/src/components/topology/topology.css +111 -0
- package/src/components/ui/Toast.tsx +74 -27
- package/src/theme/variables.css +7 -2
- package/src/types/core.ts +39 -0
- package/src/utils/download.ts +18 -3
- package/tsconfig.json +0 -1
|
@@ -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
|
-
|
|
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
|
-
//
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const next = new
|
|
204
|
-
|
|
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
|
-
//
|
|
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
|
|
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}|${
|
|
387
|
-
}, [viewMode, workingNodes,
|
|
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
|
-
|
|
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
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
}
|