@skyhook-io/k8s-ui 1.8.3 → 1.8.5
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 +2 -2
- package/src/components/charts/PrometheusChartsView.tsx +3 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +2 -3
- package/src/components/gitops/GitOpsTableView.tsx +2 -4
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -6
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +3 -7
- package/src/components/logs/LogCore.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +1 -1
- package/src/components/topology/TopologyGraph.tsx +20 -7
- package/src/components/topology/layout-elk-graph.test.ts +222 -0
- package/src/components/topology/layout.ts +96 -33
- package/src/components/topology/layout.worker.ts +18 -34
- package/src/components/ui/CodeViewer.tsx +1 -1
- package/src/components/ui/RestrictedState.tsx +152 -0
- package/src/components/ui/YamlEditor.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.5",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"yaml": ">=2.0.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@types/node": "^
|
|
88
|
+
"@types/node": "^26.0.0",
|
|
89
89
|
"@types/react": "^19.2.17",
|
|
90
90
|
"@types/react-dom": "^19.2.3",
|
|
91
91
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clsx } from 'clsx'
|
|
2
2
|
import { BarChart3, Wifi, WifiOff, Loader2 } from 'lucide-react'
|
|
3
3
|
import { AreaChart } from './AreaChart'
|
|
4
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
5
|
import { MetricsSummary } from './MetricsSummary'
|
|
5
6
|
import { SeriesLegend } from './SeriesLegend'
|
|
6
7
|
import type { TimeSeries, ReferenceLine } from './types'
|
|
@@ -108,10 +109,7 @@ export function PrometheusChartsView({
|
|
|
108
109
|
if (statusLoading) {
|
|
109
110
|
if (!showEmptyState) return null
|
|
110
111
|
return (
|
|
111
|
-
<
|
|
112
|
-
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
113
|
-
Checking Prometheus availability...
|
|
114
|
-
</div>
|
|
112
|
+
<PaneLoader label="Checking Prometheus availability…" className="py-12" />
|
|
115
113
|
)
|
|
116
114
|
}
|
|
117
115
|
|
|
@@ -181,10 +179,7 @@ export function PrometheusChartsView({
|
|
|
181
179
|
{/* Chart area */}
|
|
182
180
|
<div className="min-h-[280px] flex-1 p-4">
|
|
183
181
|
{metricsLoading ? (
|
|
184
|
-
<
|
|
185
|
-
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
186
|
-
Loading metrics...
|
|
187
|
-
</div>
|
|
182
|
+
<PaneLoader label="Loading metrics…" className="min-h-[240px]" />
|
|
188
183
|
) : metricsError ? (
|
|
189
184
|
<div className="flex h-full items-center justify-center text-sm text-red-400">
|
|
190
185
|
Failed to load metrics: {(metricsError as Error).message}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, type ComponentType, type ReactNode } from 'react'
|
|
2
2
|
import { ArrowDownUp, ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
|
|
3
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
3
4
|
|
|
4
5
|
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
5
6
|
import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
|
|
@@ -487,9 +488,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
487
488
|
)}
|
|
488
489
|
|
|
489
490
|
{resourceLoading ? (
|
|
490
|
-
<
|
|
491
|
-
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps resource…
|
|
492
|
-
</div>
|
|
491
|
+
<PaneLoader label="Loading GitOps resource…" className="flex-1" />
|
|
493
492
|
) : resourceError ? (
|
|
494
493
|
<div className="p-4 text-sm text-red-500">Failed to load resource: {resourceError.message}</div>
|
|
495
494
|
) : (
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
HeartPulse,
|
|
13
13
|
LayoutGrid,
|
|
14
14
|
List,
|
|
15
|
-
Loader2,
|
|
16
15
|
Pause,
|
|
17
16
|
Play,
|
|
18
17
|
RefreshCw,
|
|
@@ -33,6 +32,7 @@ import { FacetSection, FacetButton } from '../ui/Facet'
|
|
|
33
32
|
import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
|
|
34
33
|
import { DistributionBar } from '../ui/DistributionBar'
|
|
35
34
|
import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
|
|
35
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
36
36
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
37
37
|
import { getGitOpsResourceStatus } from './detail-helpers'
|
|
38
38
|
import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
|
|
@@ -754,9 +754,7 @@ export function GitOpsTableView({
|
|
|
754
754
|
{modeLabel(mode)} view is queued behind the application list.
|
|
755
755
|
</div>
|
|
756
756
|
) : loading && filteredRows.length === 0 ? (
|
|
757
|
-
<
|
|
758
|
-
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps applications...
|
|
759
|
-
</div>
|
|
757
|
+
<PaneLoader label="Loading GitOps applications…" className="h-full" />
|
|
760
758
|
) : error ? (
|
|
761
759
|
<div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
|
|
762
760
|
) : filteredRows.length === 0 ? (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AlertTriangle, ChevronDown, ChevronRight, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
|
|
2
|
+
import { PaneLoader } from '../../ui/PaneLoader'
|
|
2
3
|
import { clsx } from 'clsx'
|
|
3
4
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
5
|
import type { GitOpsChange, GitOpsHistoryItem, GitOpsInsight, GitOpsInsightRef, GitOpsIssue, GitOpsPlanItem, GitOpsRemediation, GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
|
|
@@ -725,7 +726,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
725
726
|
return <InsightErrorState error={error} />
|
|
726
727
|
}
|
|
727
728
|
if (!insight) {
|
|
728
|
-
return <
|
|
729
|
+
return <PaneLoader label="Loading GitOps resources…" className="h-full" />
|
|
729
730
|
}
|
|
730
731
|
// Build plan metadata maps keyed by ref so each Change row can advertise
|
|
731
732
|
// its sync step, hook phase, and wave assignment. The plan and changes
|
|
@@ -1274,7 +1275,7 @@ interface GitOpsActivityInsightViewProps {
|
|
|
1274
1275
|
|
|
1275
1276
|
export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOpsActivityInsightViewProps) {
|
|
1276
1277
|
if (error && !insight) return <InsightErrorState error={error} />
|
|
1277
|
-
if (!insight) return <
|
|
1278
|
+
if (!insight) return <PaneLoader label="Loading GitOps activity…" className="h-full" />
|
|
1278
1279
|
const canRollback = !!insight.capabilities?.rollback && !!onRollback
|
|
1279
1280
|
// Auto-sync makes rollback futile — the controller would re-sync to HEAD
|
|
1280
1281
|
// immediately. Argo's own Web UI disables the button in this state. Detect
|
|
@@ -1429,10 +1430,6 @@ function SectionHeader({ icon: Icon, title, hint }: { icon: typeof GitBranch; ti
|
|
|
1429
1430
|
)
|
|
1430
1431
|
}
|
|
1431
1432
|
|
|
1432
|
-
function CenteredText({ children }: { children: ReactNode }) {
|
|
1433
|
-
return <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">{children}</div>
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
1433
|
// Surfaced when the insights endpoint errors. Without this the subviews
|
|
1437
1434
|
// would render their "Loading…" placeholder forever, hiding the failure
|
|
1438
1435
|
// from the user and from the operator looking at logs.
|
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
type NodeTypes,
|
|
16
16
|
} from '@xyflow/react'
|
|
17
17
|
import '@xyflow/react/dist/style.css'
|
|
18
|
-
import { AlertTriangle, ChevronRight,
|
|
18
|
+
import { AlertTriangle, ChevronRight, Maximize, Search, X } from 'lucide-react'
|
|
19
|
+
import { PaneLoader } from '../../ui/PaneLoader'
|
|
19
20
|
import { clsx } from 'clsx'
|
|
20
21
|
|
|
21
22
|
import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
@@ -127,12 +128,7 @@ function GitOpsTreeGraphInner({
|
|
|
127
128
|
}, [onNodeClick])
|
|
128
129
|
|
|
129
130
|
if (loading) {
|
|
130
|
-
return
|
|
131
|
-
<div className="flex h-full items-center justify-center text-theme-text-secondary">
|
|
132
|
-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
133
|
-
Loading GitOps resource tree...
|
|
134
|
-
</div>
|
|
135
|
-
)
|
|
131
|
+
return <PaneLoader label="Loading GitOps resource tree…" className="h-full" />
|
|
136
132
|
}
|
|
137
133
|
|
|
138
134
|
if (error) {
|
|
@@ -796,7 +796,7 @@ export function LogCore({
|
|
|
796
796
|
<div className={`flex-1 flex items-center justify-center ${palette.textTertiary}`}>
|
|
797
797
|
<div className="flex items-center gap-2">
|
|
798
798
|
<RotateCcw className="w-4 h-4 animate-spin" />
|
|
799
|
-
<span>Loading logs
|
|
799
|
+
<span>Loading logs…</span>
|
|
800
800
|
</div>
|
|
801
801
|
</div>
|
|
802
802
|
) : errorMessage ? (
|
|
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, u
|
|
|
2
2
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
5
|
+
import { RestrictedState } from '../ui/RestrictedState'
|
|
5
6
|
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
|
|
6
7
|
import {
|
|
7
8
|
Search,
|
|
@@ -1840,6 +1841,9 @@ interface ResourcesViewProps {
|
|
|
1840
1841
|
// Lightweight counts for sidebar badges (from /api/resource-counts)
|
|
1841
1842
|
resourceCounts?: Record<string, number>
|
|
1842
1843
|
resourceForbidden?: string[]
|
|
1844
|
+
/** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
|
|
1845
|
+
* keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
|
|
1846
|
+
resourceReasons?: Record<string, string>
|
|
1843
1847
|
resourceUnavailable?: string[]
|
|
1844
1848
|
// Single query for the currently selected kind's full data
|
|
1845
1849
|
selectedKindQuery?: ResourceQueryResult
|
|
@@ -2058,6 +2062,7 @@ export function ResourcesView({
|
|
|
2058
2062
|
resourceQueries: resourceQueriesProp,
|
|
2059
2063
|
resourceCounts: resourceCountsProp,
|
|
2060
2064
|
resourceForbidden: resourceForbiddenProp,
|
|
2065
|
+
resourceReasons,
|
|
2061
2066
|
resourceUnavailable: resourceUnavailableProp,
|
|
2062
2067
|
selectedKindQuery: selectedKindQueryProp,
|
|
2063
2068
|
largeListGuard,
|
|
@@ -3232,7 +3237,6 @@ export function ResourcesView({
|
|
|
3232
3237
|
}, [resources])
|
|
3233
3238
|
const isLoading = selectedQuery?.isLoading ?? true
|
|
3234
3239
|
const selectedQueryError = selectedQuery?.error
|
|
3235
|
-
const isSelectedForbidden = isForbiddenError(selectedQueryError)
|
|
3236
3240
|
const refetchFn = selectedQuery?.refetch
|
|
3237
3241
|
const dataUpdatedAt = selectedQuery?.dataUpdatedAt
|
|
3238
3242
|
|
|
@@ -3294,6 +3298,23 @@ export function ResourcesView({
|
|
|
3294
3298
|
return result
|
|
3295
3299
|
}, [useNewCountsMode, resourceForbiddenProp, resourcesToCount, resourceQueries])
|
|
3296
3300
|
|
|
3301
|
+
// Render the restricted state when EITHER the list query 403s (namespaced
|
|
3302
|
+
// denials) OR the selected kind is in the counts `forbidden` set. Denied
|
|
3303
|
+
// cluster-scoped kinds return 200 with `[]` from the list endpoint, so the
|
|
3304
|
+
// 403 signal alone misses them — they'd fall through to "No <kind> found".
|
|
3305
|
+
const selectedKindCountKey = selectedKind.group
|
|
3306
|
+
? `${selectedKind.group}/${selectedKind.kind}`
|
|
3307
|
+
: selectedKind.kind
|
|
3308
|
+
// Actual rows win over a stale counts `forbidden` entry: a kind can be marked
|
|
3309
|
+
// forbidden/unavailable in counts (e.g. an informer not yet synced at counts
|
|
3310
|
+
// time) while the list query has since returned data — show the table, not
|
|
3311
|
+
// RestrictedState. A 403 on the list itself never carries rows, so it still
|
|
3312
|
+
// forces the restricted state.
|
|
3313
|
+
const selectedHasRows = Array.isArray(resources) && resources.length > 0
|
|
3314
|
+
const isSelectedForbidden =
|
|
3315
|
+
isForbiddenError(selectedQueryError) ||
|
|
3316
|
+
(!selectedHasRows && forbiddenKinds.has(selectedKindCountKey))
|
|
3317
|
+
|
|
3297
3318
|
// Reset sort and filters when kind changes (but not when syncing from URL navigation)
|
|
3298
3319
|
// Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
|
|
3299
3320
|
const prevKindRef = useRef(selectedKind.name)
|
|
@@ -4505,10 +4526,18 @@ export function ResourcesView({
|
|
|
4505
4526
|
{isLoading ? (
|
|
4506
4527
|
<PaneLoader className="absolute inset-0" />
|
|
4507
4528
|
) : isSelectedForbidden ? (
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4529
|
+
// overflow-y-auto + min-h-full: centered when the panel is short
|
|
4530
|
+
// (collapsed), scrollable (no top clip) when the RBAC snippet is
|
|
4531
|
+
// expanded on a shorter pane.
|
|
4532
|
+
<div className="absolute inset-0 overflow-y-auto">
|
|
4533
|
+
<div className="min-h-full flex items-center justify-center">
|
|
4534
|
+
<RestrictedState
|
|
4535
|
+
kindLabel={selectedKind.kind}
|
|
4536
|
+
group={selectedKind.group}
|
|
4537
|
+
resource={selectedKind.name}
|
|
4538
|
+
reason={resourceReasons?.[selectedKindCountKey]}
|
|
4539
|
+
/>
|
|
4540
|
+
</div>
|
|
4512
4541
|
</div>
|
|
4513
4542
|
) : largeListGuard ? (
|
|
4514
4543
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
|
|
@@ -128,7 +128,7 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
|
|
|
128
128
|
{hasNoSelector && !isExternalName && (
|
|
129
129
|
<Section title="EndpointSlices" icon={Radio}>
|
|
130
130
|
{endpointSlicesLoading ? (
|
|
131
|
-
<div className="text-sm text-theme-text-tertiary">Loading EndpointSlices
|
|
131
|
+
<div className="text-sm text-theme-text-tertiary">Loading EndpointSlices…</div>
|
|
132
132
|
) : endpointSlices && endpointSlices.length > 0 ? (
|
|
133
133
|
<div className="space-y-2">
|
|
134
134
|
{endpointSlices.map((slice: any) => {
|
|
@@ -422,7 +422,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
422
422
|
|
|
423
423
|
function ScalerDiagnosisRow({ entry }: { entry: ScalerDiagnosis }) {
|
|
424
424
|
if (entry.loading) {
|
|
425
|
-
return <div className="text-xs text-theme-text-tertiary">Loading autoscaler diagnosis
|
|
425
|
+
return <div className="text-xs text-theme-text-tertiary">Loading autoscaler diagnosis…</div>
|
|
426
426
|
}
|
|
427
427
|
if (entry.error) {
|
|
428
428
|
return <div className="text-xs text-theme-text-tertiary">Autoscaler diagnosis unavailable</div>
|
|
@@ -907,7 +907,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
907
907
|
<div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
|
|
908
908
|
{isLoading && (
|
|
909
909
|
<div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
|
|
910
|
-
Loading revisions
|
|
910
|
+
Loading revisions…
|
|
911
911
|
</div>
|
|
912
912
|
)}
|
|
913
913
|
|
|
@@ -30,7 +30,7 @@ import { K8sResourceNode } from './K8sResourceNode'
|
|
|
30
30
|
import { GroupNode } from './GroupNode'
|
|
31
31
|
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
32
32
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
33
|
-
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
|
|
33
|
+
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
|
|
34
34
|
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
35
35
|
import { pluralize } from '../../utils/pluralize'
|
|
36
36
|
import { foldHash } from '../../utils/structure-hash'
|
|
@@ -94,7 +94,9 @@ function buildEdges(
|
|
|
94
94
|
groupingMode: GroupingMode,
|
|
95
95
|
isTrafficView: boolean,
|
|
96
96
|
nodeToGroup?: Map<string, string>,
|
|
97
|
-
nodeCount?: number
|
|
97
|
+
nodeCount?: number,
|
|
98
|
+
groupLevels?: Map<string, GroupDisplayLevel>,
|
|
99
|
+
smartDefaultActive = false,
|
|
98
100
|
): Edge[] {
|
|
99
101
|
const edges: Edge[] = []
|
|
100
102
|
const seenEdgeIds = new Set<string>() // O(1) duplicate detection
|
|
@@ -117,15 +119,17 @@ function buildEdges(
|
|
|
117
119
|
let source = edge.source
|
|
118
120
|
let target = edge.target
|
|
119
121
|
|
|
120
|
-
// If source is in a collapsed group, point to the group instead
|
|
122
|
+
// If source is in a collapsed group, point to the group instead. Same
|
|
123
|
+
// predicate as ELK node placement (isGroupEffectivelyCollapsed) so a
|
|
124
|
+
// rendered edge never references a member hidden inside a chip.
|
|
121
125
|
const sourceGroup = nodeGroupMap.get(source)
|
|
122
|
-
if (sourceGroup &&
|
|
126
|
+
if (sourceGroup && isGroupEffectivelyCollapsed(sourceGroup, collapsedGroups, groupLevels, smartDefaultActive)) {
|
|
123
127
|
source = sourceGroup
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
// If target is in a collapsed group, point to the group instead
|
|
127
131
|
const targetGroup = nodeGroupMap.get(target)
|
|
128
|
-
if (targetGroup &&
|
|
132
|
+
if (targetGroup && isGroupEffectivelyCollapsed(targetGroup, collapsedGroups, groupLevels, smartDefaultActive)) {
|
|
129
133
|
target = targetGroup
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -607,13 +611,20 @@ export function TopologyGraph({
|
|
|
607
611
|
// Increment version to invalidate any previous in-flight layout
|
|
608
612
|
const thisLayoutVersion = ++layoutVersionRef.current
|
|
609
613
|
|
|
614
|
+
// The smart-default chip pass only ever materializes namespace groups, so its
|
|
615
|
+
// "no-entry group defaults to collapsed" semantics apply only in namespace
|
|
616
|
+
// mode. Outside it (small clusters, app grouping) a no-entry group stays
|
|
617
|
+
// expanded — see isGroupEffectivelyCollapsed.
|
|
618
|
+
const smartDefaultActive = hasAppliedSmartDefaultRef.current && groupingMode === 'namespace'
|
|
619
|
+
|
|
610
620
|
// Build hierarchical ELK graph
|
|
611
621
|
const { elkGraph, groupMap, nodeToGroup } = buildHierarchicalElkGraph(
|
|
612
622
|
workingNodes,
|
|
613
623
|
workingEdges,
|
|
614
624
|
groupingMode,
|
|
615
625
|
collapsedGroups,
|
|
616
|
-
groupLevels
|
|
626
|
+
groupLevels,
|
|
627
|
+
smartDefaultActive
|
|
617
628
|
)
|
|
618
629
|
groupMapRef.current = groupMap
|
|
619
630
|
|
|
@@ -711,7 +722,9 @@ export function TopologyGraph({
|
|
|
711
722
|
groupingMode,
|
|
712
723
|
isTrafficView,
|
|
713
724
|
nodeToGroup,
|
|
714
|
-
nodesWithHandlers.length
|
|
725
|
+
nodesWithHandlers.length,
|
|
726
|
+
groupLevels,
|
|
727
|
+
smartDefaultActive
|
|
715
728
|
)
|
|
716
729
|
setEdges(builtEdges)
|
|
717
730
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { buildHierarchicalElkGraph, applyHierarchicalLayout, buildInterGroupEdges, setLayoutEngine, type GroupDisplayLevel } from './layout'
|
|
3
|
+
import type { TopologyNode, TopologyEdge } from '../../types'
|
|
4
|
+
|
|
5
|
+
// Collect every id ELK will see as a layoutable shape: top-level children plus
|
|
6
|
+
// the members of expanded groups. An edge endpoint outside this set is exactly
|
|
7
|
+
// what makes ELK throw "Referenced shape does not exist".
|
|
8
|
+
function validEndpointIds(elkGraph: { children: Array<{ id: string; children?: Array<{ id: string }> }> }): Set<string> {
|
|
9
|
+
const ids = new Set<string>()
|
|
10
|
+
for (const child of elkGraph.children) {
|
|
11
|
+
ids.add(child.id)
|
|
12
|
+
for (const c of child.children ?? []) ids.add(c.id)
|
|
13
|
+
}
|
|
14
|
+
return ids
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deployment(ns: string, name: string): TopologyNode {
|
|
18
|
+
return {
|
|
19
|
+
id: `deployment/${ns}/${name}`,
|
|
20
|
+
kind: 'Deployment',
|
|
21
|
+
name,
|
|
22
|
+
status: 'healthy',
|
|
23
|
+
data: { namespace: ns },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('buildHierarchicalElkGraph — collapse predicate consistency', () => {
|
|
28
|
+
// Reproduces the Resources→Traffic crash: smart-default chipped the namespaces
|
|
29
|
+
// present at the time (app1), then a view switch surfaced a namespace
|
|
30
|
+
// (skyhook-gateway) with no groupLevels entry. Node placement hid its members
|
|
31
|
+
// (treated as collapsed) but the old edge-redirect only fired for groups in
|
|
32
|
+
// collapsedGroups — leaving an edge pointed at a hidden plain node id.
|
|
33
|
+
it('redirects edges into a late-arriving (no-level) group so no edge dangles', () => {
|
|
34
|
+
const nodes: TopologyNode[] = [
|
|
35
|
+
deployment('app1', 'web'),
|
|
36
|
+
deployment('skyhook-gateway', 'skyhook-frpc'),
|
|
37
|
+
]
|
|
38
|
+
const edges: TopologyEdge[] = [
|
|
39
|
+
{ id: 'e1', source: 'deployment/app1/web', target: 'deployment/skyhook-gateway/skyhook-frpc', type: 'routes-to' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// Smart default chipped app1 only; skyhook-gateway appeared later → no entry.
|
|
43
|
+
const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
|
|
44
|
+
// collapsedGroups mirrors TopologyGraph: only explicit non-'topology' levels.
|
|
45
|
+
const collapsedGroups = new Set<string>(['group-namespace-app1'])
|
|
46
|
+
|
|
47
|
+
// smartDefaultActive=true: the large-cluster chip pass ran, so the
|
|
48
|
+
// late-arriving skyhook-gateway defaults to collapsed.
|
|
49
|
+
const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, true)
|
|
50
|
+
|
|
51
|
+
const valid = validEndpointIds(elkGraph)
|
|
52
|
+
for (const edge of elkGraph.edges) {
|
|
53
|
+
expect(valid.has(edge.sources[0]), `source ${edge.sources[0]} must exist`).toBe(true)
|
|
54
|
+
expect(valid.has(edge.targets[0]), `target ${edge.targets[0]} must exist`).toBe(true)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The plain hidden member id must never survive as an endpoint.
|
|
58
|
+
const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
|
|
59
|
+
expect(endpoints).not.toContain('deployment/skyhook-gateway/skyhook-frpc')
|
|
60
|
+
expect(endpoints).toContain('group-namespace-skyhook-gateway')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Without smart-default (small clusters, manual toggles), collapsing one group
|
|
64
|
+
// must NOT cascade-collapse untouched no-entry groups. app2 has no level entry;
|
|
65
|
+
// since smartDefaultActive is false it stays expanded and its member renders.
|
|
66
|
+
it('does not cascade-collapse no-entry groups when smart-default is inactive', () => {
|
|
67
|
+
const nodes: TopologyNode[] = [
|
|
68
|
+
deployment('app1', 'web'),
|
|
69
|
+
deployment('app2', 'api'),
|
|
70
|
+
]
|
|
71
|
+
const edges: TopologyEdge[] = [
|
|
72
|
+
{ id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
|
|
73
|
+
]
|
|
74
|
+
// User collapsed only app1; app2 untouched (no entry).
|
|
75
|
+
const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
|
|
76
|
+
const collapsedGroups = new Set<string>(['group-namespace-app1'])
|
|
77
|
+
|
|
78
|
+
const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, false)
|
|
79
|
+
|
|
80
|
+
// app2 stays expanded with its member as a child.
|
|
81
|
+
const app2 = elkGraph.children.find(c => c.id === 'group-namespace-app2')
|
|
82
|
+
expect(app2?.children?.some(c => c.id === 'deployment/app2/api')).toBe(true)
|
|
83
|
+
|
|
84
|
+
// Edge stays valid: app1 redirected to its chip, app2 member kept (it exists).
|
|
85
|
+
const valid = validEndpointIds(elkGraph)
|
|
86
|
+
for (const edge of elkGraph.edges) {
|
|
87
|
+
expect(valid.has(edge.sources[0])).toBe(true)
|
|
88
|
+
expect(valid.has(edge.targets[0])).toBe(true)
|
|
89
|
+
}
|
|
90
|
+
const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
|
|
91
|
+
expect(endpoints).toContain('group-namespace-app1')
|
|
92
|
+
expect(endpoints).toContain('deployment/app2/api')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps edges between expanded groups referencing plain member ids', () => {
|
|
96
|
+
const nodes: TopologyNode[] = [
|
|
97
|
+
deployment('app1', 'web'),
|
|
98
|
+
deployment('app2', 'api'),
|
|
99
|
+
]
|
|
100
|
+
const edges: TopologyEdge[] = [
|
|
101
|
+
{ id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
|
|
102
|
+
]
|
|
103
|
+
// Both groups explicitly expanded.
|
|
104
|
+
const groupLevels = new Map<string, GroupDisplayLevel>([
|
|
105
|
+
['group-namespace-app1', 'topology'],
|
|
106
|
+
['group-namespace-app2', 'topology'],
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', new Set(), groupLevels)
|
|
110
|
+
|
|
111
|
+
const valid = validEndpointIds(elkGraph)
|
|
112
|
+
for (const edge of elkGraph.edges) {
|
|
113
|
+
expect(valid.has(edge.sources[0])).toBe(true)
|
|
114
|
+
expect(valid.has(edge.targets[0])).toBe(true)
|
|
115
|
+
}
|
|
116
|
+
const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
|
|
117
|
+
expect(endpoints).toContain('deployment/app1/web')
|
|
118
|
+
expect(endpoints).toContain('deployment/app2/api')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('produces no dangling endpoints with no grouping', () => {
|
|
122
|
+
const nodes: TopologyNode[] = [deployment('app1', 'web'), deployment('app1', 'api')]
|
|
123
|
+
const edges: TopologyEdge[] = [
|
|
124
|
+
{ id: 'e1', source: 'deployment/app1/web', target: 'deployment/app1/api', type: 'routes-to' },
|
|
125
|
+
]
|
|
126
|
+
const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'none', new Set(), new Map())
|
|
127
|
+
const valid = validEndpointIds(elkGraph)
|
|
128
|
+
for (const edge of elkGraph.edges) {
|
|
129
|
+
expect(valid.has(edge.sources[0])).toBe(true)
|
|
130
|
+
expect(valid.has(edge.targets[0])).toBe(true)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Phase-2 meta layout: edges that position groups relative to each other must
|
|
136
|
+
// survive even when both endpoints are already meta nodes (two collapsed chips, or
|
|
137
|
+
// a chip and an ungrouped node). The old branching dropped those, so connected
|
|
138
|
+
// chips weren't pulled together.
|
|
139
|
+
describe('buildInterGroupEdges — meta-graph connectivity', () => {
|
|
140
|
+
const edge = (id: string, source: string, target: string) => ({ id, sources: [source], targets: [target] })
|
|
141
|
+
|
|
142
|
+
it('keeps chip↔chip edges (both endpoints already meta nodes)', () => {
|
|
143
|
+
const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
|
|
144
|
+
// Neither endpoint is an expanded-group member, so nodeToGroup is empty.
|
|
145
|
+
const out = buildInterGroupEdges([edge('e1', 'group-namespace-app1', 'group-namespace-app2')], new Map(), metaIds)
|
|
146
|
+
expect(out).toHaveLength(1)
|
|
147
|
+
expect(out[0]).toMatchObject({ sources: ['group-namespace-app1'], targets: ['group-namespace-app2'] })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('keeps chip↔ungrouped edges', () => {
|
|
151
|
+
const metaIds = new Set(['group-namespace-app1', 'orphan-node'])
|
|
152
|
+
const out = buildInterGroupEdges([edge('e1', 'group-namespace-app1', 'orphan-node')], new Map(), metaIds)
|
|
153
|
+
expect(out).toHaveLength(1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('normalizes expanded-group members to their group', () => {
|
|
157
|
+
const nodeToGroup = new Map([
|
|
158
|
+
['deployment/app1/web', 'group-namespace-app1'],
|
|
159
|
+
['deployment/app2/api', 'group-namespace-app2'],
|
|
160
|
+
])
|
|
161
|
+
const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
|
|
162
|
+
const out = buildInterGroupEdges([edge('e1', 'deployment/app1/web', 'deployment/app2/api')], nodeToGroup, metaIds)
|
|
163
|
+
expect(out[0]).toMatchObject({ sources: ['group-namespace-app1'], targets: ['group-namespace-app2'] })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('drops intra-group edges and endpoints absent from the meta graph', () => {
|
|
167
|
+
const nodeToGroup = new Map([
|
|
168
|
+
['deployment/app1/web', 'group-namespace-app1'],
|
|
169
|
+
['deployment/app1/api', 'group-namespace-app1'],
|
|
170
|
+
])
|
|
171
|
+
const metaIds = new Set(['group-namespace-app1'])
|
|
172
|
+
// Same group on both ends → intra, skip. And an edge to a non-meta id → skip.
|
|
173
|
+
const out = buildInterGroupEdges([
|
|
174
|
+
edge('e1', 'deployment/app1/web', 'deployment/app1/api'),
|
|
175
|
+
edge('e2', 'group-namespace-app1', 'ghost-node'),
|
|
176
|
+
], nodeToGroup, metaIds)
|
|
177
|
+
expect(out).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('dedupes edges collapsing to the same meta pair', () => {
|
|
181
|
+
const nodeToGroup = new Map([
|
|
182
|
+
['deployment/app1/web', 'group-namespace-app1'],
|
|
183
|
+
['deployment/app1/api', 'group-namespace-app1'],
|
|
184
|
+
['deployment/app2/x', 'group-namespace-app2'],
|
|
185
|
+
])
|
|
186
|
+
const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
|
|
187
|
+
const out = buildInterGroupEdges([
|
|
188
|
+
edge('e1', 'deployment/app1/web', 'deployment/app2/x'),
|
|
189
|
+
edge('e2', 'deployment/app1/api', 'deployment/app2/x'),
|
|
190
|
+
], nodeToGroup, metaIds)
|
|
191
|
+
expect(out).toHaveLength(1)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Render layer: the GroupNode's displayLevel must agree with ELK placement, or a
|
|
196
|
+
// chip renders over its own laid-out children. group.isCollapsed (from the layout
|
|
197
|
+
// engine) is the single source of truth.
|
|
198
|
+
describe('applyHierarchicalLayout — rendered displayLevel matches placement', () => {
|
|
199
|
+
beforeAll(() => setLayoutEngine('main-thread'))
|
|
200
|
+
|
|
201
|
+
const noop = () => {}
|
|
202
|
+
const callbacks = { onSetLevel: noop, onCardClick: noop }
|
|
203
|
+
|
|
204
|
+
it('renders an untouched no-entry group as topology (not chip) on manual collapse', async () => {
|
|
205
|
+
const nodes: TopologyNode[] = [deployment('app1', 'web'), deployment('app2', 'api')]
|
|
206
|
+
const edges: TopologyEdge[] = [
|
|
207
|
+
{ id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
|
|
208
|
+
]
|
|
209
|
+
// Small cluster, smart-default inactive: user collapsed only app1.
|
|
210
|
+
const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
|
|
211
|
+
const collapsedGroups = new Set<string>(['group-namespace-app1'])
|
|
212
|
+
|
|
213
|
+
const { elkGraph, groupMap } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, false)
|
|
214
|
+
const { nodes: rendered } = await applyHierarchicalLayout(
|
|
215
|
+
elkGraph, nodes, edges, groupMap, 'namespace', collapsedGroups, callbacks, false, groupLevels,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const groupNode = (id: string) => rendered.find(n => n.id === id && n.type === 'group')
|
|
219
|
+
expect(groupNode('group-namespace-app1')?.data.displayLevel).toBe('chip')
|
|
220
|
+
expect(groupNode('group-namespace-app2')?.data.displayLevel).toBe('topology')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -245,19 +245,11 @@ async function runLayoutOnMainThread(
|
|
|
245
245
|
|
|
246
246
|
// Phase 2: Build meta-graph and position groups based on inter-group edges
|
|
247
247
|
// (nodeToGroup was built once above).
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (sg && tg && sg !== tg) {
|
|
254
|
-
const key = `${sg}->${tg}`
|
|
255
|
-
if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [sg], targets: [tg] }) }
|
|
256
|
-
} else if ((!sg && tg) || (sg && !tg)) {
|
|
257
|
-
const s = sg || edge.sources[0], t = tg || edge.targets[0], key = `${s}->${t}`
|
|
258
|
-
if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [s], targets: [t] }) }
|
|
259
|
-
}
|
|
260
|
-
}
|
|
248
|
+
const metaIds = new Set<string>([
|
|
249
|
+
...groupLayouts.map(g => g.groupId),
|
|
250
|
+
...ungroupedNodes.map(n => n.id),
|
|
251
|
+
])
|
|
252
|
+
const interGroupEdges = buildInterGroupEdges(elkGraph.edges, nodeToGroup, metaIds)
|
|
261
253
|
|
|
262
254
|
const metaResult = await elk.layout({
|
|
263
255
|
id: 'meta-root',
|
|
@@ -323,6 +315,36 @@ interface ElkGraph {
|
|
|
323
315
|
edges: ElkEdge[]
|
|
324
316
|
}
|
|
325
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Build the meta-graph edges that position groups relative to each other (Phase 2).
|
|
320
|
+
* Each endpoint is normalized to its meta node: an expanded group's member maps to
|
|
321
|
+
* its group (via nodeToGroup); a collapsed-group chip id or an ungrouped node id is
|
|
322
|
+
* ALREADY a meta node and is used as-is. Edges between two already-meta nodes
|
|
323
|
+
* (chip↔chip, chip↔ungrouped) must still be included — dropping them loses the
|
|
324
|
+
* connectivity ELK uses to place connected groups near each other. Endpoints absent
|
|
325
|
+
* from metaIds are skipped (defensive — keeps the meta graph importable).
|
|
326
|
+
*
|
|
327
|
+
* Mirrored inline in layout.worker.ts, which is intentionally self-contained.
|
|
328
|
+
*/
|
|
329
|
+
export function buildInterGroupEdges(
|
|
330
|
+
edges: ElkEdge[],
|
|
331
|
+
nodeToGroup: Map<string, string>,
|
|
332
|
+
metaIds: Set<string>,
|
|
333
|
+
): ElkEdge[] {
|
|
334
|
+
const out: ElkEdge[] = []
|
|
335
|
+
const seen = new Set<string>()
|
|
336
|
+
for (const edge of edges) {
|
|
337
|
+
const s = nodeToGroup.get(edge.sources[0]) ?? edge.sources[0]
|
|
338
|
+
const t = nodeToGroup.get(edge.targets[0]) ?? edge.targets[0]
|
|
339
|
+
if (s === t || !metaIds.has(s) || !metaIds.has(t)) continue
|
|
340
|
+
const key = `${s}->${t}`
|
|
341
|
+
if (seen.has(key)) continue
|
|
342
|
+
seen.add(key)
|
|
343
|
+
out.push({ id: `inter-${key}`, sources: [s], targets: [t] })
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
326
348
|
// Get app label from a node (if it has one)
|
|
327
349
|
function getAppLabel(node: TopologyNode): string | null {
|
|
328
350
|
const labels = (node.data.labels as Record<string, string>) || {}
|
|
@@ -459,13 +481,41 @@ function pickGroupName(nodes: TopologyNode[]): string {
|
|
|
459
481
|
return sorted[0].name
|
|
460
482
|
}
|
|
461
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Whether a group renders as a single collapsed chip/card (members hidden) vs an
|
|
486
|
+
* expanded container. This is the ONE predicate that node placement, ELK edge
|
|
487
|
+
* redirect, and ReactFlow edge building must all agree on — if they diverge, an
|
|
488
|
+
* edge can reference a member hidden inside a chip, which either crashes ELK
|
|
489
|
+
* ("Referenced shape does not exist") or silently drops the rendered edge.
|
|
490
|
+
*
|
|
491
|
+
* - Explicit chip/cardGrid levels are always collapsed (they're in collapsedGroups).
|
|
492
|
+
* - When the smart-default chip pass is active (large clusters), a group with no
|
|
493
|
+
* level entry is a late arrival and defaults to collapsed — e.g. a namespace
|
|
494
|
+
* that only surfaces after switching Resources↔Traffic. When it is NOT active
|
|
495
|
+
* (small clusters, manual per-group toggles), a no-entry group stays expanded,
|
|
496
|
+
* so collapsing one group never cascades to untouched groups.
|
|
497
|
+
*/
|
|
498
|
+
export function isGroupEffectivelyCollapsed(
|
|
499
|
+
groupId: string,
|
|
500
|
+
collapsedGroups: Set<string>,
|
|
501
|
+
groupLevels: Map<string, GroupDisplayLevel> | undefined,
|
|
502
|
+
smartDefaultActive: boolean,
|
|
503
|
+
): boolean {
|
|
504
|
+
if (collapsedGroups.has(groupId)) return true
|
|
505
|
+
if (smartDefaultActive && groupLevels?.get(groupId) !== 'topology') return true
|
|
506
|
+
return false
|
|
507
|
+
}
|
|
508
|
+
|
|
462
509
|
// Build hierarchical ELK graph with groups containing children
|
|
463
510
|
export function buildHierarchicalElkGraph(
|
|
464
511
|
topologyNodes: TopologyNode[],
|
|
465
512
|
edges: Array<{ id: string; source: string; target: string; type: string }>,
|
|
466
513
|
groupingMode: GroupingMode,
|
|
467
514
|
collapsedGroups: Set<string>,
|
|
468
|
-
groupLevels?: Map<string, GroupDisplayLevel
|
|
515
|
+
groupLevels?: Map<string, GroupDisplayLevel>,
|
|
516
|
+
/** True when the large-cluster smart-default chip pass has materialized levels;
|
|
517
|
+
* makes no-entry groups default to collapsed (see isGroupEffectivelyCollapsed). */
|
|
518
|
+
smartDefaultActive = false,
|
|
469
519
|
): { elkGraph: ElkGraph; groupMap: Map<string, string[]>; nodeToGroup: Map<string, string> } {
|
|
470
520
|
const groupMap = new Map<string, string[]>()
|
|
471
521
|
const nodeToGroup = new Map<string, string>()
|
|
@@ -496,6 +546,11 @@ export function buildHierarchicalElkGraph(
|
|
|
496
546
|
}
|
|
497
547
|
}
|
|
498
548
|
|
|
549
|
+
// Node placement and edge redirect MUST share this predicate — see
|
|
550
|
+
// isGroupEffectivelyCollapsed for why.
|
|
551
|
+
const isGroupCollapsed = (groupId: string): boolean =>
|
|
552
|
+
isGroupEffectivelyCollapsed(groupId, collapsedGroups, groupLevels, smartDefaultActive)
|
|
553
|
+
|
|
499
554
|
const children: ElkNode[] = []
|
|
500
555
|
const processedNodes = new Set<string>()
|
|
501
556
|
|
|
@@ -516,16 +571,7 @@ export function buildHierarchicalElkGraph(
|
|
|
516
571
|
// Create group nodes with children
|
|
517
572
|
for (const [groupKey, memberIds] of groupMap) {
|
|
518
573
|
const groupId = `group-${groupingMode}-${groupKey}`
|
|
519
|
-
|
|
520
|
-
// an explicit 'topology' level is collapsed. This prevents late-arriving namespaces
|
|
521
|
-
// from defaulting to expanded and overlapping with collapsed chips.
|
|
522
|
-
// Only apply when levels exist for the same grouping prefix — don't let namespace
|
|
523
|
-
// levels leak into app/label grouping contexts.
|
|
524
|
-
const groupPrefix = `group-${groupingMode}-`
|
|
525
|
-
const hasLevelsForCurrentMode = groupLevels && groupLevels.size > 0 &&
|
|
526
|
-
[...groupLevels.keys()].some(k => k.startsWith(groupPrefix))
|
|
527
|
-
const isCollapsed = collapsedGroups.has(groupId) ||
|
|
528
|
-
(hasLevelsForCurrentMode && groupLevels!.get(groupId) !== 'topology')
|
|
574
|
+
const isCollapsed = isGroupCollapsed(groupId)
|
|
529
575
|
|
|
530
576
|
if (isCollapsed) {
|
|
531
577
|
const displayLevel = groupLevels?.get(groupId) || 'chip'
|
|
@@ -623,6 +669,18 @@ export function buildHierarchicalElkGraph(
|
|
|
623
669
|
}
|
|
624
670
|
}
|
|
625
671
|
|
|
672
|
+
// Every ELK edge endpoint must reference a node present in the graph (a
|
|
673
|
+
// top-level child, or a member of an expanded group). A single dangling
|
|
674
|
+
// reference makes ELK reject the whole import, blanking the topology — so we
|
|
675
|
+
// drop the stray edge instead. With the unified isGroupCollapsed predicate
|
|
676
|
+
// this should never fire; it's insurance against future placement/redirect
|
|
677
|
+
// drift.
|
|
678
|
+
const validEndpointIds = new Set<string>()
|
|
679
|
+
for (const child of children) {
|
|
680
|
+
validEndpointIds.add(child.id)
|
|
681
|
+
if (child.children) for (const c of child.children) validEndpointIds.add(c.id)
|
|
682
|
+
}
|
|
683
|
+
|
|
626
684
|
// Build edges, redirecting to groups when collapsed
|
|
627
685
|
const elkEdges: ElkEdge[] = []
|
|
628
686
|
const seenEdges = new Set<string>()
|
|
@@ -631,17 +689,21 @@ export function buildHierarchicalElkGraph(
|
|
|
631
689
|
let source = edge.source
|
|
632
690
|
let target = edge.target
|
|
633
691
|
|
|
634
|
-
// Redirect edges to collapsed groups
|
|
692
|
+
// Redirect edges to collapsed groups — same predicate as node placement so
|
|
693
|
+
// an edge never references a member hidden inside a chip.
|
|
635
694
|
const sourceGroup = nodeToGroup.get(source)
|
|
636
|
-
if (sourceGroup &&
|
|
695
|
+
if (sourceGroup && isGroupCollapsed(sourceGroup)) {
|
|
637
696
|
source = sourceGroup
|
|
638
697
|
}
|
|
639
698
|
|
|
640
699
|
const targetGroup = nodeToGroup.get(target)
|
|
641
|
-
if (targetGroup &&
|
|
700
|
+
if (targetGroup && isGroupCollapsed(targetGroup)) {
|
|
642
701
|
target = targetGroup
|
|
643
702
|
}
|
|
644
703
|
|
|
704
|
+
// Drop edges whose endpoints aren't in the graph (see validEndpointIds)
|
|
705
|
+
if (!validEndpointIds.has(source) || !validEndpointIds.has(target)) continue
|
|
706
|
+
|
|
645
707
|
// Skip self-loops
|
|
646
708
|
if (source === target) continue
|
|
647
709
|
|
|
@@ -865,12 +927,13 @@ export async function applyHierarchicalLayout(
|
|
|
865
927
|
positions.set(group.groupId, pos)
|
|
866
928
|
|
|
867
929
|
const { worstStatus, unhealthyCount } = computeGroupHealth(memberIds, nodeMap)
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
930
|
+
// Display level derives from the SAME source of truth as ELK node placement:
|
|
931
|
+
// group.isCollapsed reflects buildHierarchicalElkGraph's isGroupEffectivelyCollapsed
|
|
932
|
+
// decision (an expanded group was laid out with its children). Recomputing a
|
|
933
|
+
// separate "default" here would let the rendered chip disagree with the laid-out
|
|
934
|
+
// children — e.g. a manual single collapse rendering untouched groups as chips
|
|
935
|
+
// while their children are still placed. An explicit level (e.g. cardGrid) wins.
|
|
936
|
+
const displayLevel: GroupDisplayLevel = groupLevels?.get(group.groupId) || (group.isCollapsed ? 'chip' : 'topology')
|
|
874
937
|
|
|
875
938
|
// Compute kind breakdown for collapsed chips
|
|
876
939
|
const kindCounts: Record<string, number> = {}
|
|
@@ -198,44 +198,28 @@ self.onmessage = async (e: MessageEvent<LayoutRequest>) => {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// Phase 2: Build meta-graph and position groups (nodeToGroup built once above).
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const sourceGroup = nodeToGroup.get(edge.sources[0])
|
|
207
|
-
const targetGroup = nodeToGroup.get(edge.targets[0])
|
|
208
|
-
|
|
209
|
-
if (sourceGroup && targetGroup && sourceGroup !== targetGroup) {
|
|
210
|
-
const edgeKey = `${sourceGroup}->${targetGroup}`
|
|
211
|
-
if (!seenInterGroupEdges.has(edgeKey)) {
|
|
212
|
-
seenInterGroupEdges.add(edgeKey)
|
|
213
|
-
interGroupEdges.push({
|
|
214
|
-
id: `inter-${edgeKey}`,
|
|
215
|
-
sources: [sourceGroup],
|
|
216
|
-
targets: [targetGroup],
|
|
217
|
-
})
|
|
218
|
-
}
|
|
219
|
-
} else if ((!sourceGroup && targetGroup) || (sourceGroup && !targetGroup)) {
|
|
220
|
-
const source = sourceGroup || edge.sources[0]
|
|
221
|
-
const target = targetGroup || edge.targets[0]
|
|
222
|
-
const edgeKey = `${source}->${target}`
|
|
223
|
-
if (!seenInterGroupEdges.has(edgeKey)) {
|
|
224
|
-
seenInterGroupEdges.add(edgeKey)
|
|
225
|
-
interGroupEdges.push({
|
|
226
|
-
id: `inter-${edgeKey}`,
|
|
227
|
-
sources: [source],
|
|
228
|
-
targets: [target],
|
|
229
|
-
})
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Build and layout meta-graph
|
|
201
|
+
// Canonical version: buildInterGroupEdges in layout.ts — kept inline here because
|
|
202
|
+
// the worker is intentionally self-contained. Normalize each endpoint to its meta
|
|
203
|
+
// node (expanded member → its group; a chip id or ungrouped id is already a meta
|
|
204
|
+
// node) and keep edges between two already-meta nodes (chip↔chip, chip↔ungrouped),
|
|
205
|
+
// which the old branching dropped — losing the connectivity used to place groups.
|
|
235
206
|
const metaChildren: ElkNode[] = [
|
|
236
207
|
...groupLayouts.map(g => ({ id: g.groupId, width: g.width, height: g.height })),
|
|
237
208
|
...ungroupedNodes.map(n => ({ id: n.id, width: n.width, height: n.height })),
|
|
238
209
|
]
|
|
210
|
+
const metaIds = new Set<string>(metaChildren.map(c => c.id))
|
|
211
|
+
|
|
212
|
+
const interGroupEdges: ElkEdge[] = []
|
|
213
|
+
const seenInterGroupEdges = new Set<string>()
|
|
214
|
+
for (const edge of elkGraph.edges) {
|
|
215
|
+
const source = nodeToGroup.get(edge.sources[0]) ?? edge.sources[0]
|
|
216
|
+
const target = nodeToGroup.get(edge.targets[0]) ?? edge.targets[0]
|
|
217
|
+
if (source === target || !metaIds.has(source) || !metaIds.has(target)) continue
|
|
218
|
+
const edgeKey = `${source}->${target}`
|
|
219
|
+
if (seenInterGroupEdges.has(edgeKey)) continue
|
|
220
|
+
seenInterGroupEdges.add(edgeKey)
|
|
221
|
+
interGroupEdges.push({ id: `inter-${edgeKey}`, sources: [source], targets: [target] })
|
|
222
|
+
}
|
|
239
223
|
|
|
240
224
|
const metaGraph: ElkGraph = {
|
|
241
225
|
id: 'meta-root',
|
|
@@ -391,7 +391,7 @@ export function CodeViewer({
|
|
|
391
391
|
style={{ maxHeight }}
|
|
392
392
|
>
|
|
393
393
|
{highlighting ? (
|
|
394
|
-
<div className="p-4 text-theme-text-tertiary text-sm font-mono">Loading
|
|
394
|
+
<div className="p-4 text-theme-text-tertiary text-sm font-mono">Loading…</div>
|
|
395
395
|
) : (
|
|
396
396
|
<div
|
|
397
397
|
ref={contentRef}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Shield, ChevronDown, Copy, Check } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
// RestrictedState is the shared "you can't see this because of Kubernetes RBAC"
|
|
6
|
+
// surface — distinct from EmptyState (which covers healthy / filtered / no-data).
|
|
7
|
+
// It never claims WHY the SAR failed (Radar can't prove tier vs custom-role vs
|
|
8
|
+
// disabled-value from a denied check); it states the fact and hands the operator
|
|
9
|
+
// something to forward to whoever administers their cluster.
|
|
10
|
+
//
|
|
11
|
+
// Reused across the resource list, topology, search, and the per-cluster access
|
|
12
|
+
// summary so the messaging can't drift.
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Display kind, e.g. "Node". */
|
|
16
|
+
kindLabel: string
|
|
17
|
+
/** API group for the kind ("" for core). Used to build the example RBAC. */
|
|
18
|
+
group?: string
|
|
19
|
+
/** Plural resource name, e.g. "nodes". When omitted the snippet uses a
|
|
20
|
+
* placeholder the admin fills in. */
|
|
21
|
+
resource?: string
|
|
22
|
+
/** Why the kind is hidden. "rbac_denied" (default): Radar can read it but the
|
|
23
|
+
* user's RBAC can't — show the grant request. "unavailable": Radar's
|
|
24
|
+
* ServiceAccount can't read it at all (not installed / SA RBAC / feature off)
|
|
25
|
+
* — a user grant won't help, so show a different message and no snippet. */
|
|
26
|
+
reason?: 'rbac_denied' | 'unavailable' | string
|
|
27
|
+
/** Tightens spacing for inline/embedded use (topology overlay, etc.). */
|
|
28
|
+
compact?: boolean
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildRbacRequest(group: string, resource: string): string {
|
|
33
|
+
// resource is the placeholder when we don't have a confident API plural
|
|
34
|
+
// (e.g. a CRD deep-link before discovery resolves the kind) — use a generic
|
|
35
|
+
// role name rather than radar-read-<Kind>.
|
|
36
|
+
const roleName = resource === '<resource>' ? 'radar-read-access' : `radar-read-${resource}`
|
|
37
|
+
return [
|
|
38
|
+
`apiVersion: rbac.authorization.k8s.io/v1`,
|
|
39
|
+
`kind: ClusterRole`,
|
|
40
|
+
`metadata:`,
|
|
41
|
+
` name: ${roleName}`,
|
|
42
|
+
`rules:`,
|
|
43
|
+
` - apiGroups: ["${group}"]`,
|
|
44
|
+
` resources: ["${resource}"]`,
|
|
45
|
+
` verbs: ["get", "list", "watch"]`,
|
|
46
|
+
`---`,
|
|
47
|
+
`apiVersion: rbac.authorization.k8s.io/v1`,
|
|
48
|
+
`kind: ClusterRoleBinding`,
|
|
49
|
+
`metadata:`,
|
|
50
|
+
` name: ${roleName}`,
|
|
51
|
+
`roleRef:`,
|
|
52
|
+
` apiGroup: rbac.authorization.k8s.io`,
|
|
53
|
+
` kind: ClusterRole`,
|
|
54
|
+
` name: ${roleName}`,
|
|
55
|
+
`subjects:`,
|
|
56
|
+
` - kind: Group # or User / ServiceAccount`,
|
|
57
|
+
` name: <your-identity>`,
|
|
58
|
+
` apiGroup: rbac.authorization.k8s.io`,
|
|
59
|
+
].join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) {
|
|
63
|
+
const [expanded, setExpanded] = useState(false)
|
|
64
|
+
const [copied, setCopied] = useState(false)
|
|
65
|
+
const isUnavailable = reason === 'unavailable'
|
|
66
|
+
|
|
67
|
+
// RBAC `resources` must be the lowercase API plural. selectedKind.name is
|
|
68
|
+
// that in normal use, but a CRD deep-link can transiently carry the Kind
|
|
69
|
+
// (e.g. "HTTPRoute") before discovery resolves it — emitting that would
|
|
70
|
+
// produce a snippet that doesn't grant access. Only inline the resource when
|
|
71
|
+
// it looks like a valid resource name; otherwise leave a clear placeholder.
|
|
72
|
+
const validResource = resource && /^[a-z0-9.-]+$/.test(resource) ? resource : '<resource>'
|
|
73
|
+
const snippet = buildRbacRequest(group, validResource)
|
|
74
|
+
|
|
75
|
+
const copy = () => {
|
|
76
|
+
navigator.clipboard.writeText(snippet).then(
|
|
77
|
+
() => {
|
|
78
|
+
setCopied(true)
|
|
79
|
+
setTimeout(() => setCopied(false), 2000)
|
|
80
|
+
},
|
|
81
|
+
() => {},
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className={clsx(
|
|
88
|
+
'flex flex-col items-center justify-center text-center text-theme-text-tertiary',
|
|
89
|
+
compact ? 'p-4' : 'p-6',
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<Shield className="w-8 h-8 text-amber-400 mb-2" />
|
|
94
|
+
{isUnavailable ? (
|
|
95
|
+
// Radar's ServiceAccount can't read this kind — a user grant won't help.
|
|
96
|
+
<>
|
|
97
|
+
<p className="text-theme-text-secondary font-medium">{kindLabel} isn't available here</p>
|
|
98
|
+
<p className="text-sm mt-1 max-w-md">
|
|
99
|
+
Radar can't read {kindLabel} resources in this cluster — the type may not be installed,
|
|
100
|
+
or read access isn't granted to Radar's ServiceAccount (some kinds, like RBAC objects
|
|
101
|
+
and Secrets, are off unless enabled in the Radar chart). Granting your own identity
|
|
102
|
+
access won't surface it.
|
|
103
|
+
</p>
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
<p className="text-theme-text-secondary font-medium">You don't have access to {kindLabel}</p>
|
|
108
|
+
<p className="text-sm mt-1 max-w-md">
|
|
109
|
+
Your Kubernetes RBAC doesn't allow listing {kindLabel} resources in this cluster. This
|
|
110
|
+
isn't an empty cluster — Radar is hiding what your identity can't read.
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
<div className="mt-3 w-full max-w-md">
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setExpanded((v) => !v)}
|
|
116
|
+
className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
117
|
+
>
|
|
118
|
+
<ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} />
|
|
119
|
+
How to get access
|
|
120
|
+
</button>
|
|
121
|
+
|
|
122
|
+
{expanded && (
|
|
123
|
+
<div className="mt-2 text-left">
|
|
124
|
+
<p className="text-xs text-theme-text-tertiary mb-2">
|
|
125
|
+
Apply this, or send it to whoever administers your cluster, to grant your identity
|
|
126
|
+
read access.
|
|
127
|
+
</p>
|
|
128
|
+
<div className="rounded-md border border-theme-border bg-theme-base overflow-hidden">
|
|
129
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-theme-border bg-theme-elevated">
|
|
130
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
131
|
+
ClusterRole + ClusterRoleBinding
|
|
132
|
+
</span>
|
|
133
|
+
<button
|
|
134
|
+
onClick={copy}
|
|
135
|
+
className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
136
|
+
>
|
|
137
|
+
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
138
|
+
{copied ? 'Copied' : 'Copy'}
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
<pre className="text-xs p-3 max-h-72 overflow-auto text-theme-text-secondary">
|
|
142
|
+
{snippet}
|
|
143
|
+
</pre>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -990,7 +990,7 @@ export function EventsSection({ events, updates = [], isLoading, eventsError, up
|
|
|
990
990
|
if (isLoading) {
|
|
991
991
|
return (
|
|
992
992
|
<Section title="Recent Events" defaultExpanded>
|
|
993
|
-
<div className="text-sm text-theme-text-tertiary">Loading events
|
|
993
|
+
<div className="text-sm text-theme-text-tertiary">Loading events…</div>
|
|
994
994
|
</Section>
|
|
995
995
|
)
|
|
996
996
|
}
|
|
@@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis'
|
|
|
5
5
|
export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
6
6
|
export { EmptyState } from './EmptyState'
|
|
7
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
8
|
+
export { RestrictedState } from './RestrictedState'
|
|
8
9
|
export { FetchResult } from './FetchResult'
|
|
9
10
|
export { SearchBox } from './SearchBox'
|
|
10
11
|
export { FilterPill } from './FilterPill'
|
|
@@ -1126,7 +1126,7 @@ function EventsTab({
|
|
|
1126
1126
|
return (
|
|
1127
1127
|
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1128
1128
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
1129
|
-
Loading events
|
|
1129
|
+
Loading events…
|
|
1130
1130
|
</div>
|
|
1131
1131
|
)
|
|
1132
1132
|
}
|