@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 +1 -1
- package/src/components/resources/ResourcesView.tsx +93 -39
- package/src/components/topology/K8sResourceNode.tsx +21 -2
- package/src/components/topology/TopologyGraph.tsx +46 -10
- package/src/components/topology/layout.ts +20 -11
- package/src/components/topology/layout.worker.ts +21 -17
- package/src/index.ts +3 -0
- package/src/perf/index.ts +1 -0
- package/src/perf/store.ts +110 -0
- package/src/types/core.ts +10 -0
- package/src/utils/resource-hierarchy.ts +13 -3
- package/src/utils/structure-hash.test.ts +94 -0
- package/src/utils/structure-hash.ts +35 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
|
3561
|
-
<
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
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
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
|
|
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:
|
|
602
|
+
onExpand: isExpandablePodGroup ? handleExpandPodGroup : undefined,
|
|
576
603
|
onCollapse: expandedFromGroup ? handleCollapsePodGroup : undefined,
|
|
577
|
-
isExpanded:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|