@skyhook-io/k8s-ui 1.7.11 → 1.7.13
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/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- 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/ConfirmDialog.tsx +1 -1
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +3 -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
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { useState, useMemo,
|
|
1
|
+
import { useState, useMemo, useEffect } from 'react'
|
|
2
2
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
3
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
4
5
|
import {
|
|
5
6
|
AlertCircle,
|
|
6
7
|
CheckCircle,
|
|
7
8
|
Clock,
|
|
8
|
-
Search,
|
|
9
9
|
RefreshCw,
|
|
10
10
|
ChevronRight,
|
|
11
11
|
Filter,
|
|
@@ -24,7 +24,6 @@ import { ResourceRefBadge } from '../ui/drawer-components'
|
|
|
24
24
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
25
25
|
import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
|
|
26
26
|
import { pluralize } from '../../utils/pluralize'
|
|
27
|
-
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
28
27
|
|
|
29
28
|
/** Format resource age (e.g., "3d", "5h", "10m") */
|
|
30
29
|
function formatResourceAge(createdAt: string): string {
|
|
@@ -87,29 +86,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
87
86
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
|
|
88
87
|
const [kindFilter, setKindFilter] = useState<string>('')
|
|
89
88
|
const [expandedItem, setExpandedItem] = useState<string | null>(null)
|
|
90
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
91
89
|
|
|
92
90
|
useEffect(() => {
|
|
93
91
|
onQueryChange?.({ timeRange, kind: kindFilter || undefined })
|
|
94
92
|
}, [timeRange, kindFilter, onQueryChange])
|
|
95
93
|
|
|
96
|
-
// Keyboard shortcut: / to focus search
|
|
97
|
-
useRegisterShortcut({
|
|
98
|
-
id: 'timeline-list-search',
|
|
99
|
-
keys: '/',
|
|
100
|
-
description: 'Focus search',
|
|
101
|
-
category: 'Search',
|
|
102
|
-
scope: 'timeline',
|
|
103
|
-
handler: () => searchInputRef.current?.focus(),
|
|
104
|
-
})
|
|
105
|
-
useRegisterShortcut({
|
|
106
|
-
id: 'timeline-list-escape',
|
|
107
|
-
keys: 'Escape',
|
|
108
|
-
description: 'Blur search',
|
|
109
|
-
category: 'Search',
|
|
110
|
-
scope: 'timeline',
|
|
111
|
-
handler: () => searchInputRef.current?.blur(),
|
|
112
|
-
})
|
|
113
94
|
|
|
114
95
|
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
|
|
115
96
|
|
|
@@ -278,17 +259,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
278
259
|
{/* Toolbar */}
|
|
279
260
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 flex-wrap">
|
|
280
261
|
{/* Search */}
|
|
281
|
-
<
|
|
282
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
283
|
-
<input
|
|
284
|
-
ref={searchInputRef}
|
|
285
|
-
type="text"
|
|
286
|
-
placeholder="Search... (press /)"
|
|
287
|
-
value={searchTerm}
|
|
288
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
289
|
-
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
290
|
-
/>
|
|
291
|
-
</div>
|
|
262
|
+
<SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="timeline-list-search" className="flex-1 min-w-[200px] max-w-md" />
|
|
292
263
|
|
|
293
264
|
{/* Activity type filter */}
|
|
294
265
|
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ZoomIn,
|
|
8
8
|
ZoomOut,
|
|
9
9
|
ChevronRight,
|
|
10
|
-
Search,
|
|
11
10
|
X,
|
|
12
11
|
List,
|
|
13
12
|
GanttChart,
|
|
@@ -27,6 +26,7 @@ import type { TimelineEvent, Topology } from '../../types'
|
|
|
27
26
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
28
27
|
import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
29
28
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
29
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
30
30
|
import { pluralize } from '../../utils/pluralize'
|
|
31
31
|
import { gitOpsRouteForKind } from '../../utils/gitops-route'
|
|
32
32
|
import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
|
|
@@ -194,7 +194,6 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
194
194
|
onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
|
|
195
195
|
}, [onNavigatePath, onResourceClick])
|
|
196
196
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
197
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
198
197
|
const [zoom, setZoom] = useState(1)
|
|
199
198
|
const [panOffset, setPanOffset] = useState(0)
|
|
200
199
|
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
|
|
@@ -241,23 +240,14 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
241
240
|
}, [events, hasAutoZoomed])
|
|
242
241
|
|
|
243
242
|
// Keyboard shortcuts
|
|
244
|
-
useRegisterShortcut({
|
|
245
|
-
id: 'swimlane-search',
|
|
246
|
-
keys: '/',
|
|
247
|
-
description: 'Focus search',
|
|
248
|
-
category: 'Search',
|
|
249
|
-
scope: 'timeline',
|
|
250
|
-
handler: () => searchInputRef.current?.focus(),
|
|
251
|
-
})
|
|
252
243
|
useRegisterShortcut({
|
|
253
244
|
id: 'swimlane-escape',
|
|
254
245
|
keys: 'Escape',
|
|
255
|
-
description: 'Close detail
|
|
246
|
+
description: 'Close event detail',
|
|
256
247
|
category: 'Timeline',
|
|
257
248
|
scope: 'timeline',
|
|
258
249
|
handler: () => {
|
|
259
250
|
if (selectedEvent) setSelectedEvent(null)
|
|
260
|
-
else searchInputRef.current?.blur()
|
|
261
251
|
},
|
|
262
252
|
})
|
|
263
253
|
|
|
@@ -467,25 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
467
457
|
<div className="flex items-center justify-between px-4 py-2">
|
|
468
458
|
<div className="flex items-center gap-4">
|
|
469
459
|
{/* Search */}
|
|
470
|
-
<
|
|
471
|
-
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
472
|
-
<input
|
|
473
|
-
ref={searchInputRef}
|
|
474
|
-
type="text"
|
|
475
|
-
value={searchTerm}
|
|
476
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
477
|
-
placeholder="Search... (press /)"
|
|
478
|
-
className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
479
|
-
/>
|
|
480
|
-
{searchTerm && (
|
|
481
|
-
<button
|
|
482
|
-
onClick={() => setSearchTerm('')}
|
|
483
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
|
|
484
|
-
>
|
|
485
|
-
<X className="w-4 h-4" />
|
|
486
|
-
</button>
|
|
487
|
-
)}
|
|
488
|
-
</div>
|
|
460
|
+
<SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="swimlane-search" className="w-80" />
|
|
489
461
|
{/* Zoom controls */}
|
|
490
462
|
<div className="flex items-center gap-2">
|
|
491
463
|
<button
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|