@skyhook-io/k8s-ui 1.6.2 → 1.7.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -23,6 +23,7 @@ import {
23
23
  Check,
24
24
  Plus,
25
25
  GitCompare,
26
+ Regex,
26
27
  } from 'lucide-react'
27
28
  import { clsx } from 'clsx'
28
29
  import { ResourceBar } from '../ui/ResourceBar'
@@ -1940,6 +1941,7 @@ export function ResourcesView({
1940
1941
  onSelectedKindChange?.(selectedKind)
1941
1942
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
1942
1943
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
1944
+ const [regexMode, setRegexMode] = useState(false)
1943
1945
  const [sortColumn, setSortColumn] = useState<string | null>(null)
1944
1946
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
1945
1947
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
@@ -2280,7 +2282,7 @@ export function ResourcesView({
2280
2282
 
2281
2283
  // Reset highlight when kind, search, sort, or namespace changes
2282
2284
  const namespacesKey = namespaces.join(',')
2283
- useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, sortColumn, sortDirection, namespacesKey])
2285
+ useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, regexMode, sortColumn, sortDirection, namespacesKey])
2284
2286
 
2285
2287
  // Scroll highlighted row into view
2286
2288
  useEffect(() => {
@@ -3073,6 +3075,18 @@ export function ResourcesView({
3073
3075
  }, [])
3074
3076
 
3075
3077
 
3078
+ // On an invalid pattern, fall back to a null matcher (search un-applied, all
3079
+ // rows shown) rather than zero results, so the table doesn't flash empty
3080
+ // while the user is mid-typing a pattern.
3081
+ const searchRegex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
3082
+ if (!regexMode || !searchTerm) return { re: null, error: null }
3083
+ try {
3084
+ return { re: new RegExp(searchTerm, 'i'), error: null }
3085
+ } catch (e) {
3086
+ return { re: null, error: e instanceof Error ? e.message : 'Invalid regex' }
3087
+ }
3088
+ }, [regexMode, searchTerm])
3089
+
3076
3090
  // Filter resources by search term, status, problems, and sort
3077
3091
  const filteredResources = useMemo(() => {
3078
3092
  if (!resources) return []
@@ -3081,11 +3095,21 @@ export function ResourcesView({
3081
3095
 
3082
3096
  // Apply search filter
3083
3097
  if (searchTerm) {
3084
- const term = searchTerm.toLowerCase()
3085
- result = result.filter((r: any) =>
3086
- r.metadata?.name?.toLowerCase().includes(term) ||
3087
- r.metadata?.namespace?.toLowerCase().includes(term)
3088
- )
3098
+ if (regexMode) {
3099
+ const re = searchRegex.re
3100
+ if (re) {
3101
+ result = result.filter((r: any) =>
3102
+ re.test(r.metadata?.name ?? '') ||
3103
+ re.test(r.metadata?.namespace ?? '')
3104
+ )
3105
+ }
3106
+ } else {
3107
+ const term = searchTerm.toLowerCase()
3108
+ result = result.filter((r: any) =>
3109
+ r.metadata?.name?.toLowerCase().includes(term) ||
3110
+ r.metadata?.namespace?.toLowerCase().includes(term)
3111
+ )
3112
+ }
3089
3113
  }
3090
3114
 
3091
3115
  // Apply column filters (generic, multi-select per column — OR within column, AND across columns)
@@ -3241,7 +3265,7 @@ export function ResourcesView({
3241
3265
  }
3242
3266
 
3243
3267
  return result
3244
- }, [resources, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
3268
+ }, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
3245
3269
 
3246
3270
  // For nodes table: compute the majority minor version so outliers can be highlighted
3247
3271
  const majorityNodeMinorVersion = useMemo(() => {
@@ -3557,38 +3581,68 @@ export function ResourcesView({
3557
3581
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-theme-surface">
3558
3582
  {/* Toolbar */}
3559
3583
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border bg-theme-base shrink-0">
3560
- <div className="flex-1 relative">
3561
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
3562
- <input
3563
- ref={searchInputRef}
3564
- type="text"
3565
- placeholder="Search... (press /)"
3566
- value={searchTerm}
3567
- onChange={(e) => setSearchTerm(e.target.value)}
3568
- onKeyDown={(e) => {
3569
- if (e.key === 'ArrowDown') {
3570
- // Hand off to the table's keyboard navigation — blur the input
3571
- // so the registered ArrowDown/j/k shortcuts take over, and
3572
- // highlight the first row.
3573
- e.preventDefault()
3574
- searchInputRef.current?.blur()
3575
- setHighlightedIndex(0)
3576
- } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
3577
- // Select the first (or currently highlighted) resource
3578
- e.preventDefault()
3579
- searchInputRef.current?.blur()
3580
- if (highlightedIndex < 0) setHighlightedIndex(0)
3581
- // Defer to next frame so the highlight renders before we open
3582
- requestAnimationFrame(() => {
3583
- const res = highlightedResourceRef.current ?? filteredResources[0]
3584
- selectResource(res)
3585
- })
3586
- } else if (e.key === 'Escape') {
3587
- searchInputRef.current?.blur()
3588
- }
3589
- }}
3590
- 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-skyhook-500"
3591
- />
3584
+ <div className="flex-1 min-w-0">
3585
+ <div className="relative max-w-md">
3586
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
3587
+ <input
3588
+ ref={searchInputRef}
3589
+ type="text"
3590
+ placeholder={regexMode ? 'Search by regex... (press /)' : 'Search... (press /)'}
3591
+ value={searchTerm}
3592
+ onChange={(e) => setSearchTerm(e.target.value)}
3593
+ onKeyDown={(e) => {
3594
+ if (e.key === 'ArrowDown') {
3595
+ // Hand off to the table's keyboard navigation blur the input
3596
+ // so the registered ArrowDown/j/k shortcuts take over, and
3597
+ // highlight the first row.
3598
+ e.preventDefault()
3599
+ searchInputRef.current?.blur()
3600
+ setHighlightedIndex(0)
3601
+ } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
3602
+ // Select the first (or currently highlighted) resource
3603
+ e.preventDefault()
3604
+ searchInputRef.current?.blur()
3605
+ if (highlightedIndex < 0) setHighlightedIndex(0)
3606
+ // Defer to next frame so the highlight renders before we open
3607
+ requestAnimationFrame(() => {
3608
+ const res = highlightedResourceRef.current ?? filteredResources[0]
3609
+ selectResource(res)
3610
+ })
3611
+ } else if (e.key === 'Escape') {
3612
+ searchInputRef.current?.blur()
3613
+ }
3614
+ }}
3615
+ className={clsx(
3616
+ 'w-full pl-10 pr-10 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
3617
+ searchRegex.error
3618
+ ? 'border-red-500/60 focus:ring-red-500'
3619
+ : 'border-theme-border-light focus:ring-skyhook-500'
3620
+ )}
3621
+ />
3622
+ <button
3623
+ type="button"
3624
+ onClick={() => setRegexMode((v) => !v)}
3625
+ aria-pressed={regexMode}
3626
+ aria-label={regexMode ? 'Disable regex search' : 'Enable regex search'}
3627
+ title={regexMode ? 'Regex search enabled — click to disable' : 'Enable regex search'}
3628
+ className={clsx(
3629
+ 'absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded transition-colors',
3630
+ regexMode
3631
+ ? 'bg-skyhook-500/20 text-skyhook-400'
3632
+ : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover'
3633
+ )}
3634
+ >
3635
+ <Regex className="w-3.5 h-3.5" />
3636
+ </button>
3637
+ {searchRegex.error && (
3638
+ <div
3639
+ title={searchRegex.error}
3640
+ className="absolute left-0 top-full mt-1 z-10 px-2 py-1 rounded bg-theme-elevated border border-red-500/40 text-[11px] text-red-400 shadow-theme-sm"
3641
+ >
3642
+ Invalid regex pattern
3643
+ </div>
3644
+ )}
3645
+ </div>
3592
3646
  </div>
3593
3647
 
3594
3648
  {/* Problems dropdown (pods only) */}
@@ -5,7 +5,7 @@ import {
5
5
  ChevronUp,
6
6
  } from 'lucide-react'
7
7
  import { clsx } from 'clsx'
8
- import type { NodeKind, HealthStatus } from '../../types'
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
11
  import { Tooltip } from '../ui/Tooltip'
@@ -171,8 +171,27 @@ function getStatusStyle(status: HealthStatus): React.CSSProperties {
171
171
  }
172
172
 
173
173
 
174
- // Format subtitle based on node kind
174
+ // Format subtitle based on node kind. In summary mode the pod tier is
175
+ // collapsed, so workload/service nodes carry a podSummary — append it so the
176
+ // count of pods (and any unhealthy/pending) is still visible without children.
175
177
  function getSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
178
+ const base = baseSubtitle(kind, nodeData)
179
+ const ps = nodeData.podSummary as PodSummary | undefined
180
+ if (ps && SUMMARY_POD_KINDS.has(kind)) {
181
+ let suffix = `${ps.total} pods`
182
+ if (ps.unhealthy > 0) suffix += ` (${ps.unhealthy} unhealthy)`
183
+ else if (ps.degraded > 0) suffix += ` (${ps.degraded} pending)`
184
+ return base ? `${base} • ${suffix}` : suffix
185
+ }
186
+ return base
187
+ }
188
+
189
+ // Kinds that own pods and therefore carry a podSummary in summary mode.
190
+ const SUMMARY_POD_KINDS = new Set<NodeKind>([
191
+ 'Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'Service',
192
+ ])
193
+
194
+ function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
176
195
  switch (kind) {
177
196
  case 'Deployment':
178
197
  case 'Rollout':
@@ -20,7 +20,7 @@ import {
20
20
  import '@xyflow/react/dist/style.css'
21
21
  import { toCanvas } from 'html-to-image'
22
22
 
23
- import { AlertTriangle, Download, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
23
+ import { AlertTriangle, Download, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
24
24
  import { PaneLoader } from '../ui/PaneLoader'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
26
  import { useToast } from '../ui/Toast'
@@ -31,6 +31,8 @@ import { GroupNode } from './GroupNode'
31
31
  import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
32
32
  import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
33
33
  import { pluralize } from '../../utils/pluralize'
34
+ import { foldHash } from '../../utils/structure-hash'
35
+ import { recordLayoutDuration, recordLayoutSkipped, recordStructureKeyDuration } from '../../perf'
34
36
 
35
37
  // Edge colors by type
36
38
  const EDGE_COLORS = {
@@ -442,13 +444,29 @@ export function TopologyGraph({
442
444
  if (topoNode) onNodeClick(topoNode)
443
445
  }, [topology, workingNodes, onNodeClick])
444
446
 
445
- // Structure key for change detection — includes groupLevels so chip↔cardGrid triggers relayout
447
+ // Structure key for change detection — includes groupLevels so chip↔cardGrid triggers relayout.
448
+ //
449
+ // Uses an order-independent fold of per-ID hashes (see foldHash) instead of
450
+ // sort+join. At thousands of nodes the join allocated tens of KB of string
451
+ // every render (and the sort dominated for short ID arrays); the fold is
452
+ // O(n) with constant memory and detects the same structural changes
453
+ // (add/remove/rename) — combined with the element count in the key. Pure
454
+ // reorders no longer trigger a layout, which is correct: ELK relayouts on
455
+ // reorder were wasted work.
446
456
  const structureKey = useMemo(() => {
447
- const nodeIds = workingNodes.map(n => n.id).sort().join(',')
448
- const edgeIds = workingEdges.map(e => `${e.source}->${e.target}:${e.type}`).sort().join(',')
449
- const levels = Array.from(groupLevels.entries()).sort().map(([k, v]) => `${k}:${v}`).join(',')
450
- const expanded = Array.from(expandedPodGroups).sort().join(',')
451
- return `${viewMode}|${nodeIds}|${edgeIds}|${levels}|${expanded}|${groupingMode}|${layoutRetryCount}`
457
+ const t0 = performance.now()
458
+ const nodeHash = foldHash(workingNodes, n => n.id)
459
+ const edgeHash = foldHash(workingEdges, e => `${e.source}->${e.target}:${e.type}`)
460
+ const levelsHash = foldHash(Array.from(groupLevels.entries()), ([k, v]) => `${k}:${v}`)
461
+ const expandedHash = foldHash(Array.from(expandedPodGroups), s => s)
462
+ const key =
463
+ `${viewMode}|${groupingMode}|${layoutRetryCount}` +
464
+ `|n${workingNodes.length}:${nodeHash}` +
465
+ `|e${workingEdges.length}:${edgeHash}` +
466
+ `|l${groupLevels.size}:${levelsHash}` +
467
+ `|x${expandedPodGroups.size}:${expandedHash}`
468
+ recordStructureKeyDuration((performance.now() - t0) * 1000)
469
+ return key
452
470
  }, [viewMode, workingNodes, workingEdges, groupLevels, expandedPodGroups, groupingMode, layoutRetryCount])
453
471
 
454
472
  // Layout when structure changes - use hierarchical ELK layout
@@ -465,6 +483,7 @@ export function TopologyGraph({
465
483
  const structureChanged = structureKey !== prevStructureRef.current
466
484
 
467
485
  if (!structureChanged) {
486
+ recordLayoutSkipped()
468
487
  return
469
488
  }
470
489
 
@@ -517,6 +536,7 @@ export function TopologyGraph({
517
536
  groupMapRef.current = groupMap
518
537
 
519
538
  // Apply layout and get positioned nodes
539
+ const layoutStartMs = performance.now()
520
540
  applyHierarchicalLayout(
521
541
  elkGraph,
522
542
  workingNodes,
@@ -540,6 +560,7 @@ export function TopologyGraph({
540
560
  return
541
561
  }
542
562
  setLayoutError(null)
563
+ recordLayoutDuration(performance.now() - layoutStartMs, workingNodes.length, workingEdges.length)
543
564
 
544
565
  // Preserve positions for nodes that already have a saved position (i.e. were
545
566
  // present in a previous layout). New nodes use the ELK-computed position.
@@ -562,19 +583,25 @@ export function TopologyGraph({
562
583
  savedPositionsRef.current.set(node.id, node.position)
563
584
  }
564
585
 
565
- // Add expand/collapse handlers to pod-related nodes
586
+ // Add expand/collapse handlers to pod-related nodes. Only PodGroups that
587
+ // actually carry a per-pod array are expandable — summary-only orphan
588
+ // nodes (summary mode) hold counts only, so they get no expand affordance.
566
589
  const nodesWithHandlers = positionedNodes.map(node => {
567
590
  const isPodGroup = node.data?.kind === 'PodGroup'
568
591
  const nodeData = node.data?.nodeData as Record<string, unknown> | undefined
592
+ // The per-pod array lives on the backend node data (nodeData.pods).
593
+ // Summary-only orphan nodes omit it, so they get no expand affordance.
594
+ const podsArray = nodeData?.pods
595
+ const isExpandablePodGroup = isPodGroup && Array.isArray(podsArray) && podsArray.length > 0
569
596
  const expandedFromGroup = nodeData?.expandedFromGroup as string | undefined
570
597
 
571
598
  return {
572
599
  ...node,
573
600
  data: {
574
601
  ...node.data,
575
- onExpand: isPodGroup ? handleExpandPodGroup : undefined,
602
+ onExpand: isExpandablePodGroup ? handleExpandPodGroup : undefined,
576
603
  onCollapse: expandedFromGroup ? handleCollapsePodGroup : undefined,
577
- isExpanded: isPodGroup ? expandedPodGroups.has(node.id) : undefined,
604
+ isExpanded: isExpandablePodGroup ? expandedPodGroups.has(node.id) : undefined,
578
605
  },
579
606
  }
580
607
  })
@@ -780,6 +807,15 @@ export function TopologyGraph({
780
807
  </div>
781
808
  </div>
782
809
  )}
810
+ {/* Summary-mode pill — pod tier collapsed to per-workload/service counts */}
811
+ {topology?.summaryMode && (
812
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 bg-blue-500/10 border border-blue-500/30 rounded-full px-3 py-1 backdrop-blur-sm">
813
+ <Layers className="w-3.5 h-3.5 text-blue-400 shrink-0" />
814
+ <span className="text-xs text-theme-text-secondary">
815
+ Summary view — pods collapsed to counts. Filter to a smaller namespace to see individual pods.
816
+ </span>
817
+ </div>
818
+ )}
783
819
  <ReactFlow
784
820
  nodes={nodes}
785
821
  edges={edges}
@@ -164,7 +164,24 @@ async function runLayoutOnMainThread(
164
164
 
165
165
  const groupLayouts: LayoutResult['groupLayouts'] = []
166
166
  const ungroupedNodes: LayoutResult['ungroupedNodes'] = []
167
- const groupNodeIds = new Map<string, Set<string>>()
167
+ // Map each node to its group once. Serves both the intra-group edge bucketing
168
+ // here (filtering all edges per group would be O(groups × edges)) and the
169
+ // node→group lookup for Phase 2's inter-group edges below.
170
+ const nodeToGroup = new Map<string, string>()
171
+ for (const child of elkGraph.children) {
172
+ if (child.id.startsWith('group-') && child.children) {
173
+ for (const c of child.children) nodeToGroup.set(c.id, child.id)
174
+ }
175
+ }
176
+ const intraEdgesByGroup = new Map<string, ElkEdge[]>()
177
+ for (const e of elkGraph.edges) {
178
+ const sg = nodeToGroup.get(e.sources[0])
179
+ if (sg && sg === nodeToGroup.get(e.targets[0])) {
180
+ const arr = intraEdgesByGroup.get(sg)
181
+ if (arr) arr.push(e)
182
+ else intraEdgesByGroup.set(sg, [e])
183
+ }
184
+ }
168
185
 
169
186
  // Phase 1: Layout each group independently
170
187
  for (const child of elkGraph.children) {
@@ -173,12 +190,8 @@ async function runLayoutOnMainThread(
173
190
  if (isGroup && child.children && child.children.length > 0) {
174
191
  const groupKey = child.id.replace(`group-${groupingMode}-`, '')
175
192
  const minWidth = hideGroupHeader ? 300 : Math.max(500, groupKey.length * 16 + 200)
176
- const nodeIds = new Set(child.children.map(c => c.id))
177
- groupNodeIds.set(child.id, nodeIds)
178
193
 
179
- const intraGroupEdges = elkGraph.edges.filter(e =>
180
- nodeIds.has(e.sources[0]) && nodeIds.has(e.targets[0])
181
- )
194
+ const intraGroupEdges = intraEdgesByGroup.get(child.id) ?? []
182
195
 
183
196
  const layoutResult = await elk.layout({
184
197
  id: child.id,
@@ -217,11 +230,7 @@ async function runLayoutOnMainThread(
217
230
  }
218
231
 
219
232
  // Phase 2: Build meta-graph and position groups based on inter-group edges
220
- const nodeToGroup = new Map<string, string>()
221
- for (const [groupId, nodeIds] of groupNodeIds) {
222
- for (const nodeId of nodeIds) nodeToGroup.set(nodeId, groupId)
223
- }
224
-
233
+ // (nodeToGroup was built once above).
225
234
  const interGroupEdges: ElkEdge[] = []
226
235
  const seen = new Set<string>()
227
236
  for (const edge of elkGraph.edges) {
@@ -116,8 +116,25 @@ self.onmessage = async (e: MessageEvent<LayoutRequest>) => {
116
116
  const groupLayouts: GroupLayoutResult[] = []
117
117
  const ungroupedNodes: UngroupedNodeResult[] = []
118
118
 
119
- // Build a set of node IDs in each group for edge filtering
120
- const groupNodeIds = new Map<string, Set<string>>()
119
+ // Map each node to its group once. Serves both the intra-group edge
120
+ // bucketing here (filtering all edges per group would be O(groups × edges))
121
+ // and the node→group lookup for Phase 2's inter-group edges. This is the
122
+ // default (worker) layout path, so the win actually lands here.
123
+ const nodeToGroup = new Map<string, string>()
124
+ for (const child of elkGraph.children) {
125
+ if (child.id.startsWith('group-') && child.children) {
126
+ for (const c of child.children) nodeToGroup.set(c.id, child.id)
127
+ }
128
+ }
129
+ const intraEdgesByGroup = new Map<string, ElkEdge[]>()
130
+ for (const e of elkGraph.edges) {
131
+ const sg = nodeToGroup.get(e.sources[0])
132
+ if (sg && sg === nodeToGroup.get(e.targets[0])) {
133
+ const arr = intraEdgesByGroup.get(sg)
134
+ if (arr) arr.push(e)
135
+ else intraEdgesByGroup.set(sg, [e])
136
+ }
137
+ }
121
138
 
122
139
  // Phase 1: Layout each group independently
123
140
  for (const child of elkGraph.children) {
@@ -127,14 +144,8 @@ self.onmessage = async (e: MessageEvent<LayoutRequest>) => {
127
144
  const groupKey = child.id.replace(`group-${groupingMode}-`, '')
128
145
  const minWidth = hideGroupHeader ? 300 : Math.max(500, groupKey.length * 16 + 200)
129
146
 
130
- // Track node IDs in this group
131
- const nodeIds = new Set(child.children.map(c => c.id))
132
- groupNodeIds.set(child.id, nodeIds)
133
-
134
147
  // Layout this group independently with only intra-group edges
135
- const intraGroupEdges = elkGraph.edges.filter(e =>
136
- nodeIds.has(e.sources[0]) && nodeIds.has(e.targets[0])
137
- )
148
+ const intraGroupEdges = intraEdgesByGroup.get(child.id) ?? []
138
149
 
139
150
  const groupGraph: ElkGraph = {
140
151
  id: child.id,
@@ -186,14 +197,7 @@ self.onmessage = async (e: MessageEvent<LayoutRequest>) => {
186
197
  }
187
198
  }
188
199
 
189
- // Phase 2: Build meta-graph and position groups
190
- const nodeToGroup = new Map<string, string>()
191
- for (const [groupId, nodeIds] of groupNodeIds) {
192
- for (const nodeId of nodeIds) {
193
- nodeToGroup.set(nodeId, groupId)
194
- }
195
- }
196
-
200
+ // Phase 2: Build meta-graph and position groups (nodeToGroup built once above).
197
201
  // Find inter-group edges
198
202
  const interGroupEdges: ElkEdge[] = []
199
203
  const seenInterGroupEdges = new Set<string>()
package/src/index.ts CHANGED
@@ -45,3 +45,6 @@ export * from './components/cluster-switcher'
45
45
 
46
46
  // Compare (ResourceCompareView, CompareResourcePicker, normalize utilities)
47
47
  export * from './components/compare'
48
+
49
+ // Perf instrumentation (ELK + structureKey timers, surfaced in diagnostics overlay)
50
+ export * from './perf'
@@ -0,0 +1 @@
1
+ export * from './store'
@@ -0,0 +1,110 @@
1
+ // Always-on, in-memory performance instrumentation for k8s-ui internals.
2
+ // Records ELK layout duration and structureKey rebuild duration so users can
3
+ // include them in bug reports via the host app's diagnostics overlay.
4
+ // Cost is one performance.now() pair + a 50-entry ring buffer append per
5
+ // topology layout — negligible.
6
+
7
+ const RING_SIZE = 50
8
+
9
+ interface Ring {
10
+ samples: number[]
11
+ next: number
12
+ count: number
13
+ last: number
14
+ }
15
+
16
+ function makeRing(): Ring {
17
+ return { samples: new Array(RING_SIZE), next: 0, count: 0, last: 0 }
18
+ }
19
+
20
+ function ringAdd(r: Ring, v: number): void {
21
+ r.samples[r.next] = v
22
+ r.next = (r.next + 1) % RING_SIZE
23
+ if (r.count < RING_SIZE) r.count++
24
+ r.last = v
25
+ }
26
+
27
+ export interface SampleWindow {
28
+ count: number
29
+ last: number
30
+ min: number
31
+ p50: number
32
+ p95: number
33
+ p99: number
34
+ max: number
35
+ }
36
+
37
+ function ringSnapshot(r: Ring): SampleWindow {
38
+ if (r.count === 0) return { count: 0, last: 0, min: 0, p50: 0, p95: 0, p99: 0, max: 0 }
39
+ const buf = r.samples.slice(0, r.count).sort((a, b) => a - b)
40
+ const pick = (p: number) => buf[Math.min(buf.length - 1, Math.floor((buf.length - 1) * p))]
41
+ return {
42
+ count: r.count,
43
+ last: r.last,
44
+ min: buf[0],
45
+ p50: pick(0.5),
46
+ p95: pick(0.95),
47
+ p99: pick(0.99),
48
+ max: buf[buf.length - 1],
49
+ }
50
+ }
51
+
52
+ const layoutMs = makeRing()
53
+ const structureKeyUs = makeRing()
54
+ const lastLayoutNodeCount = { value: 0 }
55
+ const lastLayoutEdgeCount = { value: 0 }
56
+ let totalLayouts = 0
57
+ let totalLayoutsSkipped = 0
58
+ let totalStructureKeyComputes = 0
59
+
60
+ export interface K8sUIPerfSnapshot {
61
+ totalLayouts: number
62
+ totalLayoutsSkipped: number
63
+ totalStructureKeyComputes: number
64
+ lastLayoutNodeCount: number
65
+ lastLayoutEdgeCount: number
66
+ layoutMs: SampleWindow
67
+ structureKeyUs: SampleWindow
68
+ }
69
+
70
+ export function recordLayoutDuration(ms: number, nodeCount: number, edgeCount: number): void {
71
+ totalLayouts++
72
+ ringAdd(layoutMs, ms)
73
+ lastLayoutNodeCount.value = nodeCount
74
+ lastLayoutEdgeCount.value = edgeCount
75
+ }
76
+
77
+ export function recordLayoutSkipped(): void {
78
+ totalLayoutsSkipped++
79
+ }
80
+
81
+ export function recordStructureKeyDuration(us: number): void {
82
+ totalStructureKeyComputes++
83
+ ringAdd(structureKeyUs, us)
84
+ }
85
+
86
+ export function getK8sUIPerfSnapshot(): K8sUIPerfSnapshot {
87
+ return {
88
+ totalLayouts,
89
+ totalLayoutsSkipped,
90
+ totalStructureKeyComputes,
91
+ lastLayoutNodeCount: lastLayoutNodeCount.value,
92
+ lastLayoutEdgeCount: lastLayoutEdgeCount.value,
93
+ layoutMs: ringSnapshot(layoutMs),
94
+ structureKeyUs: ringSnapshot(structureKeyUs),
95
+ }
96
+ }
97
+
98
+ // Test seam — reset all counters and windows. Not safe to call concurrently
99
+ // with the record functions.
100
+ export function resetK8sUIPerf(): void {
101
+ layoutMs.samples = new Array(RING_SIZE)
102
+ layoutMs.next = 0; layoutMs.count = 0; layoutMs.last = 0
103
+ structureKeyUs.samples = new Array(RING_SIZE)
104
+ structureKeyUs.next = 0; structureKeyUs.count = 0; structureKeyUs.last = 0
105
+ totalLayouts = 0
106
+ totalLayoutsSkipped = 0
107
+ totalStructureKeyComputes = 0
108
+ lastLayoutNodeCount.value = 0
109
+ lastLayoutEdgeCount.value = 0
110
+ }
package/src/types/core.ts CHANGED
@@ -212,9 +212,19 @@ export interface Topology {
212
212
  largeCluster?: boolean // True if cluster exceeds large cluster threshold
213
213
  hiddenKinds?: string[] // Resource kinds auto-hidden for performance
214
214
  requiresNamespaceFilter?: boolean // True if cluster is too large for all-namespace topology
215
+ estimatedNodes?: number // Pre-build node count estimate
216
+ summaryMode?: boolean // True when the pod tier was collapsed into per-workload/service counts
215
217
  crdDiscoveryStatus?: 'idle' | 'discovering' | 'ready' // CRD discovery status
216
218
  }
217
219
 
220
+ // PodSummary is stamped onto a workload or service node's data in summary mode.
221
+ export interface PodSummary {
222
+ total: number
223
+ healthy: number
224
+ degraded: number
225
+ unhealthy: number
226
+ }
227
+
218
228
  // K8s Event (from SSE stream)
219
229
  export interface K8sEvent {
220
230
  kind: string
@@ -559,13 +559,23 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
559
559
  TraefikService: 2, Middleware: 3, MiddlewareTCP: 3,
560
560
  HTTPProxy: 1, // Contour
561
561
  }
562
+ // Precompute each child's latest event time once — otherwise the
563
+ // comparator below reparses every child's event timestamps on every
564
+ // comparison (O(children log children × events) Date parses).
565
+ const latestByChildId = new Map<string, number>()
566
+ for (const c of lane.children) {
567
+ let latest = 0
568
+ for (const e of c.events) {
569
+ const t = new Date(e.timestamp).getTime()
570
+ if (t > latest) latest = t
571
+ }
572
+ latestByChildId.set(c.id, latest)
573
+ }
562
574
  lane.children.sort((a, b) => {
563
575
  const aPriority = kindPriority[a.kind] || 10
564
576
  const bPriority = kindPriority[b.kind] || 10
565
577
  if (aPriority !== bPriority) return aPriority - bPriority
566
- const aLatest = a.events.length > 0 ? Math.max(...a.events.map(e => new Date(e.timestamp).getTime())) : 0
567
- const bLatest = b.events.length > 0 ? Math.max(...b.events.map(e => new Date(e.timestamp).getTime())) : 0
568
- return bLatest - aLatest
578
+ return (latestByChildId.get(b.id) ?? 0) - (latestByChildId.get(a.id) ?? 0)
569
579
  })
570
580
  }
571
581
 
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { fnv1a32, foldHash } from './structure-hash'
3
+
4
+ describe('fnv1a32', () => {
5
+ it('is deterministic', () => {
6
+ expect(fnv1a32('hello')).toBe(fnv1a32('hello'))
7
+ })
8
+
9
+ it('distinguishes similar strings', () => {
10
+ expect(fnv1a32('pod/default/a')).not.toBe(fnv1a32('pod/default/b'))
11
+ expect(fnv1a32('a')).not.toBe(fnv1a32('A'))
12
+ })
13
+
14
+ it('handles empty string', () => {
15
+ expect(typeof fnv1a32('')).toBe('number')
16
+ })
17
+ })
18
+
19
+ describe('foldHash', () => {
20
+ const id = (s: string) => s
21
+
22
+ it('is order-independent', () => {
23
+ const a = foldHash(['a', 'b', 'c'], id)
24
+ const b = foldHash(['c', 'a', 'b'], id)
25
+ expect(a).toBe(b)
26
+ })
27
+
28
+ it('changes when an element is added', () => {
29
+ const before = foldHash(['a', 'b'], id)
30
+ const after = foldHash(['a', 'b', 'c'], id)
31
+ expect(before).not.toBe(after)
32
+ })
33
+
34
+ it('changes when an element is removed', () => {
35
+ const before = foldHash(['a', 'b', 'c'], id)
36
+ const after = foldHash(['a', 'b'], id)
37
+ expect(before).not.toBe(after)
38
+ })
39
+
40
+ it('changes when an element is renamed', () => {
41
+ const before = foldHash(['a', 'b', 'c'], id)
42
+ const after = foldHash(['a', 'b', 'd'], id)
43
+ expect(before).not.toBe(after)
44
+ })
45
+
46
+ it('returns the zero fingerprint for empty input', () => {
47
+ expect(foldHash([], id)).toBe('0.0')
48
+ })
49
+
50
+ it('works with object items via keyOf', () => {
51
+ const items = [{ id: 'x' }, { id: 'y' }]
52
+ expect(foldHash(items, i => i.id)).toBe(foldHash(['x', 'y'], id))
53
+ })
54
+
55
+ it('emits a "<xor>.<sum>" shape', () => {
56
+ expect(foldHash(['a', 'b'], id)).toMatch(/^\d+\.\d+$/)
57
+ })
58
+
59
+ // The dual accumulator's whole point: a swap that preserves the XOR fold
60
+ // (a^b stays constant) must still change the fingerprint via the sum fold.
61
+ // Single-XOR would have collided here. Construct two sets with equal XOR
62
+ // but different members.
63
+ it('distinguishes sets that share an XOR but differ in membership', () => {
64
+ // {x} vs {y, z} where hash(x) === hash(y) ^ hash(z) would collide under
65
+ // pure XOR. We can't easily force that, so instead verify the additive
66
+ // fold breaks a known XOR-preserving transform: doubling an element.
67
+ // ['a','a'] XOR-folds to 0 (a^a), same as [] — but sum differs.
68
+ expect(foldHash(['a', 'a'], id)).not.toBe(foldHash([], id))
69
+ expect(foldHash(['a', 'a'], id)).not.toBe(foldHash(['b', 'b'], id))
70
+ })
71
+ })
72
+
73
+ // The production guard against skipped relayouts is the *composed* key
74
+ // (count + foldHash), exactly as TopologyGraph builds it. These tests pin that
75
+ // composition, not just the bare fold — a genuine same-count add/remove/rename
76
+ // must change the composed key so the layout effect doesn't short-circuit.
77
+ describe('composed structure key (count + foldHash)', () => {
78
+ const id = (s: string) => s
79
+ // Mirrors TopologyGraph's structureKey shape for the node portion.
80
+ const composed = (nodeIds: string[]) => `n${nodeIds.length}:${foldHash(nodeIds, id)}`
81
+
82
+ it('changes on a same-count rename', () => {
83
+ expect(composed(['a', 'b', 'c'])).not.toBe(composed(['a', 'b', 'x']))
84
+ })
85
+
86
+ it('changes on add and on remove', () => {
87
+ expect(composed(['a', 'b'])).not.toBe(composed(['a', 'b', 'c']))
88
+ expect(composed(['a', 'b', 'c'])).not.toBe(composed(['a', 'b']))
89
+ })
90
+
91
+ it('is stable across reorder (no wasted relayout)', () => {
92
+ expect(composed(['a', 'b', 'c'])).toBe(composed(['c', 'b', 'a']))
93
+ })
94
+ })
@@ -0,0 +1,35 @@
1
+ // FNV-1a 32-bit string hash. Cheap, well-distributed, no allocations.
2
+ export function fnv1a32(s: string): number {
3
+ let h = 0x811c9dc5
4
+ for (let i = 0; i < s.length; i++) {
5
+ h ^= s.charCodeAt(i)
6
+ h = Math.imul(h, 0x01000193)
7
+ }
8
+ return h >>> 0
9
+ }
10
+
11
+ // foldHash returns an order-independent fingerprint of the given items as a
12
+ // "<xor>.<sum>" string. Used by TopologyGraph's structureKey change-detection
13
+ // — at thousands of nodes, sort+join over IDs allocated tens of KB of string
14
+ // every render; this is O(n) with two uint32 accumulators.
15
+ //
16
+ // Two independent commutative folds (XOR and 32-bit additive sum) are combined
17
+ // because a single collision in the structure key is a false negative on the
18
+ // exact path that serves big, high-churn graphs: the layout effect bails on an
19
+ // unchanged key, so a real shape change would silently skip setNodes/setEdges.
20
+ // A collision now requires BOTH folds to collide simultaneously across the
21
+ // difference set, which is vanishingly unlikely. Combine with element count
22
+ // (caller composes "count.xor.sum") for full structural identity.
23
+ //
24
+ // Order independence is intentional: pure reorders of the same node/edge set
25
+ // produce an identical graph and shouldn't trigger an ELK relayout.
26
+ export function foldHash<T>(items: ArrayLike<T>, keyOf: (item: T) => string): string {
27
+ let xor = 0
28
+ let sum = 0
29
+ for (let i = 0; i < items.length; i++) {
30
+ const h = fnv1a32(keyOf(items[i]))
31
+ xor ^= h
32
+ sum = (sum + h) >>> 0
33
+ }
34
+ return `${xor >>> 0}.${sum}`
35
+ }