@skyhook-io/k8s-ui 1.6.1 → 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.
Files changed (69) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +431 -96
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/topology/K8sResourceNode.tsx +21 -2
  51. package/src/components/topology/TopologyGraph.tsx +46 -10
  52. package/src/components/topology/layout.ts +20 -11
  53. package/src/components/topology/layout.worker.ts +21 -17
  54. package/src/components/ui/YamlEditor.tsx +28 -15
  55. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/perf/index.ts +1 -0
  58. package/src/perf/store.ts +110 -0
  59. package/src/types/core.ts +10 -0
  60. package/src/types/index.ts +1 -0
  61. package/src/types/rbac.ts +99 -0
  62. package/src/utils/api-resources.ts +9 -1
  63. package/src/utils/index.ts +1 -0
  64. package/src/utils/rbac-badges.ts +61 -0
  65. package/src/utils/rbac-blast-radius.test.ts +137 -0
  66. package/src/utils/rbac-blast-radius.ts +98 -0
  67. package/src/utils/resource-hierarchy.ts +13 -3
  68. package/src/utils/structure-hash.test.ts +94 -0
  69. package/src/utils/structure-hash.ts +35 -0
@@ -105,6 +105,7 @@ import {
105
105
  ClusterNetworkPolicyRenderer,
106
106
  PodDisruptionBudgetRenderer,
107
107
  ServiceAccountRenderer,
108
+ NamespaceRenderer,
108
109
  RoleRenderer,
109
110
  RoleBindingRenderer,
110
111
  WebhookConfigRenderer,
@@ -199,7 +200,23 @@ import {
199
200
  AzureManagedControlPlaneRenderer,
200
201
  AzureManagedMachinePoolRenderer,
201
202
  AzureMachineRenderer,
203
+ ManagedResourceRenderer,
204
+ CompositeRenderer,
205
+ CrossplanePackageRenderer,
206
+ CrossplaneProviderConfigRenderer,
207
+ CompositionRenderer,
208
+ CompositionRevisionRenderer,
209
+ XRDRenderer,
202
210
  } from '../resources/renderers'
211
+ import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer'
212
+ import {
213
+ getCrossplaneStatus,
214
+ getProviderStatus,
215
+ getProviderConfigStatus,
216
+ isManagedResource,
217
+ isComposite,
218
+ isClaim,
219
+ } from '../resources/resource-utils-crossplane'
203
220
  import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
204
221
  import type { CopyHandler } from '../ui/drawer-components'
205
222
  import { AlertBanner } from '../ui/drawer-components'
@@ -229,6 +246,51 @@ export interface RendererOverrides {
229
246
  kind: string; data: any
230
247
  onNavigate?: (ref: ResourceRef) => void
231
248
  }>
249
+ // Optional override for Crossplane Composite / Claim — host wraps the
250
+ // package renderer to fan out per-composed-ref status fetches via React Query.
251
+ CompositeRenderer?: React.ComponentType<{
252
+ data: any
253
+ onNavigate?: (ref: ResourceRef) => void
254
+ composedRefStatuses?: Map<string, ComposedRefStatus>
255
+ }>
256
+ // ServiceAccount reverse-lookup: the host fetches /api/rbac/subject/... and
257
+ // feeds the result into the base renderer via this wrapper.
258
+ ServiceAccountRenderer?: React.ComponentType<{
259
+ data: any
260
+ onNavigate?: (ref: ResourceRef) => void
261
+ }>
262
+ // Role / ClusterRole reverse-lookup: host fetches /api/rbac/role/... so the
263
+ // detail page can show "who is bound to this role".
264
+ RoleRenderer?: React.ComponentType<{
265
+ data: any
266
+ onNavigate?: (ref: ResourceRef) => void
267
+ }>
268
+ // RoleBinding inline rules preview: host fetches the referenced Role/
269
+ // ClusterRole's rules so the binding view can show what's granted without
270
+ // a navigation step.
271
+ RoleBindingRenderer?: React.ComponentType<{
272
+ data: any
273
+ onNavigate?: (ref: ResourceRef) => void
274
+ }>
275
+ // Namespace RBAC summary: host fetches /api/rbac/namespace/{ns} so the
276
+ // namespace page can show bindings configured here without falling
277
+ // through to GenericRenderer.
278
+ NamespaceRenderer?: React.ComponentType<{
279
+ data: any
280
+ onNavigate?: (ref: ResourceRef) => void
281
+ }>
282
+ // HPA: host wraps the base renderer to add Prometheus-backed replicas /
283
+ // metric charts below the static spec data.
284
+ HPARenderer?: React.ComponentType<{
285
+ data: any
286
+ onNavigate?: (ref: ResourceRef) => void
287
+ }>
288
+ // PVC: host wraps the base renderer to add a kubelet-derived usage gauge
289
+ // when Prometheus is scraping kubelet endpoints.
290
+ PVCRenderer?: React.ComponentType<{
291
+ data: any
292
+ onNavigate?: (ref: ResourceRef) => void
293
+ }>
232
294
  }
233
295
 
234
296
  // Known resource types with specific renderers (module-level to avoid re-allocation)
@@ -243,7 +305,7 @@ const KNOWN_KINDS = new Set([
243
305
  'networkpolicies', 'networkpolicy',
244
306
  'ciliumnetworkpolicies', 'ciliumnetworkpolicy', 'ciliumclusterwidenetworkpolicies', 'ciliumclusterwidenetworkpolicy',
245
307
  'clusternetworkpolicies', 'clusternetworkpolicy',
246
- 'poddisruptionbudgets', 'serviceaccounts',
308
+ 'poddisruptionbudgets', 'serviceaccounts', 'namespaces',
247
309
  'roles', 'clusterroles', 'rolebindings', 'clusterrolebindings',
248
310
  'events', 'gitrepositories', 'ocirepositories', 'helmrepositories',
249
311
  'kustomizations', 'helmreleases', 'alerts', 'applications',
@@ -282,6 +344,12 @@ const KNOWN_KINDS = new Set([
282
344
  // Azure CAPI Infrastructure Provider
283
345
  'azuremanagedcontrolplanes', 'azuremanagedmachinepools', 'azuremachines',
284
346
  'azuremachinetemplates', 'azuremanagedclusters',
347
+ // Crossplane core (Managed Resources, Composites, and Claims are detected
348
+ // dynamically by spec shape — their plurals are unbounded so they're handled
349
+ // via fall-through, not enumerated in KNOWN_KINDS).
350
+ 'providers', 'providerconfigs',
351
+ 'compositeresourcedefinitions', 'compositions', 'compositionrevisions',
352
+ 'functions', 'configurations',
285
353
  ])
286
354
 
287
355
  // ============================================================================
@@ -355,12 +423,47 @@ export function ResourceRendererDispatch({
355
423
  }: ResourceRendererDispatchProps) {
356
424
  const kind = resource.kind.toLowerCase()
357
425
 
358
- const isKnownKind = KNOWN_KINDS.has(kind)
426
+ // Crossplane Managed Resources / Composites / Claims are detected by spec
427
+ // shape because their plurals are unbounded (one CRD kind per provider
428
+ // service). These flags suppress the GenericRenderer fall-through and route
429
+ // to the right Crossplane renderer below.
430
+ const isCrossplaneMR = isManagedResource(data)
431
+ const isCrossplaneClaim = !isCrossplaneMR && isClaim(data)
432
+ const isCrossplaneXR = !isCrossplaneMR && !isCrossplaneClaim && isComposite(data)
433
+
434
+ // Crossplane plurals that collide with foreign CRDs — `configurations`
435
+ // overlaps Knative serving.knative.dev/Configuration, `functions` could
436
+ // collide with OpenFaaS, `compositions` is in this list because its
437
+ // render line is apiVersion-gated. We add these to KNOWN_KINDS so the
438
+ // Crossplane renderer wins on apiVersion match, but a foreign CR with
439
+ // the same plural needs to fall through to GenericRenderer — otherwise
440
+ // it renders blank (no Crossplane match + isKnownKind suppresses
441
+ // generic). Only kinds whose render lines are apiVersion-gated belong
442
+ // here; `providerconfigs`/`compositionrevisions`/`compositeresource-
443
+ // definitions` are Crossplane-specific plurals with no realistic
444
+ // collision and unguarded render lines, so including them would risk
445
+ // double-render for foreign CRDs we'll never see.
446
+ const isCollisionGatedKind =
447
+ kind === 'providers' || kind === 'functions' || kind === 'configurations' || kind === 'compositions'
448
+ const crossplaneApiVersionMatched = isCollisionGatedKind && (
449
+ data?.apiVersion?.startsWith('pkg.crossplane.io/')
450
+ || data?.apiVersion?.startsWith('apiextensions.crossplane.io/')
451
+ )
452
+ const crossplaneCollisionFallthrough = isCollisionGatedKind && !crossplaneApiVersionMatched
453
+
454
+ const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
359
455
 
360
456
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
361
457
  const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
362
458
  const NodeComp = rendererOverrides?.NodeRenderer ?? NodeRenderer
363
459
  const ServiceComp = rendererOverrides?.ServiceRenderer ?? ServiceRenderer
460
+ const CompositeComp = rendererOverrides?.CompositeRenderer ?? CompositeRenderer
461
+ const ServiceAccountComp = rendererOverrides?.ServiceAccountRenderer ?? ServiceAccountRenderer
462
+ const RoleComp = rendererOverrides?.RoleRenderer ?? RoleRenderer
463
+ const RoleBindingComp = rendererOverrides?.RoleBindingRenderer ?? RoleBindingRenderer
464
+ const NamespaceComp = rendererOverrides?.NamespaceRenderer ?? NamespaceRenderer
465
+ const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
466
+ const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
364
467
 
365
468
  const sidebarContent = showCommonSections && (
366
469
  <>
@@ -385,9 +488,9 @@ export function ResourceRendererDispatch({
385
488
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
386
489
  {kind === 'jobs' && <JobRenderer data={data} />}
387
490
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
388
- {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPARenderer data={data} onNavigate={onNavigate} />}
491
+ {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
389
492
  {kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
390
- {kind === 'persistentvolumeclaims' && <PVCRenderer data={data} onNavigate={onNavigate} />}
493
+ {kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
391
494
  {kind === 'rollouts' && <RolloutRenderer data={data} />}
392
495
  {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
393
496
  {kind === 'workflows' && <WorkflowRenderer data={data} />}
@@ -410,9 +513,10 @@ export function ResourceRendererDispatch({
410
513
  {(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && <CiliumNetworkPolicyRenderer data={data} />}
411
514
  {(kind === 'clusternetworkpolicies' || kind === 'clusternetworkpolicy') && <ClusterNetworkPolicyRenderer data={data} />}
412
515
  {kind === 'poddisruptionbudgets' && <PodDisruptionBudgetRenderer data={data} />}
413
- {kind === 'serviceaccounts' && <ServiceAccountRenderer data={data} />}
414
- {(kind === 'roles' || kind === 'clusterroles') && <RoleRenderer data={data} />}
415
- {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingRenderer data={data} onNavigate={onNavigate} />}
516
+ {kind === 'serviceaccounts' && <ServiceAccountComp data={data} onNavigate={onNavigate} />}
517
+ {kind === 'namespaces' && <NamespaceComp data={data} onNavigate={onNavigate} />}
518
+ {(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
519
+ {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
416
520
  {kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
417
521
  {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
418
522
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
@@ -525,8 +629,23 @@ export function ResourceRendererDispatch({
525
629
  {/* Contour */}
526
630
  {kind === 'httpproxies' && <ContourHTTPProxyRenderer data={data} onNavigate={onNavigate} />}
527
631
 
528
- {/* Generic renderer for CRDs and unknown resource types */}
529
- {!isKnownKind && <GenericRenderer data={data} />}
632
+ {/* Crossplane — kind-dispatched for the static package/config kinds, spec-shape
633
+ detected for MR/XR/Claim (their plurals are unbounded). */}
634
+ {kind === 'providers' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Provider" onNavigate={onNavigate} />}
635
+ {kind === 'functions' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Function" onNavigate={onNavigate} />}
636
+ {kind === 'configurations' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Configuration" onNavigate={onNavigate} />}
637
+ {kind === 'providerconfigs' && <CrossplaneProviderConfigRenderer data={data} onNavigate={onNavigate} />}
638
+ {kind === 'compositeresourcedefinitions' && <XRDRenderer data={data} onNavigate={onNavigate} />}
639
+ {kind === 'compositions' && data?.apiVersion?.startsWith('apiextensions.crossplane.io/') && <CompositionRenderer data={data} onNavigate={onNavigate} />}
640
+ {kind === 'compositionrevisions' && <CompositionRevisionRenderer data={data} onNavigate={onNavigate} />}
641
+ {isCrossplaneMR && <ManagedResourceRenderer data={data} onNavigate={onNavigate} />}
642
+ {(isCrossplaneXR || isCrossplaneClaim) && <CompositeComp data={data} onNavigate={onNavigate} />}
643
+
644
+ {/* Generic renderer for CRDs and unknown resource types — also fires
645
+ for known-plural collisions where no apiVersion-gated renderer
646
+ matched (e.g. a Knative Configuration sharing the `configurations`
647
+ plural with Crossplane Configuration). */}
648
+ {(!isKnownKind || crossplaneCollisionFallthrough) && <GenericRenderer data={data} />}
530
649
 
531
650
  {/* Common sections - can be disabled when parent handles them separately */}
532
651
  {showCommonSections && (
@@ -662,6 +781,16 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
662
781
  if (k === 'peerauthentications') return getPeerAuthenticationStatus(data)
663
782
  if (k === 'authorizationpolicies') return getAuthorizationPolicyStatus(data)
664
783
 
784
+ // Crossplane — Provider/Function/Configuration share package conditions;
785
+ // ProviderConfig has its own; MR/XR/Claim detected by spec shape.
786
+ if ((k === 'providers' || k === 'functions' || k === 'configurations') && data?.apiVersion?.startsWith('pkg.crossplane.io/')) {
787
+ return getProviderStatus(data)
788
+ }
789
+ if (k === 'providerconfigs') return getProviderConfigStatus(data)
790
+ if (isManagedResource(data) || isComposite(data) || isClaim(data)) {
791
+ return getCrossplaneStatus(data)
792
+ }
793
+
665
794
  // Contour HTTPProxy
666
795
  if (k === 'httpproxies') {
667
796
  const s = getHTTPProxyStatus(data)
@@ -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>()
@@ -1,5 +1,6 @@
1
1
  import { useRef, useCallback, useEffect } from 'react'
2
- import Editor, { OnMount, OnChange, type Monaco } from '@monaco-editor/react'
2
+ import Editor, { DiffEditor, OnMount, OnChange, type Monaco } from '@monaco-editor/react'
3
+ import { PaneLoader } from './PaneLoader'
3
4
  import type { editor } from 'monaco-editor'
4
5
 
5
6
  interface YamlEditorProps {
@@ -240,40 +241,52 @@ export function YamlEditor({
240
241
  )
241
242
  }
242
243
 
243
- // Diff editor for showing changes before apply
244
244
  interface YamlDiffEditorProps {
245
245
  original: string
246
246
  modified: string
247
247
  height?: string | number
248
+ /** When true, render unified (single column) instead of side-by-side. */
249
+ unified?: boolean
250
+ /** When true, only diff regions stay rendered — unchanged sections collapse. */
251
+ hideUnchanged?: boolean
252
+ /** Caller-controlled theme. Defaults to dark to match YamlEditor. */
253
+ theme?: 'vs-dark' | 'vs'
254
+ /** When true, drops the rounded border so the editor reaches its container's edges. */
255
+ bleed?: boolean
248
256
  }
249
257
 
250
258
  export function YamlDiffEditor({
251
- original: _original,
259
+ original,
252
260
  modified,
253
261
  height = '100%',
262
+ unified = false,
263
+ hideUnchanged = false,
264
+ theme = 'vs-dark',
265
+ bleed = false,
254
266
  }: YamlDiffEditorProps) {
255
- // TODO: Use Monaco DiffEditor to show actual diff
256
- void _original
257
267
  return (
258
- <div className="rounded-lg overflow-hidden border border-theme-border" style={{ height }}>
259
- <Editor
260
- defaultLanguage="yaml"
261
- value={modified}
262
- theme="vs-dark"
268
+ <div className={bleed ? 'overflow-hidden' : 'rounded-lg overflow-hidden border border-theme-border'} style={{ height }}>
269
+ <DiffEditor
270
+ original={original}
271
+ modified={modified}
272
+ language="yaml"
273
+ theme={theme}
263
274
  options={{
264
275
  readOnly: true,
276
+ renderSideBySide: !unified,
277
+ hideUnchangedRegions: { enabled: hideUnchanged },
265
278
  minimap: { enabled: false },
266
279
  lineNumbers: 'on',
267
280
  scrollBeyondLastLine: false,
281
+ wordWrap: 'on',
268
282
  fontSize: 13,
269
283
  fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
270
284
  padding: { top: 12, bottom: 12 },
285
+ renderOverviewRuler: true,
286
+ ignoreTrimWhitespace: false,
287
+ automaticLayout: true,
271
288
  }}
272
- loading={
273
- <div className="flex items-center justify-center h-full bg-theme-surface text-theme-text-secondary">
274
- Loading diff...
275
- </div>
276
- }
289
+ loading={<PaneLoader label="Loading resources…" className="h-full" />}
277
290
  />
278
291
  </div>
279
292
  )
@@ -37,7 +37,7 @@ interface ResourceDetailDrawerProps {
37
37
  }) => ReactNode
38
38
  }
39
39
 
40
- const MIN_WIDTH = 400
40
+ const MIN_WIDTH = 520
41
41
  const MAX_WIDTH_PERCENT = 0.7
42
42
  const DEFAULT_WIDTH = 550
43
43
  const WIDE_WIDTH = 750
package/src/index.ts CHANGED
@@ -42,3 +42,9 @@ export * from './components/audit'
42
42
 
43
43
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
44
44
  export * from './components/cluster-switcher'
45
+
46
+ // Compare (ResourceCompareView, CompareResourcePicker, normalize utilities)
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'