@skyhook-io/k8s-ui 1.8.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.4",
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": "^25.9.3",
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
- <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
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
- <div className="flex min-h-[240px] items-center justify-center text-theme-text-tertiary">
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
- <div className="flex flex-1 items-center justify-center text-theme-text-secondary">
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
- <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
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 <CenteredText>Loading GitOps resources...</CenteredText>
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 <CenteredText>Loading GitOps activity...</CenteredText>
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, Loader2, Maximize, Search, X } from 'lucide-react'
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...</span>
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
- <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
4509
- <Shield className="w-8 h-8 text-amber-400 mb-2" />
4510
- <p className="text-theme-text-secondary font-medium">Access Restricted</p>
4511
- <p className="text-sm mt-1">Insufficient permissions to list {selectedKind.kind} resources</p>
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...</div>
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...</div>
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 && collapsedGroups.has(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 && collapsedGroups.has(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 interGroupEdges: ElkEdge[] = []
249
- const seen = new Set<string>()
250
- for (const edge of elkGraph.edges) {
251
- const sg = nodeToGroup.get(edge.sources[0])
252
- const tg = nodeToGroup.get(edge.targets[0])
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
- // When groupLevels has entries for the CURRENT grouping mode, any group without
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 && collapsedGroups.has(sourceGroup)) {
695
+ if (sourceGroup && isGroupCollapsed(sourceGroup)) {
637
696
  source = sourceGroup
638
697
  }
639
698
 
640
699
  const targetGroup = nodeToGroup.get(target)
641
- if (targetGroup && collapsedGroups.has(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
- // Default to 'chip' when groupLevels has entries for the current grouping mode,
869
- // so late-arriving namespaces don't render as expanded topology and overlap with chips.
870
- const hasLevelsForMode = groupLevels && groupLevels.size > 0 &&
871
- [...groupLevels.keys()].some(k => k.startsWith(`group-${groupingMode}-`))
872
- const defaultLevel: GroupDisplayLevel = hasLevelsForMode ? 'chip' : 'topology'
873
- const displayLevel: GroupDisplayLevel = groupLevels?.get(group.groupId) || (group.isCollapsed ? 'chip' : defaultLevel)
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
- // Find inter-group edges
202
- const interGroupEdges: ElkEdge[] = []
203
- const seenInterGroupEdges = new Set<string>()
204
-
205
- for (const edge of elkGraph.edges) {
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...</div>
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
+ }
@@ -233,7 +233,7 @@ export function YamlEditor({
233
233
  }}
234
234
  loading={
235
235
  <div className="flex items-center justify-center h-full bg-theme-surface text-theme-text-secondary">
236
- Loading editor...
236
+ Loading editor
237
237
  </div>
238
238
  }
239
239
  />
@@ -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...</div>
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
  }