@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.
- package/package.json +6 -1
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +431 -96
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- 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/components/ui/YamlEditor.tsx +28 -15
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/index.ts +6 -0
- package/src/perf/index.ts +1 -0
- package/src/perf/store.ts +110 -0
- package/src/types/core.ts +10 -0
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -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
|
@@ -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
|
-
|
|
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') && <
|
|
491
|
+
{(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
|
|
389
492
|
{kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
|
|
390
|
-
{kind === 'persistentvolumeclaims' && <
|
|
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' && <
|
|
414
|
-
{
|
|
415
|
-
{(kind === '
|
|
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
|
-
{/*
|
|
529
|
-
|
|
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
|
|
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>()
|
|
@@ -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
|
|
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=
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
)
|
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'
|