@skyhook-io/k8s-ui 1.7.7 → 1.7.8

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 (48) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsTableView.tsx +1 -2
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  7. package/src/components/issues/IssuesView.tsx +352 -0
  8. package/src/components/issues/index.ts +24 -0
  9. package/src/components/issues/issues.test.ts +100 -0
  10. package/src/components/issues/severity.ts +125 -0
  11. package/src/components/issues/types.ts +194 -0
  12. package/src/components/resources/ResourcesView.tsx +2 -19
  13. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  14. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  15. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  19. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  26. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  27. package/src/components/shared/DetailShell.tsx +107 -0
  28. package/src/components/shared/ManagedByChip.tsx +149 -16
  29. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  30. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  31. package/src/components/shared/index.ts +2 -1
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  33. package/src/components/timeline/index.ts +1 -0
  34. package/src/components/topology/K8sResourceNode.tsx +60 -60
  35. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  36. package/src/components/topology/TopologyGraph.tsx +168 -52
  37. package/src/components/topology/TopologySearch.tsx +17 -8
  38. package/src/components/topology/topology-search-match.test.ts +1 -1
  39. package/src/components/topology/topology.css +18 -0
  40. package/src/components/ui/Tooltip.tsx +37 -2
  41. package/src/components/workload/WorkloadView.tsx +118 -112
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +16 -0
  44. package/src/types/core.ts +3 -2
  45. package/src/utils/env-from.ts +3 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/replica-scalers.ts +9 -0
  48. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -1,3 +1,4 @@
1
1
  export * from './shared'
2
2
  export * from './DiffViewer'
3
3
  export * from './TimelineList'
4
+ export * from './TimelineSwimlanes'
@@ -129,70 +129,70 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
129
129
  }
130
130
 
131
131
  // Default dimensions for unknown CRD kinds
132
- export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 56 }
132
+ export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 }
133
133
 
134
134
  // Node dimensions for ELK layout - sized for typical K8s resource names
135
135
  export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }> = {
136
136
  Internet: { width: 120, height: 52 },
137
- Ingress: { width: 300, height: 56 },
138
- Gateway: { width: 300, height: 56 },
139
- HTTPRoute: { width: 300, height: 56 },
140
- GRPCRoute: { width: 300, height: 56 },
141
- TCPRoute: { width: 300, height: 56 },
142
- TLSRoute: { width: 300, height: 56 },
143
- Service: { width: 260, height: 56 },
144
- Deployment: { width: 280, height: 56 },
145
- Rollout: { width: 280, height: 56 },
146
- Application: { width: 300, height: 56 }, // ArgoCD Application
147
- Kustomization: { width: 300, height: 56 }, // FluxCD Kustomization
148
- HelmRelease: { width: 280, height: 56 }, // FluxCD HelmRelease
149
- GitRepository: { width: 280, height: 56 }, // FluxCD GitRepository
150
- DaemonSet: { width: 280, height: 56 },
151
- StatefulSet: { width: 280, height: 56 },
152
- ReplicaSet: { width: 280, height: 56 },
153
- Pod: { width: 300, height: 56 },
137
+ Ingress: { width: 300, height: 84 },
138
+ Gateway: { width: 300, height: 84 },
139
+ HTTPRoute: { width: 300, height: 84 },
140
+ GRPCRoute: { width: 300, height: 84 },
141
+ TCPRoute: { width: 300, height: 84 },
142
+ TLSRoute: { width: 300, height: 84 },
143
+ Service: { width: 260, height: 84 },
144
+ Deployment: { width: 280, height: 84 },
145
+ Rollout: { width: 280, height: 84 },
146
+ Application: { width: 300, height: 84 }, // ArgoCD Application
147
+ Kustomization: { width: 300, height: 84 }, // FluxCD Kustomization
148
+ HelmRelease: { width: 280, height: 84 }, // FluxCD HelmRelease
149
+ GitRepository: { width: 280, height: 84 }, // FluxCD GitRepository
150
+ DaemonSet: { width: 280, height: 84 },
151
+ StatefulSet: { width: 280, height: 84 },
152
+ ReplicaSet: { width: 280, height: 84 },
153
+ Pod: { width: 300, height: 84 },
154
154
  PodGroup: { width: 200, height: 64 },
155
- ConfigMap: { width: 180, height: 48 },
156
- Secret: { width: 180, height: 48 },
157
- HorizontalPodAutoscaler: { width: 280, height: 56 },
158
- Job: { width: 180, height: 56 },
159
- CronJob: { width: 200, height: 56 },
160
- PersistentVolumeClaim: { width: 200, height: 48 },
161
- Namespace: { width: 180, height: 48 },
162
- Node: { width: 280, height: 56 },
163
- NodePool: { width: 260, height: 56 },
164
- NodeClaim: { width: 260, height: 56 },
165
- NodeClass: { width: 260, height: 56 },
166
- KnativeService: { width: 280, height: 56 },
167
- KnativeConfiguration: { width: 280, height: 56 },
168
- KnativeRevision: { width: 280, height: 56 },
169
- KnativeRoute: { width: 280, height: 56 },
170
- Broker: { width: 280, height: 56 },
171
- Channel: { width: 280, height: 56 },
172
- Trigger: { width: 280, height: 56 },
173
- PingSource: { width: 280, height: 56 },
174
- ApiServerSource: { width: 280, height: 56 },
175
- ContainerSource: { width: 280, height: 56 },
176
- SinkBinding: { width: 280, height: 56 },
177
- IngressRoute: { width: 280, height: 56 },
178
- IngressRouteTCP: { width: 280, height: 56 },
179
- IngressRouteUDP: { width: 280, height: 56 },
180
- Middleware: { width: 280, height: 56 },
181
- MiddlewareTCP: { width: 280, height: 56 },
182
- TraefikService: { width: 280, height: 56 },
183
- ServersTransport: { width: 280, height: 56 },
184
- ServersTransportTCP: { width: 300, height: 56 },
185
- TLSOption: { width: 280, height: 56 },
186
- TLSStore: { width: 280, height: 56 },
187
- HTTPProxy: { width: 280, height: 56 }, // Contour
188
- CAPICluster: { width: 280, height: 56 }, // Cluster API
189
- MachineDeployment: { width: 300, height: 56 },
190
- MachineSet: { width: 280, height: 56 },
191
- Machine: { width: 260, height: 56 },
192
- MachinePool: { width: 280, height: 56 },
193
- KubeadmControlPlane: { width: 300, height: 56 },
194
- ClusterClass: { width: 280, height: 56 },
195
- MachineHealthCheck: { width: 300, height: 56 },
155
+ ConfigMap: { width: 180, height: 84 },
156
+ Secret: { width: 180, height: 84 },
157
+ HorizontalPodAutoscaler: { width: 280, height: 84 },
158
+ Job: { width: 180, height: 84 },
159
+ CronJob: { width: 200, height: 84 },
160
+ PersistentVolumeClaim: { width: 200, height: 84 },
161
+ Namespace: { width: 180, height: 84 },
162
+ Node: { width: 280, height: 84 },
163
+ NodePool: { width: 260, height: 84 },
164
+ NodeClaim: { width: 260, height: 84 },
165
+ NodeClass: { width: 260, height: 84 },
166
+ KnativeService: { width: 280, height: 84 },
167
+ KnativeConfiguration: { width: 280, height: 84 },
168
+ KnativeRevision: { width: 280, height: 84 },
169
+ KnativeRoute: { width: 280, height: 84 },
170
+ Broker: { width: 280, height: 84 },
171
+ Channel: { width: 280, height: 84 },
172
+ Trigger: { width: 280, height: 84 },
173
+ PingSource: { width: 280, height: 84 },
174
+ ApiServerSource: { width: 280, height: 84 },
175
+ ContainerSource: { width: 280, height: 84 },
176
+ SinkBinding: { width: 280, height: 84 },
177
+ IngressRoute: { width: 280, height: 84 },
178
+ IngressRouteTCP: { width: 280, height: 84 },
179
+ IngressRouteUDP: { width: 280, height: 84 },
180
+ Middleware: { width: 280, height: 84 },
181
+ MiddlewareTCP: { width: 280, height: 84 },
182
+ TraefikService: { width: 280, height: 84 },
183
+ ServersTransport: { width: 280, height: 84 },
184
+ ServersTransportTCP: { width: 300, height: 84 },
185
+ TLSOption: { width: 280, height: 84 },
186
+ TLSStore: { width: 280, height: 84 },
187
+ HTTPProxy: { width: 280, height: 84 }, // Contour
188
+ CAPICluster: { width: 280, height: 84 }, // Cluster API
189
+ MachineDeployment: { width: 300, height: 84 },
190
+ MachineSet: { width: 280, height: 84 },
191
+ Machine: { width: 260, height: 84 },
192
+ MachinePool: { width: 280, height: 84 },
193
+ KubeadmControlPlane: { width: 300, height: 84 },
194
+ ClusterClass: { width: 280, height: 84 },
195
+ MachineHealthCheck: { width: 300, height: 84 },
196
196
  }
197
197
 
198
198
 
@@ -425,7 +425,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
425
425
  className={clsx(
426
426
  'relative rounded-lg overflow-hidden',
427
427
  'bg-theme-surface topology-node-card',
428
- selected && 'ring-2 ring-skyhook-400',
428
+ selected && 'topology-node-selected',
429
429
  isSmallNode && 'opacity-90',
430
430
  // Status bar via CSS pseudo-element (defined in index.css)
431
431
  (status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
@@ -386,9 +386,31 @@ export const TopologyFilterSidebar = memo(function TopologyFilterSidebar({
386
386
 
387
387
  {/* Footer stats */}
388
388
  <div className="px-3 py-2 border-t border-theme-border bg-theme-surface/50">
389
- <div className="text-xs text-theme-text-tertiary">
390
- Showing {availableKinds.filter(k => visibleKinds.has(k.kind)).reduce((sum, k) => sum + (kindCounts.get(k.kind) || 0), 0)} of {nodes.length} resources
391
- </div>
389
+ {(() => {
390
+ // Total and visible both sum over availableKinds (the kinds the user
391
+ // can filter). nodes.length would include the synthetic Internet node,
392
+ // which isn't a filterable kind, so the two wouldn't reconcile and a
393
+ // "filtered" count would show even with nothing hidden.
394
+ const sumKinds = (ks: typeof availableKinds) =>
395
+ ks.reduce((sum, k) => sum + (kindCounts.get(k.kind) || 0), 0)
396
+ const total = sumKinds(availableKinds)
397
+ const visible = sumKinds(availableKinds.filter(k => visibleKinds.has(k.kind)))
398
+ const hidden = total - visible
399
+ const filteredOutKinds = availableKinds.filter(k => !visibleKinds.has(k.kind) && (kindCounts.get(k.kind) || 0) > 0)
400
+ return (
401
+ <div
402
+ className="text-xs text-theme-text-tertiary"
403
+ title={filteredOutKinds.length > 0
404
+ ? `Hidden by kind filter: ${filteredOutKinds.map(k => `${kindCounts.get(k.kind)} ${k.kind}`).join(', ')}`
405
+ : undefined}
406
+ >
407
+ Showing {visible} of {total} resources
408
+ {hidden > 0 && (
409
+ <span className="ml-1 text-amber-400 cursor-help">· {hidden} filtered</span>
410
+ )}
411
+ </div>
412
+ )
413
+ })()}
392
414
  </div>
393
415
  </div>
394
416
  )
@@ -3,7 +3,7 @@ import {
3
3
  ReactFlow,
4
4
  ReactFlowProvider,
5
5
  Background,
6
- Controls,
6
+ Panel,
7
7
  useNodesState,
8
8
  useEdgesState,
9
9
  useReactFlow,
@@ -20,7 +20,7 @@ import {
20
20
  import '@xyflow/react/dist/style.css'
21
21
  import { toCanvas } from 'html-to-image'
22
22
 
23
- import { AlertTriangle, Download, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
23
+ import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
24
24
  import { PaneLoader } from '../ui/PaneLoader'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
26
  import { useToast } from '../ui/Toast'
@@ -175,6 +175,10 @@ interface TopologyGraphProps {
175
175
  onClearNamespace?: () => void
176
176
  /** Serialized namespace filter — when this changes, reset groupLevels for fresh smart default */
177
177
  namespacesKey?: string
178
+ /** Node to pan/zoom the canvas to. Bump focusNonce to re-trigger for the same id. */
179
+ focusNodeId?: string
180
+ /** Increment to request a focus on focusNodeId (lets the same node be re-focused). */
181
+ focusNonce?: number
178
182
  }
179
183
 
180
184
  export function TopologyGraph({
@@ -191,6 +195,8 @@ export function TopologyGraph({
191
195
  namespaceBreadcrumb,
192
196
  onClearNamespace,
193
197
  namespacesKey = '',
198
+ focusNodeId,
199
+ focusNonce,
194
200
  }: TopologyGraphProps) {
195
201
  const isTrafficView = viewMode === 'traffic'
196
202
  const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
@@ -234,6 +240,11 @@ export function TopologyGraph({
234
240
  // After layout completes for a single-group change, stores the group ID so
235
241
  // ViewportController can fitView to it (with correct timing — after setNodes)
236
242
  const fitToGroupAfterLayoutRef = useRef<string | null>(null)
243
+ // Set by the bulk level controls (collapse/cards/expand all); fits the whole
244
+ // graph once the relayout lands. A flag (not a counter) so the fit is keyed
245
+ // to the post-relayout nodes update, not the click — the click fires before
246
+ // the async ELK relayout, which would frame the pre-change layout.
247
+ const fitAllAfterLayoutRef = useRef(false)
237
248
 
238
249
  // Reset group display levels when namespace filter changes (instant switching)
239
250
  const prevNamespacesKeyRef = useRef(namespacesKey)
@@ -247,6 +258,21 @@ export function TopologyGraph({
247
258
  }
248
259
  }, [namespacesKey])
249
260
 
261
+ // Changing grouping (By Namespace / By App / No Grouping) reorganizes the
262
+ // whole graph, so re-frame it. Skip when a namespace change drove it — that
263
+ // path already fits via the effect above (avoids a double fit).
264
+ const prevGroupingModeRef = useRef(groupingMode)
265
+ const prevNsKeyForGroupingRef = useRef(namespacesKey)
266
+ useEffect(() => {
267
+ const groupingChanged = groupingMode !== prevGroupingModeRef.current
268
+ const nsChanged = namespacesKey !== prevNsKeyForGroupingRef.current
269
+ prevGroupingModeRef.current = groupingMode
270
+ prevNsKeyForGroupingRef.current = namespacesKey
271
+ if (groupingChanged && !nsChanged) {
272
+ fitAllAfterLayoutRef.current = true
273
+ }
274
+ }, [groupingMode, namespacesKey])
275
+
250
276
  // Set display level for a single group
251
277
  const handleSetLevel = useCallback((groupId: string, level: GroupDisplayLevel) => {
252
278
  setGroupLevels(prev => {
@@ -267,7 +293,9 @@ export function TopologyGraph({
267
293
  }
268
294
  setGroupLevels(next)
269
295
  savedPositionsRef.current.clear()
270
- setFitViewCounter(c => c + 1)
296
+ // Re-frame the whole graph after the relayout — collapse/expand changes the
297
+ // content bounds enough that the old viewport no longer fits it.
298
+ fitAllAfterLayoutRef.current = true
271
299
  }, [groupingMode])
272
300
 
273
301
  // Expand pod group to show individual pods
@@ -444,6 +472,30 @@ export function TopologyGraph({
444
472
  if (topoNode) onNodeClick(topoNode)
445
473
  }, [topology, workingNodes, onNodeClick])
446
474
 
475
+ // Expand the group containing a searched-but-collapsed node, then fit the
476
+ // viewport to that group once the relayout lands. We fit to the GROUP (via
477
+ // the existing fitToGroupAfterLayoutRef path) rather than centering on the
478
+ // node directly: the node's absolute position isn't settled until ELK
479
+ // finishes the async relayout, so a per-node center races it and lands on
480
+ // stale coords. The node still carries data.selected, so it glows inside the
481
+ // framed group.
482
+ const expandGroupForNode = useCallback((nodeId: string) => {
483
+ if (groupingMode === 'none') return
484
+ const target = workingNodes.find(n => n.id === nodeId)
485
+ if (!target) return
486
+ const groupKey = getGroupKey(target, groupingMode)
487
+ if (!groupKey) return
488
+ const groupId = `group-${groupingMode}-${groupKey}`
489
+ fitToGroupAfterLayoutRef.current = groupId
490
+ savedPositionsRef.current.clear()
491
+ setGroupLevels(prev => {
492
+ if (prev.get(groupId) === 'topology') return prev
493
+ const next = new Map(prev)
494
+ next.set(groupId, 'topology')
495
+ return next
496
+ })
497
+ }, [groupingMode, workingNodes])
498
+
447
499
  // Structure key for change detection — includes groupLevels so chip↔cardGrid triggers relayout.
448
500
  //
449
501
  // Uses an order-independent fold of per-ID hashes (see foldHash) instead of
@@ -665,10 +717,17 @@ export function TopologyGraph({
665
717
  const updated = nds.map(node => {
666
718
  const shouldBeSelected = node.id === selectedNodeId
667
719
  const isCurrentlySelected = node.data?.selected ?? false
720
+ // Only act on the select/deselect transition. Don't touch zIndex
721
+ // otherwise — a blanket compare would fight the layout's group zIndex
722
+ // (-1) every render and loop (React #185). Groups are never selectable,
723
+ // so they never enter here and keep their layout zIndex.
668
724
  if (shouldBeSelected !== isCurrentlySelected) {
669
725
  changed = true
670
726
  return {
671
727
  ...node,
728
+ // Lift the selected leaf above its siblings (default z 0) so its
729
+ // outline+glow isn't painted over; restore default on deselect.
730
+ zIndex: shouldBeSelected ? 10 : undefined,
672
731
  data: {
673
732
  ...node.data,
674
733
  selected: shouldBeSelected,
@@ -679,7 +738,10 @@ export function TopologyGraph({
679
738
  })
680
739
  return changed ? updated : nds // Return same array if nothing changed
681
740
  })
682
- }, [selectedNodeId, setNodes])
741
+ // `nodes` is a dep so selection re-applies after a relayout introduces the
742
+ // target node (e.g. search expands a collapsed group). Safe from loops: the
743
+ // functional update returns the same array ref when nothing changed.
744
+ }, [selectedNodeId, setNodes, nodes])
683
745
 
684
746
  if (!topology) {
685
747
  return <PaneLoader label="Loading topology…" className="absolute inset-0" />
@@ -831,55 +893,58 @@ export function TopologyGraph({
831
893
  onlyRenderVisibleElements={!isExporting}
832
894
  >
833
895
  <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
834
- <Controls
835
- className="bg-theme-surface border border-theme-border rounded-lg"
836
- showInteractive={false}
837
- showZoom={false}
838
- showFitView={false}
839
- >
840
- <CustomControlButtons
841
- showExportButton={showExportButton}
842
- paused={paused}
843
- onTogglePause={onTogglePause}
844
- onExportingChange={setIsExporting}
845
- />
846
- </Controls>
847
- {/* Level controls — separate group matching per-node icons */}
848
- {groupingMode !== 'none' && (
849
- <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 }}>
850
- {!hideGroupHeader && (
851
- <Tooltip content="Collapse all" delay={100} position="right">
896
+ {/* Bottom-left controls. Two distinct pills with a gap rather than one
897
+ long strip: a viewport group (zoom/fit/export/pause) and, only when
898
+ grouping is active, a level-of-detail group. */}
899
+ <Panel position="bottom-left" className="flex flex-col items-start gap-2">
900
+ {groupingMode !== 'none' && (
901
+ <div className="react-flow__controls overflow-hidden" style={{ position: 'static', margin: 0 }}>
902
+ {!hideGroupHeader && (
903
+ <Tooltip content="Collapse all groups" delay={100} position="right">
904
+ <button
905
+ className="react-flow__controls-button"
906
+ onClick={() => setAllLevels('chip')}
907
+ >
908
+ <ChevronsDownUp className="w-3.5 h-3.5" />
909
+ </button>
910
+ </Tooltip>
911
+ )}
912
+ <Tooltip content="All workload cards" delay={100} position="right">
852
913
  <button
853
914
  className="react-flow__controls-button"
854
- onClick={() => setAllLevels('chip')}
915
+ onClick={() => setAllLevels('cardGrid')}
855
916
  >
856
- <Minus className="w-3.5 h-3.5" />
917
+ <LayoutGrid className="w-3.5 h-3.5" />
857
918
  </button>
858
919
  </Tooltip>
859
- )}
860
- <Tooltip content="All workload cards" delay={100} position="right">
861
- <button
862
- className="react-flow__controls-button"
863
- onClick={() => setAllLevels('cardGrid')}
864
- >
865
- <LayoutGrid className="w-3.5 h-3.5" />
866
- </button>
867
- </Tooltip>
868
- <Tooltip content="Expand all" delay={100} position="right">
869
- <button
870
- className="react-flow__controls-button"
871
- onClick={() => setAllLevels('topology')}
872
- >
873
- <Workflow className="w-3.5 h-3.5" />
874
- </button>
875
- </Tooltip>
920
+ <Tooltip content="Expand all groups" delay={100} position="right">
921
+ <button
922
+ className="react-flow__controls-button"
923
+ onClick={() => setAllLevels('topology')}
924
+ >
925
+ <ChevronsUpDown className="w-3.5 h-3.5" />
926
+ </button>
927
+ </Tooltip>
928
+ </div>
929
+ )}
930
+ <div className="react-flow__controls overflow-hidden" style={{ position: 'static', margin: 0 }}>
931
+ <CustomControlButtons
932
+ showExportButton={showExportButton}
933
+ paused={paused}
934
+ onTogglePause={onTogglePause}
935
+ onExportingChange={setIsExporting}
936
+ />
876
937
  </div>
877
- )}
938
+ </Panel>
878
939
  <ViewportController
879
940
  viewMode={viewMode}
880
941
  layoutRetryCount={layoutRetryCount}
881
942
  fitViewCounter={fitViewCounter}
882
943
  fitToGroupAfterLayoutRef={fitToGroupAfterLayoutRef}
944
+ fitAllAfterLayoutRef={fitAllAfterLayoutRef}
945
+ focusNodeId={focusNodeId}
946
+ focusNonce={focusNonce}
947
+ onRequestExpandForNode={expandGroupForNode}
883
948
  />
884
949
  </ReactFlow>
885
950
  </ReactFlowProvider>
@@ -1213,17 +1278,37 @@ function ViewportController({
1213
1278
  layoutRetryCount,
1214
1279
  fitViewCounter = 0,
1215
1280
  fitToGroupAfterLayoutRef,
1281
+ fitAllAfterLayoutRef,
1282
+ focusNodeId,
1283
+ focusNonce = 0,
1284
+ onRequestExpandForNode,
1216
1285
  }: {
1217
1286
  viewMode: string
1218
1287
  layoutRetryCount: number
1219
1288
  fitViewCounter?: number
1220
1289
  fitToGroupAfterLayoutRef?: React.MutableRefObject<string | null>
1290
+ fitAllAfterLayoutRef?: React.MutableRefObject<boolean>
1291
+ focusNodeId?: string
1292
+ focusNonce?: number
1293
+ onRequestExpandForNode?: (nodeId: string) => void
1221
1294
  }) {
1222
- const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
1295
+ const { fitView, zoomIn, zoomOut, setViewport, getViewport, getInternalNode, setCenter } = useReactFlow()
1223
1296
  const nodes = useNodes() // Reactive hook to watch node changes
1297
+
1298
+ // Pan/zoom the viewport so a single node is centered.
1299
+ const centerOnNode = useCallback((nodeId: string): boolean => {
1300
+ const node = getInternalNode(nodeId)
1301
+ if (!node) return false
1302
+ const { x, y } = node.internals.positionAbsolute
1303
+ const w = node.measured?.width ?? 0
1304
+ const h = node.measured?.height ?? 0
1305
+ setCenter(x + w / 2, y + h / 2, { zoom: 1.2, duration: VIEWPORT_ANIMATION_DURATION })
1306
+ return true
1307
+ }, [getInternalNode, setCenter])
1224
1308
  const prevViewModeRef = useRef<string>(viewMode)
1225
1309
  const prevRetryCountRef = useRef(layoutRetryCount)
1226
1310
  const prevFitViewCounterRef = useRef(fitViewCounter)
1311
+ const prevFocusNonceRef = useRef(focusNonce)
1227
1312
  const prevNodesLengthRef = useRef(0)
1228
1313
 
1229
1314
  // Topology keyboard shortcuts
@@ -1320,25 +1405,56 @@ function ViewportController({
1320
1405
  }
1321
1406
  }, [viewMode, layoutRetryCount, fitViewCounter, nodes.length, fitView])
1322
1407
 
1323
- // After a single-group expand/collapse, fit the viewport to that group.
1324
- // This effect fires when nodes update (triggered by setNodes after async layout).
1408
+ // Pan/zoom to a single searched node. Gated on focusNonce so the same
1409
+ // node can be re-focused, and so this never fires on background updates.
1410
+ // If the node is already on the canvas, center now. If it isn't (it's
1411
+ // collapsed inside a group chip), ask the parent to expand that group
1412
+ // (onRequestExpandForNode); the fit-to-group effect then frames the group
1413
+ // once the relayout lands, and the node glows inside it via data.selected.
1414
+ useEffect(() => {
1415
+ if (focusNonce === prevFocusNonceRef.current) return
1416
+ prevFocusNonceRef.current = focusNonce
1417
+ if (!focusNodeId) return
1418
+ if (!centerOnNode(focusNodeId)) {
1419
+ onRequestExpandForNode?.(focusNodeId)
1420
+ }
1421
+ }, [focusNonce, focusNodeId, centerOnNode, onRequestExpandForNode])
1422
+
1423
+ // After a single-group expand/collapse, fit the viewport to that group, once
1424
+ // the relayout has SETTLED. Debounced (reschedules on each nodes update) so
1425
+ // it frames the final positions, not an intermediate layout — and so the
1426
+ // group's nodes are measured when fitView reads their bounds.
1325
1427
  useEffect(() => {
1326
1428
  if (!fitToGroupAfterLayoutRef?.current) return
1327
1429
  const targetGroupId = fitToGroupAfterLayoutRef.current
1328
- fitToGroupAfterLayoutRef.current = null
1329
- // Find the group and its children to fit to
1330
- const targetNodes = nodes.filter(n => n.id === targetGroupId || n.parentId === targetGroupId)
1331
- if (targetNodes.length > 0) {
1332
- setTimeout(() => {
1430
+ const id = setTimeout(() => {
1431
+ fitToGroupAfterLayoutRef.current = null
1432
+ const targetNodes = nodes.filter(n => n.id === targetGroupId || n.parentId === targetGroupId)
1433
+ if (targetNodes.length > 0) {
1333
1434
  fitView({
1334
1435
  nodes: targetNodes.map(n => ({ id: n.id })),
1335
1436
  padding: 0.2,
1336
1437
  duration: VIEWPORT_ANIMATION_DURATION,
1337
1438
  maxZoom: 1.5,
1338
1439
  })
1339
- }, 10)
1340
- }
1440
+ }
1441
+ }, 250)
1442
+ return () => clearTimeout(id)
1341
1443
  }, [nodes, fitView, fitToGroupAfterLayoutRef])
1342
1444
 
1445
+ // After a bulk level change (collapse/cards/expand all), fit the whole graph
1446
+ // once the relayout has SETTLED. The expand relayout lands in phases, so a
1447
+ // fit on the first nodes update frames an intermediate (compact) layout.
1448
+ // Debounce instead: each nodes update reschedules, so the fit fires only
1449
+ // after nodes stop changing, then clears the flag.
1450
+ useEffect(() => {
1451
+ if (!fitAllAfterLayoutRef?.current) return
1452
+ const id = setTimeout(() => {
1453
+ fitAllAfterLayoutRef.current = false
1454
+ fitView({ padding: 0.15, duration: VIEWPORT_ANIMATION_DURATION })
1455
+ }, 250)
1456
+ return () => clearTimeout(id)
1457
+ }, [nodes, fitView, fitAllAfterLayoutRef])
1458
+
1343
1459
  return null
1344
1460
  }
@@ -15,7 +15,7 @@ interface TopologySearchProps {
15
15
  * only shows CAPI kinds), it should still pass the full topology
16
16
  * here so the empty-state can tell users "your query matches X
17
17
  * resources hidden by the current view" rather than the
18
- * misleading "No resources found." (SKY-828 bug 45)
18
+ * misleading "No resources found."
19
19
  */
20
20
  allNodes?: TopologyNode[]
21
21
  /**
@@ -23,6 +23,12 @@ interface TopologySearchProps {
23
23
  * empty-state hint above. Defaults to "current view".
24
24
  */
25
25
  viewModeLabel?: string
26
+ /**
27
+ * Position utilities for the trigger button (default "top-4 left-4").
28
+ * The host overrides this to avoid colliding with other canvas overlays
29
+ * (e.g. stacking below the namespace breadcrumb).
30
+ */
31
+ triggerClassName?: string
26
32
  }
27
33
 
28
34
  // Icon mapping for different resource kinds
@@ -64,7 +70,7 @@ function getKindColor(kind: string): string {
64
70
  }
65
71
  }
66
72
 
67
- export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel }: TopologySearchProps) {
73
+ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel, triggerClassName }: TopologySearchProps) {
68
74
  const [isOpen, setIsOpen] = useState(false)
69
75
  const [query, setQuery] = useState('')
70
76
  const [selectedIndex, setSelectedIndex] = useState(0)
@@ -96,9 +102,9 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
96
102
  // Count of matches in the unfiltered topology that the current
97
103
  // view-mode filter is hiding. We only compute this when there's
98
104
  // a query AND the visible set returned zero results, so the cost
99
- // is bounded to actual no-result interactions.
100
- // (SKY-828 bug 45: Fleet view hides Pods, so searching pod names
101
- // returned "No resources found" — misleading.)
105
+ // is bounded to actual no-result interactions. (Fleet view hides
106
+ // Pods, so searching a pod name there would otherwise just say
107
+ // "No resources found".)
102
108
  const hiddenMatchCount = useMemo(() => {
103
109
  if (!query.trim() || filteredNodes.length > 0 || !allNodes) return 0
104
110
  const lowerQuery = query.toLowerCase()
@@ -193,12 +199,15 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
193
199
  {/* Search trigger button */}
194
200
  <button
195
201
  onClick={handleOpen}
196
- className="absolute top-4 left-4 z-10 flex items-center gap-2 px-3 py-2 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:border-theme-border-light transition-colors"
202
+ className={clsx(
203
+ 'absolute z-10 flex items-center gap-2 px-3 py-2 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:border-theme-border-light transition-colors',
204
+ triggerClassName ?? 'top-4 left-4'
205
+ )}
197
206
  >
198
207
  <Search className="w-4 h-4" />
199
208
  <span className="text-sm">Search</span>
200
- <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs bg-theme-elevated rounded border border-theme-border-light">
201
- <span className="text-[10px]">⌘</span>K
209
+ <kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-xs bg-theme-elevated rounded border border-theme-border-light">
210
+ /
202
211
  </kbd>
203
212
  </button>
204
213
 
@@ -40,7 +40,7 @@ function makeNode(id: string, kind: string, name: string, namespace = 'default')
40
40
  } as unknown as TopologyNode
41
41
  }
42
42
 
43
- describe('TopologySearch hidden-match counting (SKY-828 bug 45)', () => {
43
+ describe('TopologySearch hidden-match counting', () => {
44
44
  // The bug: in Fleet topology view (which only shows CAPI kinds),
45
45
  // searching pod names returned "No resources found" — misleading
46
46
  // when the cluster has 338 pods. We compute a hidden-match count
@@ -18,6 +18,24 @@
18
18
  box-shadow: var(--topology-node-shadow);
19
19
  }
20
20
 
21
+ /*
22
+ * Selected node (its drawer/detail is open). An outline (not box-shadow) draws
23
+ * the crisp "outside border" with a gap and isn't clipped by the card's
24
+ * overflow-hidden; the box-shadow keeps the base elevation and adds a soft
25
+ * brand glow so the node lifts off the dark canvas. color-mix keeps the glow
26
+ * on the theme token instead of a hardcoded rgba.
27
+ */
28
+ .topology-node-selected {
29
+ /* skyhook-400 (#6A9FE0) — hardcoded like the rest of this file's palette
30
+ because @theme inline doesn't expose the brand vars at runtime. */
31
+ outline: 3px solid #6a9fe0;
32
+ outline-offset: 2px;
33
+ box-shadow:
34
+ var(--topology-node-shadow),
35
+ 0 0 0 1px rgba(106, 159, 224, 0.85),
36
+ 0 0 28px 5px rgba(106, 159, 224, 0.7);
37
+ }
38
+
21
39
  /* Status bar via pseudo-element (saves one DOM element per node) */
22
40
  .topology-node-status-bar::before {
23
41
  content: '';