@skyhook-io/k8s-ui 1.6.2 → 1.7.1

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.1",
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) */}
@@ -1,8 +1,10 @@
1
- import { Shield, Box, Users } from 'lucide-react'
1
+ import { Shield, Box, Users, Gauge } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components'
4
4
  import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, ResourceRef } from '../../../types'
5
5
  import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
6
+ import { SEVERITY_TEXT, SEVERITY_DOT } from '../../../utils/badge-colors'
7
+ import { parseCPUToNanocores, parseMemoryToBytes } from '../../../utils/format'
6
8
 
7
9
  interface NamespaceRendererProps {
8
10
  data: any
@@ -14,10 +16,23 @@ interface NamespaceRendererProps {
14
16
  rbacData?: RBACNamespaceResponse | null
15
17
  rbacLoading?: boolean
16
18
  rbacError?: Error | null
19
+ /**
20
+ * ResourceQuota objects for this namespace (from /api/resources/
21
+ * resourcequotas?namespace=). Undefined when the host hasn't wired the
22
+ * fetch (quota section omitted). A saturated quota is exactly why a
23
+ * namespace stops admitting pods, yet it's shown nowhere else.
24
+ */
25
+ quotaData?: any[] | null
26
+ /**
27
+ * Non-403 quota fetch error. When set, the quota section renders a note
28
+ * instead of silently disappearing — so a quota-constrained namespace whose
29
+ * fetch 500/503s isn't mistaken for quota-free. (403 stays hidden upstream.)
30
+ */
31
+ quotaError?: Error | null
17
32
  onNavigate?: (ref: ResourceRef) => void
18
33
  }
19
34
 
20
- export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNavigate }: NamespaceRendererProps) {
35
+ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quotaData, quotaError, onNavigate }: NamespaceRendererProps) {
21
36
  const metadata = data.metadata || {}
22
37
  const status = data.status || {}
23
38
  const phase = status.phase
@@ -48,6 +63,11 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNa
48
63
  </PropertyList>
49
64
  </Section>
50
65
 
66
+ {/* ResourceQuota usage — only when host wired the fetch. */}
67
+ {(quotaError || (quotaData != null && quotaData.length > 0)) && (
68
+ <NamespaceQuotaSection quotas={quotaData ?? []} error={quotaError ?? null} />
69
+ )}
70
+
51
71
  {/* RBAC summary — only when host wired the fetch. */}
52
72
  {rbacData !== undefined && (
53
73
  <NamespaceRBACSection
@@ -61,6 +81,89 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNa
61
81
  )
62
82
  }
63
83
 
84
+ // ============================================================================
85
+ // NAMESPACE QUOTA SECTION
86
+ // ============================================================================
87
+ // Shows ResourceQuota saturation — the signal that answers "why did this
88
+ // namespace stop admitting pods?" A quota at its hard limit blocks every new
89
+ // pod the namespace tries to create, with no failing Pod to inspect (the
90
+ // controller's FailedCreate event is the only trace). Surfacing usage here
91
+ // turns that invisible failure into a glanceable bar.
92
+
93
+ // quotaUsageRatio parses a used/hard pair for a quota resource, picking the
94
+ // right unit parser by resource name (cpu → millicores, memory/storage →
95
+ // bytes, everything else → plain count). Returns null when hard is unset or
96
+ // unparseable so the row falls back to showing the raw strings.
97
+ function quotaUsageRatio(resourceName: string, used: string, hard: string): number | null {
98
+ if (!hard) return null
99
+ const isCPU = /(^|\.)cpu$/i.test(resourceName)
100
+ const isBytes = /(memory|storage)$/i.test(resourceName)
101
+ const parse = isCPU ? parseCPUToNanocores : isBytes ? parseMemoryToBytes : (v: string) => parseFloat(v) || 0
102
+ const h = parse(hard)
103
+ if (!h) return null
104
+ return parse(used || '0') / h
105
+ }
106
+
107
+ function NamespaceQuotaSection({ quotas, error }: { quotas: any[]; error?: Error | null }) {
108
+ return (
109
+ <Section title="Resource Quotas" icon={Gauge} defaultExpanded>
110
+ {error && (
111
+ <div className="text-xs text-theme-text-secondary">
112
+ Couldn’t load resource quotas — retry shortly. A quota at its limit blocks new pods in this namespace.
113
+ </div>
114
+ )}
115
+ <div className="space-y-3">
116
+ {quotas.map((q: any, qi: number) => {
117
+ const name = q?.metadata?.name ?? `quota-${qi}`
118
+ const hard: Record<string, string> = q?.status?.hard ?? q?.spec?.hard ?? {}
119
+ const used: Record<string, string> = q?.status?.used ?? {}
120
+ const resourceNames = Object.keys(hard).sort()
121
+ return (
122
+ <div key={name} className="card-inner">
123
+ <div className="text-xs font-medium text-theme-text-primary mb-1.5">{name}</div>
124
+ {resourceNames.length === 0 ? (
125
+ <div className="text-xs text-theme-text-secondary">No hard limits set.</div>
126
+ ) : (
127
+ <div className="space-y-1">
128
+ {resourceNames.map((res) => {
129
+ const ratio = quotaUsageRatio(res, used[res] ?? '0', hard[res])
130
+ const pct = ratio === null ? null : Math.min(100, Math.round(ratio * 100))
131
+ const tone =
132
+ ratio === null ? SEVERITY_TEXT.neutral
133
+ : ratio >= 1 ? SEVERITY_TEXT.error
134
+ : ratio >= 0.9 ? SEVERITY_TEXT.alert
135
+ : SEVERITY_TEXT.neutral
136
+ const barTone =
137
+ ratio === null ? 'bg-theme-border'
138
+ : ratio >= 1 ? SEVERITY_DOT.error
139
+ : ratio >= 0.9 ? SEVERITY_DOT.alert
140
+ : 'bg-theme-text-tertiary'
141
+ return (
142
+ <div key={res} className="text-xs">
143
+ <div className="flex items-center justify-between gap-2">
144
+ <span className="text-theme-text-secondary truncate">{res}</span>
145
+ <span className={clsx('shrink-0 tabular-nums', tone)}>
146
+ {used[res] ?? '0'} / {hard[res]}{pct !== null && ` (${pct}%)`}
147
+ </span>
148
+ </div>
149
+ {pct !== null && (
150
+ <div className="mt-0.5 h-1 rounded-full bg-theme-base overflow-hidden">
151
+ <div className={clsx('h-full rounded-full', barTone)} style={{ width: `${pct}%` }} />
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ })}
157
+ </div>
158
+ )}
159
+ </div>
160
+ )
161
+ })}
162
+ </div>
163
+ </Section>
164
+ )
165
+ }
166
+
64
167
  // ============================================================================
65
168
  // NAMESPACE RBAC SECTION
66
169
  // ============================================================================
@@ -338,9 +338,12 @@ export function PodRenderer({
338
338
  <AlertBanner variant="error" title="Issues Detected">
339
339
  <ul className="text-xs space-y-1">
340
340
  {podProblems.map((p, i) => (
341
- <li key={i} className="flex items-center gap-1.5">
342
- <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
343
- <span className="text-red-600 dark:text-red-400">{p.message}</span>
341
+ <li key={i} className="flex items-start gap-1.5">
342
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0 mt-1', SEVERITY_DOT_COLOR[p.severity])} />
343
+ <span className="text-red-600 dark:text-red-400">
344
+ {p.message}
345
+ {p.detail && <span className="text-theme-text-secondary">: {p.detail}</span>}
346
+ </span>
344
347
  </li>
345
348
  ))}
346
349
  </ul>
@@ -48,6 +48,27 @@ export const healthColors: Record<HealthLevel, string> = {
48
48
  export interface PodProblem {
49
49
  severity: 'critical' | 'high' | 'medium'
50
50
  message: string
51
+ // detail carries extra human context shown after the short message (e.g.
52
+ // the scheduler's verdict for an Unschedulable pod). message stays the
53
+ // stable short label so filter-chip matching (podMatchesProblemCategory)
54
+ // and known-pattern checks keep working on exact strings.
55
+ detail?: string
56
+ }
57
+
58
+ /**
59
+ * Condense a kube-scheduler verdict (the PodScheduled=False / FailedScheduling
60
+ * message) for display: drop the "0/N nodes are available:" prefix and the
61
+ * "preemption: …" tail, keeping the per-predicate clause list — which already
62
+ * names untolerated taints, insufficient resources, and affinity/selector
63
+ * misses. Presentation-only; the backend `scheduling` issue source does the
64
+ * structured decomposition + node-label resolution (e.g. naming arm64).
65
+ */
66
+ export function summarizeSchedulerMessage(message?: string): string {
67
+ if (!message) return ''
68
+ let m = message.split('. preemption:')[0].split(' preemption:')[0].trim()
69
+ const colon = m.indexOf(':')
70
+ if (colon >= 0) m = m.slice(colon + 1).trim()
71
+ return m.replace(/\.\s*$/, '').trim()
51
72
  }
52
73
 
53
74
  /** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
@@ -302,7 +323,7 @@ export function getPodProblems(pod: any): PodProblem[] {
302
323
  for (const cond of conditions) {
303
324
  if (cond.type === 'PodScheduled' && cond.status === 'False') {
304
325
  if (cond.reason === 'Unschedulable') {
305
- problems.push({ severity: 'high', message: 'Unschedulable' })
326
+ problems.push({ severity: 'high', message: 'Unschedulable', detail: summarizeSchedulerMessage(cond.message) || undefined })
306
327
  }
307
328
  }
308
329
  // Readiness/Liveness probe failures
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { summarizeSchedulerMessage } from './resource-utils'
3
+
4
+ describe('summarizeSchedulerMessage', () => {
5
+ it('strips the "0/N nodes are available:" prefix and the preemption tail', () => {
6
+ const msg =
7
+ '0/5 nodes are available: 2 Insufficient cpu, 3 node(s) had untolerated taint {dedicated: gpu}. ' +
8
+ 'preemption: 0/5 nodes are available: 5 No preemption victims found for incoming pod.'
9
+ expect(summarizeSchedulerMessage(msg)).toBe(
10
+ '2 Insufficient cpu, 3 node(s) had untolerated taint {dedicated: gpu}',
11
+ )
12
+ })
13
+
14
+ it('returns the clause list without a node prefix unchanged (minus trailing period)', () => {
15
+ expect(summarizeSchedulerMessage('0/2 nodes are available: 2 Insufficient memory.')).toBe(
16
+ '2 Insufficient memory',
17
+ )
18
+ })
19
+
20
+ it('handles the bare " preemption:" tail variant', () => {
21
+ expect(
22
+ summarizeSchedulerMessage('0/3 nodes are available: 3 Insufficient cpu preemption: not helpful'),
23
+ ).toBe('3 Insufficient cpu')
24
+ })
25
+
26
+ it('returns empty string for empty/undefined input (so detail is omitted, message stays the stable label)', () => {
27
+ expect(summarizeSchedulerMessage('')).toBe('')
28
+ expect(summarizeSchedulerMessage(undefined)).toBe('')
29
+ })
30
+ })
@@ -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'
@@ -43,13 +43,63 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
43
43
  Pending: {
44
44
  title: 'Pending',
45
45
  description: 'Pod is waiting to be scheduled to a node.',
46
- action: 'Check for resource constraints or node availability.',
46
+ action: 'Open the pod to see the scheduler verdict (taints, resources, affinity).',
47
47
  },
48
48
  FailedScheduling: {
49
49
  title: 'Scheduling Failed',
50
50
  description: 'No suitable node found for this pod.',
51
51
  action: 'Check node resources, taints, tolerations, and affinity rules.',
52
52
  },
53
+ Unschedulable: {
54
+ title: 'Unschedulable',
55
+ description: 'The scheduler tried every node and none fit.',
56
+ action: 'Open the pod for the decomposed reason — arch/OS mismatch, untolerated taint, insufficient resources, or affinity.',
57
+ },
58
+ QuotaExceeded: {
59
+ title: 'ResourceQuota Exceeded',
60
+ description: 'A namespace ResourceQuota is at its hard limit, so new pods are rejected at admission.',
61
+ action: 'Open the namespace to see quota usage; raise the quota or free usage.',
62
+ },
63
+ QuotaNearLimit: {
64
+ title: 'ResourceQuota Near Limit',
65
+ description: 'A namespace ResourceQuota is close to its hard limit and will soon block new pods.',
66
+ action: 'Open the namespace to see quota usage.',
67
+ },
68
+ IPExhaustion: {
69
+ title: 'IP Exhaustion (CNI)',
70
+ description: 'The pod was scheduled but the CNI could not assign an IP — the node/subnet pool is exhausted.',
71
+ action: 'Free IPs, scale the subnet/ENI pool, or move the pod to a node with capacity.',
72
+ },
73
+ SandboxCreationFailed: {
74
+ title: 'Sandbox Creation Failed',
75
+ description: 'The kubelet could not create the pod sandbox.',
76
+ action: 'Check kubelet/CNI events on the node.',
77
+ },
78
+ VolumeMount: {
79
+ title: 'Volume Mount Failed',
80
+ description: 'The pod was scheduled but a volume could not be mounted.',
81
+ action: 'Check the PVC/PV binding and the CSI driver on the node.',
82
+ },
83
+ VolumeAttach: {
84
+ title: 'Volume Attach Failed',
85
+ description: 'A volume could not be attached to the node.',
86
+ action: 'Check the CSI driver and cloud-provider attach limits.',
87
+ },
88
+ VolumeMultiAttach: {
89
+ title: 'Volume Multi-Attach',
90
+ description: 'The volume is still attached to another node — a RWO volume cannot attach in two places.',
91
+ action: 'Wait for the old pod to terminate, or cordon/drain the stale node.',
92
+ },
93
+ PodSecurityViolation: {
94
+ title: 'Pod Security Violation',
95
+ description: 'Pod Security Admission rejected the pod template at admission.',
96
+ action: 'Align the pod securityContext with the namespace PSA level.',
97
+ },
98
+ WebhookDenied: {
99
+ title: 'Admission Webhook Denied',
100
+ description: 'A validating/mutating admission webhook rejected pod creation.',
101
+ action: 'Check the webhook policy that denied the request.',
102
+ },
53
103
  Evicted: {
54
104
  title: 'Pod Evicted',
55
105
  description: 'Pod was evicted from the node (usually due to resource pressure).',
@@ -171,8 +221,27 @@ function getStatusStyle(status: HealthStatus): React.CSSProperties {
171
221
  }
172
222
 
173
223
 
174
- // Format subtitle based on node kind
224
+ // Format subtitle based on node kind. In summary mode the pod tier is
225
+ // collapsed, so workload/service nodes carry a podSummary — append it so the
226
+ // count of pods (and any unhealthy/pending) is still visible without children.
175
227
  function getSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
228
+ const base = baseSubtitle(kind, nodeData)
229
+ const ps = nodeData.podSummary as PodSummary | undefined
230
+ if (ps && SUMMARY_POD_KINDS.has(kind)) {
231
+ let suffix = `${ps.total} pods`
232
+ if (ps.unhealthy > 0) suffix += ` (${ps.unhealthy} unhealthy)`
233
+ else if (ps.degraded > 0) suffix += ` (${ps.degraded} pending)`
234
+ return base ? `${base} • ${suffix}` : suffix
235
+ }
236
+ return base
237
+ }
238
+
239
+ // Kinds that own pods and therefore carry a podSummary in summary mode.
240
+ const SUMMARY_POD_KINDS = new Set<NodeKind>([
241
+ 'Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'Service',
242
+ ])
243
+
244
+ function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
176
245
  switch (kind) {
177
246
  case 'Deployment':
178
247
  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
+ }