@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.
- package/package.json +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsTableView.tsx +1 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +2 -19
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/replica-scalers.ts +9 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
|
@@ -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:
|
|
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:
|
|
138
|
-
Gateway: { width: 300, height:
|
|
139
|
-
HTTPRoute: { width: 300, height:
|
|
140
|
-
GRPCRoute: { width: 300, height:
|
|
141
|
-
TCPRoute: { width: 300, height:
|
|
142
|
-
TLSRoute: { width: 300, height:
|
|
143
|
-
Service: { width: 260, height:
|
|
144
|
-
Deployment: { width: 280, height:
|
|
145
|
-
Rollout: { width: 280, height:
|
|
146
|
-
Application: { width: 300, height:
|
|
147
|
-
Kustomization: { width: 300, height:
|
|
148
|
-
HelmRelease: { width: 280, height:
|
|
149
|
-
GitRepository: { width: 280, height:
|
|
150
|
-
DaemonSet: { width: 280, height:
|
|
151
|
-
StatefulSet: { width: 280, height:
|
|
152
|
-
ReplicaSet: { width: 280, height:
|
|
153
|
-
Pod: { width: 300, height:
|
|
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:
|
|
156
|
-
Secret: { width: 180, height:
|
|
157
|
-
HorizontalPodAutoscaler: { width: 280, height:
|
|
158
|
-
Job: { width: 180, height:
|
|
159
|
-
CronJob: { width: 200, height:
|
|
160
|
-
PersistentVolumeClaim: { width: 200, height:
|
|
161
|
-
Namespace: { width: 180, height:
|
|
162
|
-
Node: { width: 280, height:
|
|
163
|
-
NodePool: { width: 260, height:
|
|
164
|
-
NodeClaim: { width: 260, height:
|
|
165
|
-
NodeClass: { width: 260, height:
|
|
166
|
-
KnativeService: { width: 280, height:
|
|
167
|
-
KnativeConfiguration: { width: 280, height:
|
|
168
|
-
KnativeRevision: { width: 280, height:
|
|
169
|
-
KnativeRoute: { width: 280, height:
|
|
170
|
-
Broker: { width: 280, height:
|
|
171
|
-
Channel: { width: 280, height:
|
|
172
|
-
Trigger: { width: 280, height:
|
|
173
|
-
PingSource: { width: 280, height:
|
|
174
|
-
ApiServerSource: { width: 280, height:
|
|
175
|
-
ContainerSource: { width: 280, height:
|
|
176
|
-
SinkBinding: { width: 280, height:
|
|
177
|
-
IngressRoute: { width: 280, height:
|
|
178
|
-
IngressRouteTCP: { width: 280, height:
|
|
179
|
-
IngressRouteUDP: { width: 280, height:
|
|
180
|
-
Middleware: { width: 280, height:
|
|
181
|
-
MiddlewareTCP: { width: 280, height:
|
|
182
|
-
TraefikService: { width: 280, height:
|
|
183
|
-
ServersTransport: { width: 280, height:
|
|
184
|
-
ServersTransportTCP: { width: 300, height:
|
|
185
|
-
TLSOption: { width: 280, height:
|
|
186
|
-
TLSStore: { width: 280, height:
|
|
187
|
-
HTTPProxy: { width: 280, height:
|
|
188
|
-
CAPICluster: { width: 280, height:
|
|
189
|
-
MachineDeployment: { width: 300, height:
|
|
190
|
-
MachineSet: { width: 280, height:
|
|
191
|
-
Machine: { width: 260, height:
|
|
192
|
-
MachinePool: { width: 280, height:
|
|
193
|
-
KubeadmControlPlane: { width: 300, height:
|
|
194
|
-
ClusterClass: { width: 280, height:
|
|
195
|
-
MachineHealthCheck: { width: 300, height:
|
|
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 && '
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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('
|
|
915
|
+
onClick={() => setAllLevels('cardGrid')}
|
|
855
916
|
>
|
|
856
|
-
<
|
|
917
|
+
<LayoutGrid className="w-3.5 h-3.5" />
|
|
857
918
|
</button>
|
|
858
919
|
</Tooltip>
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
</
|
|
867
|
-
</
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
//
|
|
1324
|
-
//
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
}
|
|
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."
|
|
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
|
-
//
|
|
101
|
-
//
|
|
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=
|
|
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
|
|
201
|
-
|
|
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
|
|
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: '';
|