@skyhook-io/radar-app 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api/client.ts CHANGED
@@ -23,6 +23,8 @@ import type {
23
23
  InstallChartRequest,
24
24
  ArtifactHubSearchResult,
25
25
  ArtifactHubChartDetail,
26
+ GitOpsResourceTree,
27
+ GitOpsInsight,
26
28
  } from '../types'
27
29
  import type { GitOpsOperationResponse } from '../types/gitops'
28
30
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
@@ -256,6 +258,31 @@ export interface DashboardNetworkPolicyCoverage {
256
258
  totalWorkloads: number
257
259
  }
258
260
 
261
+ export interface DashboardGitOpsControllers {
262
+ // Aggregate roll-up across all detected controllers.
263
+ status: 'healthy' | 'degraded' | 'crashing'
264
+ controllers: DashboardGitOpsController[]
265
+ }
266
+
267
+ export interface DashboardGitOpsController {
268
+ name: string
269
+ // Tool vocabulary aligns with the backend `ctrlTool*` constants in
270
+ // internal/server/dashboard_gitops.go and the GitOps tree-builder
271
+ // tags in pkg/gitops/tree/graph.go (`gitopsTool`). Keep these three
272
+ // surfaces in sync — diverging vocabulary across surfaces was a real
273
+ // source of confusion until consolidated.
274
+ tool: 'argocd' | 'fluxcd'
275
+ namespace: string
276
+ ready: number
277
+ total: number
278
+ // Per-controller status (aggregate is in the parent and excludes
279
+ // 'pending' — it normalizes into 'degraded' there).
280
+ status: 'healthy' | 'degraded' | 'crashing' | 'pending'
281
+ // Reason for the crash when status === 'crashing'. Common values:
282
+ // "CrashLoopBackOff", "Error". Empty for non-crashing states.
283
+ crashReason?: string
284
+ }
285
+
259
286
  export interface DashboardResponse {
260
287
  cluster: DashboardCluster
261
288
  health: DashboardHealth
@@ -270,6 +297,7 @@ export interface DashboardResponse {
270
297
  certificateHealth: DashboardCertificateHealth | null
271
298
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
272
299
  audit: DashboardAudit | null
300
+ gitopsControllers: DashboardGitOpsControllers | null
273
301
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
274
302
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
303
  partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
@@ -783,6 +811,46 @@ export function useTopology(namespaces: string[], viewMode: string = 'resources'
783
811
  })
784
812
  }
785
813
 
814
+ export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
815
+ const ns = namespace || '_'
816
+ const params = new URLSearchParams()
817
+ if (group) params.set('group', group)
818
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
819
+ const queryString = params.toString()
820
+
821
+ return useQuery<GitOpsResourceTree>({
822
+ queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
823
+ queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
824
+ enabled: Boolean(kind && name),
825
+ staleTime: 5000,
826
+ })
827
+ }
828
+
829
+ // Poll fast (2s) while a sync/rollback is in flight so the user sees the
830
+ // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
831
+ // operationState.phase from "Running" -> Succeeded/Failed when done, so this
832
+ // auto-quiesces on completion.
833
+ const INSIGHTS_RUNNING_POLL_MS = 2000
834
+
835
+ export function useGitOpsInsights(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
836
+ const ns = namespace || '_'
837
+ const params = new URLSearchParams()
838
+ if (group) params.set('group', group)
839
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
840
+ const queryString = params.toString()
841
+
842
+ return useQuery<GitOpsInsight>({
843
+ queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
844
+ queryFn: () => fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
845
+ enabled: Boolean(kind && name),
846
+ staleTime: 5000,
847
+ refetchInterval: (query) => {
848
+ const phase = query.state.data?.summary?.operationPhase
849
+ return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
850
+ },
851
+ })
852
+ }
853
+
786
854
  // Generic resource fetching - returns resource with relationships
787
855
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
788
856
  export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
@@ -1348,6 +1416,7 @@ export interface AvailablePort {
1348
1416
  protocol: string
1349
1417
  containerName?: string
1350
1418
  name?: string
1419
+ scheme?: 'http' | 'https'
1351
1420
  }
1352
1421
 
1353
1422
  export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
@@ -2231,6 +2300,7 @@ export function useArtifactHubChart(repoName: string, chartName: string, version
2231
2300
 
2232
2301
  interface GitOpsMutationConfig<TVariables> {
2233
2302
  getPath: (variables: TVariables) => string
2303
+ getBody?: (variables: TVariables) => unknown
2234
2304
  errorMessage: string
2235
2305
  successMessage: string
2236
2306
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
@@ -2247,6 +2317,8 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2247
2317
  mutationFn: async (variables: TVariables): Promise<GitOpsOperationResponse> => {
2248
2318
  const response = await apiFetch(`${getApiBase()}${config.getPath(variables)}`, {
2249
2319
  method: 'POST',
2320
+ headers: config.getBody ? { 'Content-Type': 'application/json' } : undefined,
2321
+ body: config.getBody ? JSON.stringify(config.getBody(variables)) : undefined,
2250
2322
  })
2251
2323
  if (!response.ok) {
2252
2324
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
@@ -2269,16 +2341,46 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2269
2341
 
2270
2342
  // Common variable types
2271
2343
  type FluxResourceVars = { kind: string; namespace: string; name: string }
2344
+ // ArgoAppVars identifies the target Application. Used by mutations that don't
2345
+ // take a body (terminate, suspend, resume, refresh).
2272
2346
  type ArgoAppVars = { namespace: string; name: string }
2347
+ // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
2348
+ // useArgoSync sends these — splitting the type prevents callers from passing
2349
+ // resources/revision/prune to mutations that would silently drop them.
2350
+ type ArgoSyncVars = ArgoAppVars & {
2351
+ resources?: Array<{ group?: string; kind: string; namespace?: string; name: string }>
2352
+ revision?: string
2353
+ prune?: boolean
2354
+ dryRun?: boolean
2355
+ force?: boolean
2356
+ applyOnly?: boolean
2357
+ // Free-form Argo SyncOption strings, e.g. "Replace=true",
2358
+ // "ServerSideApply=true", "PruneLast=true". Caller is responsible for
2359
+ // spelling.
2360
+ syncOptions?: string[]
2361
+ }
2362
+
2363
+ // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
2364
+ // DryRun mirror the sync flags so the rollback dialog can offer the same
2365
+ // safety net.
2366
+ type ArgoRollbackVars = ArgoAppVars & {
2367
+ id: number
2368
+ prune?: boolean
2369
+ dryRun?: boolean
2370
+ }
2273
2371
 
2274
2372
  // Standard invalidation patterns
2275
2373
  const fluxInvalidateKeys = (v: FluxResourceVars) => [
2276
2374
  ['resources', v.kind],
2277
2375
  ['resource', v.kind, v.namespace, v.name],
2376
+ ['gitops-tree', v.kind, v.namespace, v.name],
2377
+ ['gitops-insights', v.kind, v.namespace, v.name],
2278
2378
  ]
2279
2379
  const argoInvalidateKeys = (v: ArgoAppVars) => [
2280
2380
  ['resources', 'applications'],
2281
2381
  ['resource', 'applications', v.namespace, v.name],
2382
+ ['gitops-tree', 'applications', v.namespace, v.name],
2383
+ ['gitops-insights', 'applications', v.namespace, v.name],
2282
2384
  ]
2283
2385
 
2284
2386
  // ============================================================================
@@ -2323,13 +2425,30 @@ export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
2323
2425
  // ArgoCD API hooks
2324
2426
  // ============================================================================
2325
2427
 
2326
- export const useArgoSync = createGitOpsMutation<ArgoAppVars>({
2428
+ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
2327
2429
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/sync`,
2430
+ getBody: (v) => ({
2431
+ resources: v.resources,
2432
+ revision: v.revision,
2433
+ prune: v.prune,
2434
+ dryRun: v.dryRun,
2435
+ force: v.force,
2436
+ applyOnly: v.applyOnly,
2437
+ syncOptions: v.syncOptions,
2438
+ }),
2328
2439
  errorMessage: 'Failed to trigger sync',
2329
2440
  successMessage: 'Sync initiated',
2330
2441
  getInvalidateKeys: argoInvalidateKeys,
2331
2442
  })
2332
2443
 
2444
+ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
2445
+ getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
2446
+ getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
2447
+ errorMessage: 'Failed to roll back application',
2448
+ successMessage: 'Rollback initiated',
2449
+ getInvalidateKeys: argoInvalidateKeys,
2450
+ })
2451
+
2333
2452
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
2334
2453
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
2335
2454
  errorMessage: 'Failed to terminate sync',
@@ -2372,8 +2491,13 @@ export function useArgoRefresh() {
2372
2491
  successMessage: 'Application refreshed',
2373
2492
  },
2374
2493
  onSuccess: (_, variables) => {
2375
- queryClient.invalidateQueries({ queryKey: ['resources', 'applications'] })
2376
- queryClient.invalidateQueries({ queryKey: ['resource', 'applications', variables.namespace, variables.name] })
2494
+ // Match the standard Argo invalidation set so the GitOps detail page
2495
+ // (insights strip, resource tree) refetches after Refresh / Hard
2496
+ // Refresh — without these two extra keys the user clicks Refresh and
2497
+ // sees stale insight/tree data until the next staleTime tick.
2498
+ argoInvalidateKeys(variables).forEach((key) =>
2499
+ queryClient.invalidateQueries({ queryKey: key })
2500
+ )
2377
2501
  },
2378
2502
  })
2379
2503
  }
@@ -2450,6 +2574,114 @@ export function useSwitchContext() {
2450
2574
  })
2451
2575
  }
2452
2576
 
2577
+ // ============================================================================
2578
+ // Active namespace switcher
2579
+ // ============================================================================
2580
+
2581
+ export interface NamespaceScope {
2582
+ actives: string[]
2583
+ kubeconfigNamespace: string
2584
+ /**
2585
+ * 'cluster-wide' — no per-user pick; user can list across namespaces.
2586
+ * 'namespace' — per-user view filter pinned to one or more namespaces.
2587
+ * 'restricted' — user can't list namespaces and isn't pinned to any.
2588
+ */
2589
+ mode: 'cluster-wide' | 'namespace' | 'restricted'
2590
+ accessibleNamespaces: string[]
2591
+ /** false when accessibleNamespaces is a best-effort short list (no list perm). */
2592
+ authoritative: boolean
2593
+ /** false when clearing would leave no usable namespace fallback. */
2594
+ canClearNamespace: boolean
2595
+ }
2596
+
2597
+ export function useNamespaceScope() {
2598
+ return useQuery<NamespaceScope>({
2599
+ queryKey: ['namespace-scope'],
2600
+ queryFn: () => fetchJSON('/cluster/namespace-scope'),
2601
+ staleTime: 30000,
2602
+ })
2603
+ }
2604
+
2605
+ const NAMESPACE_SWITCH_TIMEOUT = 5000
2606
+
2607
+ export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
2608
+ if (typeof window === 'undefined') return
2609
+ const enabled = window.localStorage.getItem('radar:debug:namespaces')
2610
+ if (enabled !== '1' && enabled !== 'true') return
2611
+ console.log(`[namespace-debug] ${label}`, {
2612
+ t: Math.round(performance.now()),
2613
+ href: window.location.href,
2614
+ ...payload,
2615
+ })
2616
+ }
2617
+
2618
+ export function useSetActiveNamespace() {
2619
+ const queryClient = useQueryClient()
2620
+ return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
2621
+ meta: {
2622
+ // Surface 403s (RBAC drift, denied bookmark) and network errors via the
2623
+ // global toast. Without this, App.tsx call sites that mutate without
2624
+ // their own onError (bookmark reconciliation, back-nav, topology
2625
+ // maximize/clear, command palette) silently revert when the scope
2626
+ // refetches and the mirror effect overwrites local state.
2627
+ errorMessage: 'Failed to update namespace selection',
2628
+ },
2629
+ mutationFn: async ({ namespaces }) => {
2630
+ debugNamespaceLog('mutation:start', { namespaces })
2631
+ const controller = new AbortController()
2632
+ const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
2633
+ const startedAt = performance.now()
2634
+ try {
2635
+ const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
2636
+ method: 'POST',
2637
+ headers: { 'Content-Type': 'application/json' },
2638
+ body: JSON.stringify({ namespaces }),
2639
+ signal: controller.signal,
2640
+ })
2641
+ clearTimeout(timeoutId)
2642
+ debugNamespaceLog('mutation:response', {
2643
+ namespaces,
2644
+ status: response.status,
2645
+ durationMs: Math.round(performance.now() - startedAt),
2646
+ })
2647
+ if (!response.ok) {
2648
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2649
+ throw new Error(error.error || `HTTP ${response.status}`)
2650
+ }
2651
+ return response.json()
2652
+ } catch (error) {
2653
+ clearTimeout(timeoutId)
2654
+ debugNamespaceLog('mutation:error', {
2655
+ namespaces,
2656
+ durationMs: Math.round(performance.now() - startedAt),
2657
+ error: error instanceof Error ? error.message : String(error),
2658
+ })
2659
+ if (error instanceof Error && error.name === 'AbortError') {
2660
+ throw new Error('Namespace switch timed out. The cluster may be unreachable.')
2661
+ }
2662
+ throw error
2663
+ }
2664
+ },
2665
+ onSuccess: (scope) => {
2666
+ debugNamespaceLog('mutation:success-before-scope-cache-write', {
2667
+ actives: scope.actives,
2668
+ mode: scope.mode,
2669
+ accessibleCount: scope.accessibleNamespaces.length,
2670
+ })
2671
+ queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
2672
+ debugNamespaceLog('mutation:success-after-scope-cache-write')
2673
+ },
2674
+ onError: () => {
2675
+ // A failed switch can leave the server's stored pick out of sync
2676
+ // with the cached scope (network timeout after the server wrote;
2677
+ // partial mutation). Refetch so the displayed picker matches what
2678
+ // the server actually persisted instead of what we assumed.
2679
+ debugNamespaceLog('mutation:on-error-invalidate-scope')
2680
+ queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
2681
+ },
2682
+ })
2683
+ }
2684
+
2453
2685
  // ============================================================================
2454
2686
  // Image Filesystem Inspection
2455
2687
  // ============================================================================
@@ -0,0 +1,298 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
4
+ import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
5
+ import { Tooltip } from './ui/Tooltip'
6
+
7
+ export interface NamespaceSwitcherHandle {
8
+ open: () => void
9
+ }
10
+
11
+ interface NamespaceSwitcherProps {
12
+ className?: string
13
+ disabled?: boolean
14
+ disabledTooltip?: string
15
+ }
16
+
17
+ /**
18
+ * NamespaceSwitcher is a per-user multi-select view filter for the cluster
19
+ * view. It does NOT reshape the shared informer cache — picks are saved
20
+ * server-side per user and intersected with the user's RBAC-allowed
21
+ * namespaces on each read.
22
+ *
23
+ * Three states reflect what the backend reports:
24
+ * - cluster-wide: empty trigger label "All namespaces", picker lets the
25
+ * user narrow the view; otherwise informational.
26
+ * - namespace: label shows the namespace count (or single name); picker
27
+ * offers other accessible namespaces and a clear-all reset.
28
+ * - restricted: user can't list namespaces and isn't pinned; picker
29
+ * surfaces only the kubeconfig context's namespace + any saved picks.
30
+ *
31
+ * Selection model: the dropdown keeps a draft Set<string>; toggling rows
32
+ * mutates the draft locally; closing the dropdown applies the draft in a
33
+ * single mutation. "Clear all" applies immediately and closes; "Select all
34
+ * visible" / "Clear visible" mutate the draft only and wait for close.
35
+ */
36
+ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
37
+ { className = '', disabled = false, disabledTooltip },
38
+ ref,
39
+ ) {
40
+ const { data: scope, isLoading } = useNamespaceScope()
41
+ const setActive = useSetActiveNamespace()
42
+
43
+ const [isOpen, setIsOpen] = useState(false)
44
+ const [search, setSearch] = useState('')
45
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
46
+ const [draft, setDraft] = useState<Set<string>>(() => new Set())
47
+
48
+ const triggerRef = useRef<HTMLButtonElement>(null)
49
+ const dropdownRef = useRef<HTMLDivElement>(null)
50
+
51
+ const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
52
+ const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
53
+
54
+ // Sync the draft with the server's view whenever it changes (initial load,
55
+ // post-mutation refetch, eviction after RBAC drift).
56
+ useEffect(() => {
57
+ setDraft(new Set(scopeActives))
58
+ }, [activesKey, scopeActives])
59
+
60
+ const items = useMemo(() => {
61
+ if (!scope) return [] as string[]
62
+ return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
63
+ }, [scope])
64
+
65
+ const filteredItems = useMemo(() => {
66
+ const q = search.trim().toLowerCase()
67
+ if (!q) return items
68
+ return items.filter(n => n.toLowerCase().includes(q))
69
+ }, [items, search])
70
+
71
+ const applySelection = useCallback((next: Set<string>) => {
72
+ if (!scope) return
73
+ const nextArr = Array.from(next).sort()
74
+ if (nextArr.join(',') === activesKey) return
75
+ setActive.mutate({ namespaces: nextArr })
76
+ }, [activesKey, scope, setActive])
77
+
78
+ const closeAndApply = useCallback(() => {
79
+ setIsOpen(false)
80
+ setSearch('')
81
+ applySelection(draft)
82
+ }, [applySelection, draft])
83
+
84
+ useImperativeHandle(ref, () => ({
85
+ open: () => {
86
+ if (disabled || isLoading || setActive.isPending) return
87
+ setIsOpen(true)
88
+ },
89
+ }), [disabled, isLoading, setActive.isPending])
90
+
91
+ useEffect(() => {
92
+ if (!isOpen) return
93
+ const trigger = triggerRef.current
94
+ if (!trigger) return
95
+ const r = trigger.getBoundingClientRect()
96
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
97
+ }, [isOpen])
98
+
99
+ useEffect(() => {
100
+ if (!isOpen) return
101
+ function onClick(e: MouseEvent) {
102
+ if (
103
+ !dropdownRef.current?.contains(e.target as Node) &&
104
+ !triggerRef.current?.contains(e.target as Node)
105
+ ) {
106
+ closeAndApply()
107
+ }
108
+ }
109
+ function onKey(e: KeyboardEvent) {
110
+ if (e.key === 'Escape') closeAndApply()
111
+ }
112
+ document.addEventListener('mousedown', onClick)
113
+ document.addEventListener('keydown', onKey)
114
+ return () => {
115
+ document.removeEventListener('mousedown', onClick)
116
+ document.removeEventListener('keydown', onKey)
117
+ }
118
+ }, [isOpen, closeAndApply])
119
+
120
+ if (!scope) return null
121
+
122
+ const toggle = (ns: string) => {
123
+ const next = new Set(draft)
124
+ if (next.has(ns)) next.delete(ns)
125
+ else next.add(ns)
126
+ setDraft(next)
127
+ }
128
+
129
+ const clearAll = () => {
130
+ setDraft(new Set())
131
+ setIsOpen(false)
132
+ setSearch('')
133
+ applySelection(new Set())
134
+ }
135
+
136
+ const selectAllVisible = () => {
137
+ const next = new Set(draft)
138
+ for (const ns of filteredItems) next.add(ns)
139
+ setDraft(next)
140
+ }
141
+
142
+ const clearVisible = () => {
143
+ const next = new Set(draft)
144
+ for (const ns of filteredItems) next.delete(ns)
145
+ setDraft(next)
146
+ }
147
+
148
+ const activeCount = scopeActives.length
149
+ const triggerLabel =
150
+ activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
151
+ const isClusterWide = activeCount === 0
152
+ const restrictedHint = scope.mode === 'restricted'
153
+ const isDisabled = disabled || isLoading || setActive.isPending
154
+ const canClearAll = scope.canClearNamespace || activeCount === 0
155
+ const tooltipContent = disabled && disabledTooltip
156
+ ? disabledTooltip
157
+ : restrictedHint
158
+ ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
159
+ : isClusterWide
160
+ ? 'Currently viewing all namespaces. Click to narrow the view.'
161
+ : activeCount === 1
162
+ ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
163
+ : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
164
+
165
+ // Counts used to label the bulk-action buttons; computed against the visible
166
+ // (filtered) set so the labels match what the action will affect.
167
+ const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
168
+ const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
169
+
170
+ return (
171
+ <>
172
+ <Tooltip
173
+ content={tooltipContent}
174
+ delay={300}
175
+ position="bottom"
176
+ >
177
+ <button
178
+ ref={triggerRef}
179
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
180
+ disabled={isDisabled}
181
+ className={`flex items-center gap-1.5 px-2 py-1 rounded text-sm bg-theme-elevated hover:bg-theme-hover text-theme-text-primary disabled:opacity-60 transition-colors ${className}`}
182
+ aria-label="Switch active namespaces"
183
+ >
184
+ {isClusterWide ? (
185
+ <Globe className="w-3.5 h-3.5 text-theme-text-tertiary" />
186
+ ) : restrictedHint ? (
187
+ <AlertTriangle className="w-3.5 h-3.5 text-theme-text-tertiary" />
188
+ ) : null}
189
+ <span className="font-medium max-w-[180px] truncate">
190
+ {setActive.isPending ? 'Switching…' : triggerLabel}
191
+ </span>
192
+ <ChevronDown className="w-3 h-3 opacity-60" />
193
+ </button>
194
+ </Tooltip>
195
+
196
+ {isOpen &&
197
+ createPortal(
198
+ <div
199
+ ref={dropdownRef}
200
+ style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
201
+ className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
202
+ >
203
+ {items.length > 6 && (
204
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
205
+ <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
206
+ <input
207
+ autoFocus
208
+ value={search}
209
+ onChange={e => setSearch(e.target.value)}
210
+ placeholder="Filter namespaces"
211
+ className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
212
+ />
213
+ </div>
214
+ )}
215
+
216
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
217
+ <button
218
+ onClick={canClearAll ? clearAll : undefined}
219
+ disabled={!canClearAll || activeCount === 0}
220
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
221
+ aria-label="Clear namespace selection"
222
+ >
223
+ <X className="w-3 h-3" />
224
+ Clear all
225
+ </button>
226
+ <button
227
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
228
+ disabled={filteredItems.length === 0}
229
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
230
+ >
231
+ {allVisibleSelected
232
+ ? `Clear ${filteredItems.length} visible`
233
+ : search.trim()
234
+ ? `Select ${filteredItems.length} visible`
235
+ : 'Select all'}
236
+ </button>
237
+ </div>
238
+
239
+ <ul className="max-h-80 overflow-y-auto py-1">
240
+ {filteredItems.length === 0 && (
241
+ <li className="px-3 py-2 text-xs text-theme-text-tertiary">
242
+ {search ? 'No matches.' : 'No namespaces available.'}
243
+ </li>
244
+ )}
245
+
246
+ {filteredItems.map(ns => {
247
+ const isChecked = draft.has(ns)
248
+ const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
249
+ return (
250
+ <li key={ns}>
251
+ <label
252
+ className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
253
+ >
254
+ <span className="flex items-center gap-2 min-w-0">
255
+ <input
256
+ type="checkbox"
257
+ checked={isChecked}
258
+ onChange={() => toggle(ns)}
259
+ className="shrink-0 accent-current"
260
+ />
261
+ <span className="truncate">{ns}</span>
262
+ {isContextDefault && (
263
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
264
+ kubeconfig
265
+ </span>
266
+ )}
267
+ </span>
268
+ </label>
269
+ </li>
270
+ )
271
+ })}
272
+ </ul>
273
+
274
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
275
+ <span>
276
+ {draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
277
+ </span>
278
+ <button
279
+ onClick={closeAndApply}
280
+ className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
281
+ >
282
+ Done
283
+ </button>
284
+ </div>
285
+
286
+ {!scope.authoritative && (
287
+ <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
288
+ Limited list — your RBAC doesn&rsquo;t allow listing all
289
+ namespaces. Other namespaces may be accessible but won&rsquo;t
290
+ appear here until you switch context.
291
+ </div>
292
+ )}
293
+ </div>,
294
+ document.body,
295
+ )}
296
+ </>
297
+ )
298
+ })
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  DockProvider,
4
4
  useDock,
5
+ useDockReservedHeight,
5
6
  useOpenTerminal,
6
7
  useOpenLogs,
7
8
  useOpenWorkloadLogs,
@@ -1,2 +1,2 @@
1
- export { DockProvider, useDock, useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal, useOpenLocalTerminal } from './DockContext'
1
+ export { DockProvider, useDock, useDockReservedHeight, useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal, useOpenLocalTerminal } from './DockContext'
2
2
  export { BottomDock } from './BottomDock'