@skyhook-io/k8s-ui 1.7.12 → 1.7.14
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 +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/audit/index.ts +1 -1
- package/src/components/checks/ChecksView.tsx +269 -142
- package/src/components/checks/index.ts +14 -1
- package/src/components/issues/IssuesView.tsx +56 -17
- package/src/components/issues/index.ts +2 -2
- package/src/components/issues/issues.test.ts +7 -0
- package/src/components/issues/types.ts +15 -5
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +6 -6
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -2
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +8 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +170 -36
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
|
@@ -8,6 +8,9 @@ import { clsx } from 'clsx'
|
|
|
8
8
|
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
|
|
9
9
|
import { displayKind } from '../../types'
|
|
10
10
|
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
11
|
+
import { workloadHue } from '../../utils/workload-colors'
|
|
12
|
+
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
13
|
+
import { midTruncate } from '../../utils/format'
|
|
11
14
|
import { Tooltip } from '../ui/Tooltip'
|
|
12
15
|
|
|
13
16
|
// Get actionable tooltip content for health issues
|
|
@@ -359,6 +362,8 @@ interface K8sResourceNodeProps {
|
|
|
359
362
|
status: HealthStatus
|
|
360
363
|
nodeData: Record<string, unknown>
|
|
361
364
|
selected?: boolean
|
|
365
|
+
/** Hover-focus: when a sibling workload is focused, non-members dim. */
|
|
366
|
+
dimmed?: boolean
|
|
362
367
|
onExpand?: (nodeId: string) => void
|
|
363
368
|
onCollapse?: (nodeId: string) => void
|
|
364
369
|
isExpanded?: boolean
|
|
@@ -370,7 +375,14 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
370
375
|
data,
|
|
371
376
|
id,
|
|
372
377
|
}: K8sResourceNodeProps) {
|
|
373
|
-
const { kind, name, status, nodeData, selected, onExpand, onCollapse, isExpanded } = data
|
|
378
|
+
const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
|
|
379
|
+
// Workload tint (application graph): a node owned by exactly one workload
|
|
380
|
+
// carries that workload's hue. Only on healthy/unknown cards — degraded/
|
|
381
|
+
// unhealthy already own the card background for health, which must win.
|
|
382
|
+
const { ownerColorIndex } = ownershipOf(nodeData)
|
|
383
|
+
const hue = ownerColorIndex !== null && (status === 'healthy' || status === 'unknown')
|
|
384
|
+
? workloadHue(ownerColorIndex)
|
|
385
|
+
: undefined
|
|
374
386
|
const subtitle = getSubtitle(kind, nodeData)
|
|
375
387
|
const isInternet = kind === 'Internet'
|
|
376
388
|
const isPodGroup = kind === 'PodGroup'
|
|
@@ -423,10 +435,11 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
423
435
|
|
|
424
436
|
<div
|
|
425
437
|
className={clsx(
|
|
426
|
-
'relative rounded-lg overflow-hidden',
|
|
438
|
+
'relative rounded-lg overflow-hidden transition-opacity duration-150',
|
|
427
439
|
'bg-theme-surface topology-node-card',
|
|
428
440
|
selected && 'topology-node-selected',
|
|
429
|
-
|
|
441
|
+
!selected && dimmed && 'opacity-30',
|
|
442
|
+
isSmallNode && !dimmed && 'opacity-90',
|
|
430
443
|
// Status bar via CSS pseudo-element (defined in index.css)
|
|
431
444
|
(status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
|
|
432
445
|
status === 'healthy' && 'topology-node-status-healthy',
|
|
@@ -437,6 +450,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
437
450
|
...getStatusStyle(status),
|
|
438
451
|
}}
|
|
439
452
|
>
|
|
453
|
+
{/* Workload tint — layered over the surface, inset past the 4px status bar */}
|
|
454
|
+
{hue && (
|
|
455
|
+
<div
|
|
456
|
+
className="pointer-events-none absolute inset-y-0 right-0 rounded-r-lg"
|
|
457
|
+
style={{ left: 4, background: hue.wash }}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
440
460
|
|
|
441
461
|
{/* Content */}
|
|
442
462
|
<div className={clsx(
|
|
@@ -496,9 +516,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
496
516
|
</div>
|
|
497
517
|
</div>
|
|
498
518
|
|
|
499
|
-
{/* Name
|
|
519
|
+
{/* Name — middle-ellipsis so the differentiating suffix survives
|
|
520
|
+
(a column of pods sharing a long prefix must stay tellable apart). */}
|
|
500
521
|
<div className="text-sm font-medium text-theme-text-primary truncate pr-1">
|
|
501
|
-
{name}
|
|
522
|
+
{midTruncate(name, 34)}
|
|
502
523
|
</div>
|
|
503
524
|
|
|
504
525
|
{/* Subtitle */}
|
|
@@ -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, ChevronsDownUp, ChevronsUpDown, Download, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
|
|
23
|
+
import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, 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'
|
|
@@ -28,6 +28,8 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
|
28
28
|
|
|
29
29
|
import { K8sResourceNode } from './K8sResourceNode'
|
|
30
30
|
import { GroupNode } from './GroupNode'
|
|
31
|
+
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
32
|
+
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
31
33
|
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
|
|
32
34
|
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
33
35
|
import { pluralize } from '../../utils/pluralize'
|
|
@@ -51,6 +53,15 @@ function getEdgeColor(type: string, isTrafficView: boolean): string {
|
|
|
51
53
|
return EDGE_COLORS[type as keyof typeof EDGE_COLORS] || '#64748b'
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// Human-readable edge legend for the resources view (traffic view is all-green).
|
|
57
|
+
const EDGE_LEGEND: { label: string; color: string }[] = [
|
|
58
|
+
{ label: 'owns', color: EDGE_COLORS['manages'] },
|
|
59
|
+
{ label: 'exposes', color: EDGE_COLORS['exposes'] },
|
|
60
|
+
{ label: 'configures', color: EDGE_COLORS['configures'] },
|
|
61
|
+
{ label: 'scales', color: EDGE_COLORS['uses'] },
|
|
62
|
+
{ label: 'routes to', color: EDGE_COLORS['routes-to'] },
|
|
63
|
+
]
|
|
64
|
+
|
|
54
65
|
// Memoized edge style cache to avoid creating new objects on every render
|
|
55
66
|
const edgeStyleCache = new Map<string, React.CSSProperties>()
|
|
56
67
|
|
|
@@ -179,6 +190,13 @@ interface TopologyGraphProps {
|
|
|
179
190
|
focusNodeId?: string
|
|
180
191
|
/** Increment to request a focus on focusNodeId (lets the same node be re-focused). */
|
|
181
192
|
focusNonce?: number
|
|
193
|
+
/** Application graph hover-focus (see WorkloadFocus): when set, nodes outside
|
|
194
|
+
* the focused workload's neighborhood dim. Cheap node-data toggle — never
|
|
195
|
+
* re-layouts. */
|
|
196
|
+
focusedOwnerId?: WorkloadFocus
|
|
197
|
+
/** Hover a node → reports its TopologyNode (null on leave). Drives the rail's
|
|
198
|
+
* reciprocal highlight. */
|
|
199
|
+
onNodeHover?: (node: TopologyNode | null) => void
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
export function TopologyGraph({
|
|
@@ -197,6 +215,8 @@ export function TopologyGraph({
|
|
|
197
215
|
namespacesKey = '',
|
|
198
216
|
focusNodeId,
|
|
199
217
|
focusNonce,
|
|
218
|
+
focusedOwnerId,
|
|
219
|
+
onNodeHover,
|
|
200
220
|
}: TopologyGraphProps) {
|
|
201
221
|
const isTrafficView = viewMode === 'traffic'
|
|
202
222
|
const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
|
|
@@ -227,6 +247,7 @@ export function TopologyGraph({
|
|
|
227
247
|
const [layoutRetryCount, setLayoutRetryCount] = useState(0)
|
|
228
248
|
const [fitViewCounter, setFitViewCounter] = useState(0)
|
|
229
249
|
const [isExporting, setIsExporting] = useState(false)
|
|
250
|
+
const [showLegend, setShowLegend] = useState(false)
|
|
230
251
|
const prevStructureRef = useRef<string>('')
|
|
231
252
|
const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
|
|
232
253
|
// Saved node positions for preservation across topology updates.
|
|
@@ -298,18 +319,26 @@ export function TopologyGraph({
|
|
|
298
319
|
fitAllAfterLayoutRef.current = true
|
|
299
320
|
}, [groupingMode])
|
|
300
321
|
|
|
301
|
-
// Expand pod group to show individual pods
|
|
322
|
+
// Expand pod group to show individual pods. Clear saved positions so ELK
|
|
323
|
+
// re-lays out the whole graph from scratch — otherwise existing nodes snap
|
|
324
|
+
// back to their saved spots while the newly-added pods get fresh ELK
|
|
325
|
+
// coordinates, and the two coordinate spaces collide (overlapping nodes).
|
|
326
|
+
// Re-fit afterwards since the expanded pods enlarge the content bounds.
|
|
302
327
|
const handleExpandPodGroup = useCallback((podGroupId: string) => {
|
|
303
328
|
setExpandedPodGroups(prev => new Set(prev).add(podGroupId))
|
|
329
|
+
savedPositionsRef.current.clear()
|
|
330
|
+
fitAllAfterLayoutRef.current = true
|
|
304
331
|
}, [])
|
|
305
332
|
|
|
306
|
-
// Collapse pod group back
|
|
333
|
+
// Collapse pod group back — same full relayout + re-fit (the graph shrinks).
|
|
307
334
|
const handleCollapsePodGroup = useCallback((podGroupId: string) => {
|
|
308
335
|
setExpandedPodGroups(prev => {
|
|
309
336
|
const next = new Set(prev)
|
|
310
337
|
next.delete(podGroupId)
|
|
311
338
|
return next
|
|
312
339
|
})
|
|
340
|
+
savedPositionsRef.current.clear()
|
|
341
|
+
fitAllAfterLayoutRef.current = true
|
|
313
342
|
}, [])
|
|
314
343
|
|
|
315
344
|
// Expand PodGroup to individual pods
|
|
@@ -348,6 +377,7 @@ export function TopologyGraph({
|
|
|
348
377
|
name: pod.name,
|
|
349
378
|
status: pod.phase === 'Running' ? 'healthy' : pod.phase === 'Pending' ? 'degraded' : 'unhealthy',
|
|
350
379
|
data: {
|
|
380
|
+
...podGroupNode.data,
|
|
351
381
|
namespace: pod.namespace,
|
|
352
382
|
phase: pod.phase,
|
|
353
383
|
restarts: pod.restarts,
|
|
@@ -625,6 +655,12 @@ export function TopologyGraph({
|
|
|
625
655
|
return saved ? { ...node, position: saved } : node
|
|
626
656
|
})
|
|
627
657
|
|
|
658
|
+
// The ReactFlow fitView prop fires against the pre-layout canvas; once
|
|
659
|
+
// the first ELK layout lands the content can sit off-center. Re-frame it.
|
|
660
|
+
if (isInitialLayout) {
|
|
661
|
+
fitAllAfterLayoutRef.current = true
|
|
662
|
+
}
|
|
663
|
+
|
|
628
664
|
// Update saved positions: add/overwrite with positions from this layout run.
|
|
629
665
|
// Remove stale entries for nodes no longer in the topology.
|
|
630
666
|
const currentIds = new Set(positionedNodes.map(n => n.id))
|
|
@@ -710,6 +746,17 @@ export function TopologyGraph({
|
|
|
710
746
|
[topology, workingNodes, onNodeClick]
|
|
711
747
|
)
|
|
712
748
|
|
|
749
|
+
const handleNodeMouseEnter = useCallback(
|
|
750
|
+
(_e: React.MouseEvent, node: Node) => {
|
|
751
|
+
if (!onNodeHover || node.type === 'group') return
|
|
752
|
+
const topologyNode =
|
|
753
|
+
topology?.nodes.find(n => n.id === node.id) ?? workingNodes.find(n => n.id === node.id)
|
|
754
|
+
if (topologyNode) onNodeHover(topologyNode)
|
|
755
|
+
},
|
|
756
|
+
[topology, workingNodes, onNodeHover]
|
|
757
|
+
)
|
|
758
|
+
const handleNodeMouseLeave = useCallback(() => onNodeHover?.(null), [onNodeHover])
|
|
759
|
+
|
|
713
760
|
// Update selected state - only update nodes that actually changed
|
|
714
761
|
useEffect(() => {
|
|
715
762
|
setNodes(nds => {
|
|
@@ -743,6 +790,32 @@ export function TopologyGraph({
|
|
|
743
790
|
// functional update returns the same array ref when nothing changed.
|
|
744
791
|
}, [selectedNodeId, setNodes, nodes])
|
|
745
792
|
|
|
793
|
+
// Hover-focus dim (application graph): when a workload is focused, dim every
|
|
794
|
+
// resource node not owned by it. A pure data toggle on the existing nodes —
|
|
795
|
+
// positions are untouched, so it never re-runs the (expensive) ELK layout.
|
|
796
|
+
useEffect(() => {
|
|
797
|
+
setNodes(nds => {
|
|
798
|
+
let changed = false
|
|
799
|
+
const updated = nds.map(node => {
|
|
800
|
+
if (node.type === 'group') return node
|
|
801
|
+
const stamp = ownershipOf(node.data?.nodeData as Record<string, unknown> | undefined)
|
|
802
|
+
// A focused workload lights its whole neighborhood (focusWorkloadIds);
|
|
803
|
+
// the "Shared / unscoped" focus lights every neutral node.
|
|
804
|
+
const inFocus =
|
|
805
|
+
focusedOwnerId == null
|
|
806
|
+
? true
|
|
807
|
+
: focusedOwnerId === NEUTRAL_OWNER
|
|
808
|
+
? stamp.ownerWorkloadId == null
|
|
809
|
+
: stamp.focusWorkloadIds.includes(focusedOwnerId)
|
|
810
|
+
const shouldDim = focusedOwnerId != null && !inFocus
|
|
811
|
+
if (!!node.data?.dimmed === shouldDim) return node
|
|
812
|
+
changed = true
|
|
813
|
+
return { ...node, data: { ...node.data, dimmed: shouldDim } }
|
|
814
|
+
})
|
|
815
|
+
return changed ? updated : nds
|
|
816
|
+
})
|
|
817
|
+
}, [focusedOwnerId, setNodes, nodes])
|
|
818
|
+
|
|
746
819
|
if (!topology) {
|
|
747
820
|
return <PaneLoader label="Loading topology…" className="absolute inset-0" />
|
|
748
821
|
}
|
|
@@ -884,6 +957,8 @@ export function TopologyGraph({
|
|
|
884
957
|
onNodesChange={onNodesChange}
|
|
885
958
|
onEdgesChange={onEdgesChange}
|
|
886
959
|
onNodeClick={handleNodeClick}
|
|
960
|
+
onNodeMouseEnter={handleNodeMouseEnter}
|
|
961
|
+
onNodeMouseLeave={handleNodeMouseLeave}
|
|
887
962
|
nodeTypes={nodeTypes}
|
|
888
963
|
fitView
|
|
889
964
|
fitViewOptions={{ padding: 0.2 }}
|
|
@@ -935,6 +1010,30 @@ export function TopologyGraph({
|
|
|
935
1010
|
onExportingChange={setIsExporting}
|
|
936
1011
|
/>
|
|
937
1012
|
</div>
|
|
1013
|
+
{!isTrafficView && (
|
|
1014
|
+
<>
|
|
1015
|
+
{showLegend && (
|
|
1016
|
+
<div className="rounded-md border border-theme-border bg-theme-surface/95 px-3 py-2 shadow-theme-md backdrop-blur">
|
|
1017
|
+
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Edge colors</div>
|
|
1018
|
+
<div className="flex flex-col gap-1">
|
|
1019
|
+
{EDGE_LEGEND.map((e) => (
|
|
1020
|
+
<div key={e.label} className="flex items-center gap-2 text-[11px] text-theme-text-secondary">
|
|
1021
|
+
<span className="inline-block h-0.5 w-5 rounded-full" style={{ background: e.color }} />
|
|
1022
|
+
{e.label}
|
|
1023
|
+
</div>
|
|
1024
|
+
))}
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
)}
|
|
1028
|
+
<div className="react-flow__controls overflow-hidden" style={{ position: 'static', margin: 0 }}>
|
|
1029
|
+
<Tooltip content="Edge color legend" delay={100} position="right">
|
|
1030
|
+
<button className="react-flow__controls-button" onClick={() => setShowLegend((v) => !v)}>
|
|
1031
|
+
<Info className="w-3.5 h-3.5" />
|
|
1032
|
+
</button>
|
|
1033
|
+
</Tooltip>
|
|
1034
|
+
</div>
|
|
1035
|
+
</>
|
|
1036
|
+
)}
|
|
938
1037
|
</Panel>
|
|
939
1038
|
<ViewportController
|
|
940
1039
|
viewMode={viewMode}
|
|
@@ -109,6 +109,17 @@ const pendingRequests = new Map<number, {
|
|
|
109
109
|
reject: (error: Error) => void
|
|
110
110
|
}>()
|
|
111
111
|
|
|
112
|
+
const WORKER_UNAVAILABLE = 'Worker unavailable'
|
|
113
|
+
|
|
114
|
+
function rejectPendingWorkerRequests() {
|
|
115
|
+
for (const [, pending] of pendingRequests) {
|
|
116
|
+
pending.reject(new Error(WORKER_UNAVAILABLE))
|
|
117
|
+
}
|
|
118
|
+
pendingRequests.clear()
|
|
119
|
+
layoutWorker?.terminate()
|
|
120
|
+
layoutWorker = null
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
function getOrCreateWorker(): Worker {
|
|
113
124
|
if (!layoutWorker) {
|
|
114
125
|
layoutWorker = new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' })
|
|
@@ -126,10 +137,7 @@ function getOrCreateWorker(): Worker {
|
|
|
126
137
|
}
|
|
127
138
|
layoutWorker.onerror = (e) => {
|
|
128
139
|
console.error('[TopologyLayout] Worker error:', e)
|
|
129
|
-
|
|
130
|
-
pending.reject(new Error('Worker error'))
|
|
131
|
-
}
|
|
132
|
-
pendingRequests.clear()
|
|
140
|
+
rejectPendingWorkerRequests()
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
return layoutWorker
|
|
@@ -142,10 +150,16 @@ function runLayoutViaWorker(
|
|
|
142
150
|
padding: typeof GROUP_PADDING
|
|
143
151
|
): Promise<LayoutResult> {
|
|
144
152
|
return new Promise((resolve, reject) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
try {
|
|
154
|
+
const worker = getOrCreateWorker()
|
|
155
|
+
const requestId = ++requestIdCounter
|
|
156
|
+
pendingRequests.set(requestId, { resolve, reject })
|
|
157
|
+
worker.postMessage({ type: 'layout', requestId, elkGraph, groupingMode, hideGroupHeader, padding })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('[TopologyLayout] Worker unavailable:', err)
|
|
160
|
+
rejectPendingWorkerRequests()
|
|
161
|
+
reject(new Error(WORKER_UNAVAILABLE))
|
|
162
|
+
}
|
|
149
163
|
})
|
|
150
164
|
}
|
|
151
165
|
|
|
@@ -273,7 +287,18 @@ function runLayout(
|
|
|
273
287
|
if (layoutEngine === 'main-thread') {
|
|
274
288
|
return runLayoutOnMainThread(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
275
289
|
}
|
|
276
|
-
return runLayoutViaWorker(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
290
|
+
return runLayoutViaWorker(elkGraph, groupingMode, hideGroupHeader, padding).catch((err) => {
|
|
291
|
+
// A worker failure before a layout result arrives usually means the
|
|
292
|
+
// consumer's bundler/CSP couldn't serve or run the worker chunk.
|
|
293
|
+
// Fall back to the inline engine permanently rather than rendering a dead
|
|
294
|
+
// "Layout Error" pane.
|
|
295
|
+
if (err instanceof Error && err.message === WORKER_UNAVAILABLE) {
|
|
296
|
+
console.warn('[TopologyLayout] layout worker unavailable — falling back to main-thread ELK')
|
|
297
|
+
layoutEngine = 'main-thread'
|
|
298
|
+
return runLayoutOnMainThread(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
299
|
+
}
|
|
300
|
+
throw err
|
|
301
|
+
})
|
|
277
302
|
}
|
|
278
303
|
|
|
279
304
|
interface ElkNode {
|
|
@@ -798,7 +823,7 @@ function computeGridDimensions(cardCount: number, groupKey: string): { width: nu
|
|
|
798
823
|
}
|
|
799
824
|
|
|
800
825
|
// Two-phase layout: first layout groups internally, then position groups based on connections
|
|
801
|
-
// Layout
|
|
826
|
+
// Layout runs via the active engine — a Web Worker when available, else inline
|
|
802
827
|
export async function applyHierarchicalLayout(
|
|
803
828
|
elkGraph: ElkGraph,
|
|
804
829
|
topologyNodes: TopologyNode[],
|
|
@@ -817,7 +842,7 @@ export async function applyHierarchicalLayout(
|
|
|
817
842
|
try {
|
|
818
843
|
const padding = hideGroupHeader ? GROUP_PADDING_NO_HEADER : GROUP_PADDING
|
|
819
844
|
|
|
820
|
-
// Run layout
|
|
845
|
+
// Run layout via the active engine
|
|
821
846
|
const workerResult = await runLayout(elkGraph, groupingMode, hideGroupHeader, padding)
|
|
822
847
|
|
|
823
848
|
if (workerResult.error) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
import { EmptyState, type EmptyStateTone } from './EmptyState'
|
|
4
|
+
|
|
5
|
+
// CenteredEmpty — the shared "whole panel is empty" state, centered in the
|
|
6
|
+
// available height (matches how the view components present their own
|
|
7
|
+
// empty/healthy states). Use for not-found / no-data-at-all; for "the filter
|
|
8
|
+
// excluded everything" render an inline EmptyState card where the rows would be.
|
|
9
|
+
export function CenteredEmpty({
|
|
10
|
+
tone = 'neutral',
|
|
11
|
+
icon,
|
|
12
|
+
headline,
|
|
13
|
+
body,
|
|
14
|
+
action,
|
|
15
|
+
}: {
|
|
16
|
+
tone?: EmptyStateTone
|
|
17
|
+
icon?: LucideIcon
|
|
18
|
+
headline: string
|
|
19
|
+
body?: ReactNode
|
|
20
|
+
action?: ReactNode
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex min-h-[55vh] flex-1 items-center justify-center p-4">
|
|
24
|
+
<EmptyState tone={tone} variant="card" icon={icon} headline={headline} body={body} action={action} className="border-none bg-transparent" />
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
2
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
|
3
3
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
4
|
-
import { formatKindName } from './drawer-components'
|
|
5
4
|
import { pluralize } from '../../utils/pluralize'
|
|
6
5
|
|
|
7
6
|
export interface CascadeDependent {
|
|
@@ -115,7 +114,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
115
114
|
<div className="px-3 pb-2.5 space-y-1.5">
|
|
116
115
|
{grouped.map(([kind, names]) => (
|
|
117
116
|
<div key={kind} className="text-xs">
|
|
118
|
-
<span className="font-medium text-theme-text-primary">{
|
|
117
|
+
<span className="font-medium text-theme-text-primary">{kind}</span>
|
|
119
118
|
<span className="text-theme-text-tertiary ml-1">({names.length})</span>
|
|
120
119
|
<div className="ml-3 mt-0.5 text-theme-text-secondary font-mono break-all">
|
|
121
120
|
{names.slice(0, MAX_NAMES_PER_KIND).join(', ')}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import { Search, X } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { useRegisterShortcut, type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
|
|
5
|
+
|
|
6
|
+
/** The standard list-view search box: themed input with a `/`-to-focus
|
|
7
|
+
* shortcut, Escape-to-blur, and a clear affordance. One definition so the
|
|
8
|
+
* views can't drift (hand-rolled copies had already diverged: a blue focus
|
|
9
|
+
* ring in Timeline, no clear button in Audit). ResourcesView keeps its inline
|
|
10
|
+
* variant — regex mode and row-navigation handoff are coupled to its table. */
|
|
11
|
+
export function SearchBox({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
scope,
|
|
15
|
+
shortcutId,
|
|
16
|
+
placeholder = 'Search... (press /)',
|
|
17
|
+
className,
|
|
18
|
+
onEnter,
|
|
19
|
+
onArrowDown,
|
|
20
|
+
}: {
|
|
21
|
+
value: string
|
|
22
|
+
onChange: (value: string) => void
|
|
23
|
+
/** Help-overlay grouping + collision priority for the `/` shortcut. */
|
|
24
|
+
scope: ShortcutScope
|
|
25
|
+
/** Unique shortcut id, e.g. 'applications-search'. */
|
|
26
|
+
shortcutId: string
|
|
27
|
+
placeholder?: string
|
|
28
|
+
/** Width/layout overrides — the box itself stays themed. */
|
|
29
|
+
className?: string
|
|
30
|
+
/** Enter in the box — e.g. open the first filtered row. */
|
|
31
|
+
onEnter?: () => void
|
|
32
|
+
/** ArrowDown in the box — hand focus off to list keyboard navigation. */
|
|
33
|
+
onArrowDown?: () => void
|
|
34
|
+
}) {
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
36
|
+
|
|
37
|
+
useRegisterShortcut({
|
|
38
|
+
id: shortcutId,
|
|
39
|
+
keys: '/',
|
|
40
|
+
description: 'Focus search',
|
|
41
|
+
category: 'Search',
|
|
42
|
+
scope,
|
|
43
|
+
handler: () => inputRef.current?.focus(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={clsx('relative', className)}>
|
|
48
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-tertiary" />
|
|
49
|
+
<input
|
|
50
|
+
ref={inputRef}
|
|
51
|
+
type="text"
|
|
52
|
+
value={value}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
onChange={(e) => onChange(e.target.value)}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === 'Escape') {
|
|
57
|
+
inputRef.current?.blur()
|
|
58
|
+
} else if (e.key === 'Enter' && onEnter) {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
inputRef.current?.blur()
|
|
61
|
+
onEnter()
|
|
62
|
+
} else if (e.key === 'ArrowDown' && onArrowDown) {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
inputRef.current?.blur()
|
|
65
|
+
onArrowDown()
|
|
66
|
+
}
|
|
67
|
+
}}
|
|
68
|
+
className="w-full rounded-lg border border-theme-border-light bg-theme-elevated py-1.5 pl-10 pr-9 text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
69
|
+
/>
|
|
70
|
+
{value && (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label="Clear search"
|
|
74
|
+
onClick={() => {
|
|
75
|
+
onChange('')
|
|
76
|
+
inputRef.current?.focus()
|
|
77
|
+
}}
|
|
78
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
|
|
79
|
+
>
|
|
80
|
+
<X className="h-3.5 w-3.5" />
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -701,6 +701,14 @@ export function formatKindName(kind: string): string {
|
|
|
701
701
|
return kind
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
+
// Resolve the display label for a kind chip. The fetched resource's `dataKind`
|
|
705
|
+
// is the authoritative PascalCase kind and is correct for every CRD; prefer it.
|
|
706
|
+
// Before data loads, fall back to deriving from the URL plural — which is only
|
|
707
|
+
// reliable for the core kinds in formatKindName's map.
|
|
708
|
+
export function displayKindName(urlPlural: string, dataKind?: string): string {
|
|
709
|
+
return dataKind || formatKindName(urlPlural)
|
|
710
|
+
}
|
|
711
|
+
|
|
704
712
|
// Type for copy handler
|
|
705
713
|
export type CopyHandler = (text: string, key: string) => void
|
|
706
714
|
|
|
@@ -6,6 +6,7 @@ export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
|
6
6
|
export { EmptyState } from './EmptyState'
|
|
7
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
8
8
|
export { FetchResult } from './FetchResult'
|
|
9
|
+
export { SearchBox } from './SearchBox'
|
|
9
10
|
export { FilterPill } from './FilterPill'
|
|
10
11
|
export type { FilterPillTone } from './FilterPill'
|
|
11
12
|
export { StatusDot, mapHealthToTone } from './status-tone'
|