@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.
Files changed (70) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/dock/TerminalTab.tsx +4 -2
  10. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  11. package/src/components/issues/IssuesView.tsx +64 -17
  12. package/src/components/issues/index.ts +1 -1
  13. package/src/components/issues/issues.test.ts +4 -4
  14. package/src/components/issues/severity.ts +5 -0
  15. package/src/components/issues/types.ts +43 -0
  16. package/src/components/logs/LogCore.tsx +13 -2
  17. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  18. package/src/components/logs/LogsViewer.tsx +66 -10
  19. package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
  20. package/src/components/logs/useLogStream.ts +41 -3
  21. package/src/components/resources/ResourcesView.tsx +550 -52
  22. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  23. package/src/components/resources/get-default-container-name.test.ts +31 -0
  24. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  25. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  26. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  27. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  28. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  30. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  31. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  32. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  33. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  34. package/src/components/resources/renderers/index.ts +8 -0
  35. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  36. package/src/components/resources/resource-utils-dra.ts +90 -0
  37. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  38. package/src/components/resources/resource-utils.ts +62 -4
  39. package/src/components/shared/DetailShell.tsx +14 -7
  40. package/src/components/shared/EditableYamlView.tsx +37 -17
  41. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  42. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  43. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  44. package/src/components/timeline/TimelineList.tsx +3 -32
  45. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  46. package/src/components/topology/K8sResourceNode.tsx +26 -5
  47. package/src/components/topology/TopologyGraph.tsx +102 -3
  48. package/src/components/topology/layout.ts +36 -11
  49. package/src/components/ui/CenteredEmpty.tsx +27 -0
  50. package/src/components/ui/ConfirmDialog.tsx +1 -1
  51. package/src/components/ui/SearchBox.tsx +85 -0
  52. package/src/components/ui/drawer-components.tsx +23 -1
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/workload/WorkloadView.tsx +167 -33
  55. package/src/components/workload/index.ts +1 -1
  56. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  57. package/src/index.ts +4 -0
  58. package/src/types/core.ts +1 -0
  59. package/src/utils/api-resources.ts +21 -0
  60. package/src/utils/applications.test.ts +207 -0
  61. package/src/utils/applications.ts +674 -0
  62. package/src/utils/custom-columns.test.ts +111 -0
  63. package/src/utils/custom-columns.ts +49 -0
  64. package/src/utils/extended-resources.test.ts +152 -0
  65. package/src/utils/extended-resources.ts +121 -0
  66. package/src/utils/format.ts +11 -0
  67. package/src/utils/index.ts +3 -0
  68. package/src/utils/topology-neighborhood.test.ts +185 -0
  69. package/src/utils/topology-neighborhood.ts +262 -0
  70. package/src/utils/workload-colors.ts +36 -0
@@ -1,11 +1,11 @@
1
- import { useState, useMemo, useRef, useEffect } from 'react'
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
- <div className="flex-1 relative min-w-[200px]">
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 / blur search',
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
- <div className="relative">
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
- isSmallNode && 'opacity-90',
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
- for (const [, pending] of pendingRequests) {
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
- const worker = getOrCreateWorker()
146
- const requestId = ++requestIdCounter
147
- pendingRequests.set(requestId, { resolve, reject })
148
- worker.postMessage({ type: 'layout', requestId, elkGraph, groupingMode, hideGroupHeader, padding })
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 is performed in a Web Worker to avoid blocking the main thread
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 in worker (off main thread)
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
+ }
@@ -74,7 +74,7 @@ export function ConfirmDialog({
74
74
 
75
75
  {/* Custom content */}
76
76
  {children && (
77
- <div className="px-4 pt-4">
77
+ <div className="px-4 py-4">
78
78
  {children}
79
79
  </div>
80
80
  )}
@@ -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
+ }