@skyhook-io/radar-app 1.8.2 → 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.
Files changed (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. package/src/utils/navigation.ts +5 -3
package/src/RadarApp.tsx CHANGED
@@ -25,6 +25,7 @@ import { ThemeProvider } from './context/ThemeContext';
25
25
  import { ToastProvider, showApiError, showApiSuccess } from './components/ui/Toast';
26
26
  import { setApiBase, setBasename } from './api/config';
27
27
  import { NavCustomizationProvider } from './context/NavCustomization';
28
+ import { FilterLocationBridge } from './filter/FilterLocationBridge';
28
29
  import type { NavCustomization } from './context/NavCustomization';
29
30
 
30
31
  // Declare the shape of mutation meta here — inlined rather than in a
@@ -70,6 +71,21 @@ export interface RadarAppProps {
70
71
  * See ./context/NavCustomization for the slot shape.
71
72
  */
72
73
  navSlots?: NavCustomization;
74
+ /**
75
+ * Whether Radar may set the browser tab title (`document.title`) per view.
76
+ * Defaults to OFF: embedders keep title ownership without opting out. The
77
+ * standalone binary opts in (`web/src/main.tsx` renders
78
+ * `<RadarApp manageDocumentTitle />`), and any full-page embed that wants
79
+ * Radar's per-view titles can do the same.
80
+ */
81
+ manageDocumentTitle?: boolean;
82
+ /**
83
+ * Trailing string appended after the per-view label (only when
84
+ * `manageDocumentTitle` is on). It's the *full* suffix including any
85
+ * separator, so a host can rebrand (`' — My Cloud'`) or drop it (`''`).
86
+ * Defaults to `' · Radar'`.
87
+ */
88
+ documentTitleSuffix?: string;
73
89
  /**
74
90
  * Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
75
91
  * deep-link a specific view (e.g. '/topology') without owning the URL bar —
@@ -116,6 +132,8 @@ export function RadarApp({
116
132
  router = 'browser',
117
133
  queryClient,
118
134
  navSlots,
135
+ manageDocumentTitle = false,
136
+ documentTitleSuffix,
119
137
  initialPath,
120
138
  }: RadarAppProps): React.ReactElement {
121
139
  // Apply runtime config during render so module-level singletons are set
@@ -136,7 +154,9 @@ export function RadarApp({
136
154
  <QueryClientProvider client={client}>
137
155
  <ToastProvider>
138
156
  <NavCustomizationProvider value={navSlots}>
139
- <App />
157
+ <FilterLocationBridge>
158
+ <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
159
+ </FilterLocationBridge>
140
160
  </NavCustomizationProvider>
141
161
  </ToastProvider>
142
162
  </QueryClientProvider>
package/src/api/client.ts CHANGED
@@ -16,8 +16,12 @@ import type {
16
16
  HelmReleaseDetail,
17
17
  HelmValues,
18
18
  ManifestDiff,
19
+ NotesDiff,
20
+ HooksDiff,
21
+ ResourceDiff,
19
22
  UpgradeInfo,
20
23
  BatchUpgradeInfo,
24
+ ValuesDiff,
21
25
  ValuesPreviewResponse,
22
26
  HelmRepository,
23
27
  ChartSearchResult,
@@ -32,6 +36,15 @@ import type { GitOpsOperationResponse } from '../types/gitops'
32
36
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
33
37
  import { pluralToKind } from '../utils/navigation'
34
38
 
39
+ // Auto-refresh cadences (ms) — named constants for each polled hook's
40
+ // refetchInterval below, so the poll rate reads clearly at each call site.
41
+ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
42
+ const AUDIT_REFRESH_INTERVAL_MS = 60_000
43
+ const ISSUES_REFRESH_INTERVAL_MS = 30_000
44
+ const COST_REFRESH_INTERVAL_MS = 60_000
45
+ const CHANGES_REFRESH_INTERVAL_MS = 60_000
46
+ const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
47
+
35
48
  // Wrapper around fetch that always includes credentials (for session cookies)
36
49
  // and handles 401 responses globally. Merges caller-provided headers with
37
50
  // auth headers from the config module so library consumers (Radar Hub) can
@@ -323,7 +336,7 @@ export function useDashboard(namespaces: string[] = []) {
323
336
  queryKey: ['dashboard', namespaces],
324
337
  queryFn: () => fetchJSON(`/dashboard${params}`),
325
338
  staleTime: 15000, // 15 seconds
326
- refetchInterval: 30000, // Refresh every 30 seconds
339
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
327
340
  })
328
341
  }
329
342
 
@@ -334,7 +347,7 @@ export function useAudit(namespaces: string[] = []) {
334
347
  queryKey: ['audit', namespaces],
335
348
  queryFn: () => fetchJSON(`/audit${params}`),
336
349
  staleTime: 30000,
337
- refetchInterval: 60000,
350
+ refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
338
351
  placeholderData: (prev) => prev,
339
352
  })
340
353
  }
@@ -365,7 +378,7 @@ export function useIssues(namespaces: string[] = []) {
365
378
  queryKey: ['issues', namespaces],
366
379
  queryFn: () => fetchJSON(`/issues${params}`),
367
380
  staleTime: 30000,
368
- refetchInterval: 30000,
381
+ refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
369
382
  })
370
383
  }
371
384
 
@@ -377,6 +390,28 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
377
390
  })
378
391
  }
379
392
 
393
+ // Live Issues that touch ONE resource — its own issues plus, for a workload, its
394
+ // owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
395
+ // the "Operational Issues" section in the resource detail. Cluster-scoped
396
+ // resources pass "_" for namespace; namespaced ones also scope the scan via
397
+ // ?namespaces= for a cheap, bounded Compose.
398
+ export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
399
+ const clusterScoped = !namespace
400
+ const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
401
+ const params = new URLSearchParams()
402
+ if (group) params.set('group', group)
403
+ const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
404
+ const qs = params.toString()
405
+ return useQuery<Issue[]>({
406
+ queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
407
+ queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
408
+ // No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
409
+ // on reopen without re-running an uncapped Compose every 30s.
410
+ staleTime: 30000,
411
+ enabled: enabled && !!kind && !!name,
412
+ })
413
+ }
414
+
380
415
  // Audit settings
381
416
  export interface AuditSettings {
382
417
  ignoredNamespaces: string[]
@@ -491,7 +526,7 @@ export function useOpenCostSummary() {
491
526
  return useQuery<OpenCostSummary>({
492
527
  queryKey: ['opencost-summary'],
493
528
  queryFn: () => fetchJSON('/opencost/summary'),
494
- refetchInterval: 60000, // Refresh every minute
529
+ refetchInterval: COST_REFRESH_INTERVAL_MS,
495
530
  staleTime: 30000,
496
531
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
497
532
  })
@@ -929,7 +964,7 @@ export function useApplications(namespaces: string[]) {
929
964
  queryKey: ['applications', namespaces],
930
965
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
931
966
  staleTime: 30_000,
932
- refetchInterval: 60_000,
967
+ refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
933
968
  })
934
969
  }
935
970
 
@@ -1088,7 +1123,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1088
1123
  queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1089
1124
  queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1090
1125
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1091
- refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
1126
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1092
1127
  enabled,
1093
1128
  })
1094
1129
  }
@@ -2358,11 +2393,14 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
2358
2393
  }
2359
2394
 
2360
2395
  // Get values for a Helm release. `enabled` see useHelmManifest.
2361
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
2362
- const params = allValues ? '?all=true' : ''
2396
+ export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
2397
+ const params = new URLSearchParams()
2398
+ if (allValues) params.set('all', 'true')
2399
+ if (revision && revision > 0) params.set('revision', String(revision))
2400
+ const query = params.toString() ? `?${params.toString()}` : ''
2363
2401
  return useQuery<HelmValues>({
2364
- queryKey: ['helm-values', namespace, name, allValues],
2365
- queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
2402
+ queryKey: ['helm-values', namespace, name, allValues, revision],
2403
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
2366
2404
  enabled: Boolean(namespace && name && enabled),
2367
2405
  staleTime: 60000,
2368
2406
  })
@@ -2385,6 +2423,77 @@ export function useHelmManifestDiff(
2385
2423
  })
2386
2424
  }
2387
2425
 
2426
+ export function useHelmValuesDiff(
2427
+ namespace: string,
2428
+ name: string,
2429
+ revision1: number,
2430
+ revision2: number,
2431
+ allValues = false,
2432
+ enabled = true,
2433
+ ) {
2434
+ return useQuery<ValuesDiff>({
2435
+ queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
2436
+ queryFn: () => {
2437
+ const params = new URLSearchParams({
2438
+ revision1: String(revision1),
2439
+ revision2: String(revision2),
2440
+ })
2441
+ if (allValues) params.set('all', 'true')
2442
+ return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
2443
+ },
2444
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2445
+ staleTime: 60000,
2446
+ })
2447
+ }
2448
+
2449
+ export function useHelmNotesDiff(
2450
+ namespace: string,
2451
+ name: string,
2452
+ revision1: number,
2453
+ revision2: number,
2454
+ enabled = true,
2455
+ ) {
2456
+ return useQuery<NotesDiff>({
2457
+ queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
2458
+ queryFn: () =>
2459
+ fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
2460
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2461
+ staleTime: 60000,
2462
+ })
2463
+ }
2464
+
2465
+ export function useHelmHooksDiff(
2466
+ namespace: string,
2467
+ name: string,
2468
+ revision1: number,
2469
+ revision2: number,
2470
+ enabled = true,
2471
+ ) {
2472
+ return useQuery<HooksDiff>({
2473
+ queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2474
+ queryFn: () =>
2475
+ fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
2476
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2477
+ staleTime: 60000,
2478
+ })
2479
+ }
2480
+
2481
+ export function useHelmResourceDiff(
2482
+ namespace: string,
2483
+ name: string,
2484
+ revision1: number,
2485
+ revision2: number,
2486
+ enabled = true,
2487
+ ) {
2488
+ return useQuery<ResourceDiff>({
2489
+ queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
2490
+ queryFn: () =>
2491
+ fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
2492
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2493
+ staleTime: 60000,
2494
+ })
2495
+ }
2496
+
2388
2497
  // Check for upgrade availability (lazy - called when drawer opens)
2389
2498
  export function useHelmUpgradeInfo(namespace: string, name: string, enabled = true) {
2390
2499
  return useQuery<UpgradeInfo>({
@@ -3147,6 +3256,11 @@ export interface NamespaceScope {
3147
3256
  authoritative: boolean
3148
3257
  /** false when clearing would leave no usable namespace fallback. */
3149
3258
  canClearNamespace: boolean
3259
+ /** true when the backend informer cache is pinned to a namespace. */
3260
+ cacheScoped: boolean
3261
+ cacheScopeNamespace?: string
3262
+ /** true when this client may rebuild the local cache for another namespace. */
3263
+ namespaceRescope: boolean
3150
3264
  }
3151
3265
 
3152
3266
  export function useNamespaceScope() {
@@ -3158,6 +3272,7 @@ export function useNamespaceScope() {
3158
3272
  }
3159
3273
 
3160
3274
  const NAMESPACE_SWITCH_TIMEOUT = 5000
3275
+ const NAMESPACE_RESCOPE_TIMEOUT = 120000
3161
3276
 
3162
3277
  export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
3163
3278
  if (typeof window === 'undefined') return
@@ -3184,7 +3299,16 @@ export function useSetActiveNamespace() {
3184
3299
  mutationFn: async ({ namespaces }) => {
3185
3300
  debugNamespaceLog('mutation:start', { namespaces })
3186
3301
  const controller = new AbortController()
3187
- const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
3302
+ const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
3303
+ // cacheScoped is a stable per-process property (the server's --namespace-scope
3304
+ // flag). If the scope query is missing/stale we can't yet tell a cheap
3305
+ // view-filter change from a cache-rebuilding rescope, so bias to the long
3306
+ // timeout — only a confirmed non-scoped session gets the fast switch timeout.
3307
+ // Aborting a real rebuild at 5s surfaces a spurious failure while the server
3308
+ // keeps going.
3309
+ const isRescope = currentScope?.cacheScoped !== false
3310
+ const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
3311
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
3188
3312
  const startedAt = performance.now()
3189
3313
  try {
3190
3314
  const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
@@ -3212,7 +3336,9 @@ export function useSetActiveNamespace() {
3212
3336
  error: error instanceof Error ? error.message : String(error),
3213
3337
  })
3214
3338
  if (error instanceof Error && error.name === 'AbortError') {
3215
- throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3339
+ throw new Error(isRescope
3340
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
3341
+ : 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3216
3342
  }
3217
3343
  throw error
3218
3344
  }
@@ -3223,7 +3349,13 @@ export function useSetActiveNamespace() {
3223
3349
  mode: scope.mode,
3224
3350
  accessibleCount: scope.accessibleNamespaces.length,
3225
3351
  })
3352
+ if (scope.cacheScoped) {
3353
+ queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
3354
+ }
3226
3355
  queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
3356
+ if (scope.cacheScoped) {
3357
+ queryClient.invalidateQueries()
3358
+ }
3227
3359
  debugNamespaceLog('mutation:success-after-scope-cache-write')
3228
3360
  },
3229
3361
  onError: () => {
@@ -254,7 +254,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
254
254
 
255
255
  {connection.error && (
256
256
  <div className="w-full bg-theme-elevated border border-theme-border rounded-lg p-3 mb-6 overflow-auto max-h-32">
257
- <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-all">
257
+ <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-words">
258
258
  {connection.error}
259
259
  </code>
260
260
  </div>
@@ -14,6 +14,8 @@ import { parseContextName, type ParsedContextName } from '../utils/context-name'
14
14
 
15
15
  interface ContextSwitcherProps {
16
16
  className?: string
17
+ variant?: 'chip' | 'segment'
18
+ label?: string
17
19
  }
18
20
 
19
21
  export interface ContextSwitcherHandle {
@@ -24,7 +26,7 @@ interface ParsedContext extends ParsedContextName {
24
26
  context: ContextInfo
25
27
  }
26
28
 
27
- export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
29
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label }, ref) => {
28
30
  const [showConfirm, setShowConfirm] = useState(false)
29
31
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
30
32
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -191,6 +193,8 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
191
193
  <ClusterSwitcher
192
194
  ref={ref}
193
195
  className={className}
196
+ variant={variant}
197
+ label={label}
194
198
  currentId={currentId}
195
199
  currentName={currentRaw}
196
200
  currentSourceLabel={currentSourceLabel}
@@ -1,298 +1,41 @@
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'
1
+ import { forwardRef } from 'react'
2
+ import { NamespacePicker, type NamespacePickerHandle } from '@skyhook-io/k8s-ui'
4
3
  import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
5
- import { Tooltip } from './ui/Tooltip'
6
4
 
7
- export interface NamespaceSwitcherHandle {
8
- open: () => void
9
- }
5
+ export type NamespaceSwitcherHandle = NamespacePickerHandle
10
6
 
11
7
  interface NamespaceSwitcherProps {
12
8
  className?: string
13
9
  disabled?: boolean
14
10
  disabledTooltip?: string
11
+ variant?: 'chip' | 'segment'
12
+ label?: string
15
13
  }
16
14
 
17
15
  /**
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.
16
+ * OSS Radar's namespace scope control — a thin data container over the shared
17
+ * presentational NamespacePicker (@skyhook-io/k8s-ui). Wires Radar's own API
18
+ * hooks; Radar Hub supplies its own container over the per-cluster apiBase.
35
19
  */
36
20
  export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
37
- { className = '', disabled = false, disabledTooltip },
21
+ { className, disabled, disabledTooltip, variant, label },
38
22
  ref,
39
23
  ) {
40
24
  const { data: scope, isLoading } = useNamespaceScope()
41
25
  const setActive = useSetActiveNamespace()
42
26
 
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
27
  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
- </>
28
+ <NamespacePicker
29
+ ref={ref}
30
+ scope={scope}
31
+ loading={isLoading}
32
+ pending={setActive.isPending}
33
+ onApply={namespaces => setActive.mutate({ namespaces })}
34
+ disabled={disabled}
35
+ disabledTooltip={disabledTooltip}
36
+ className={className}
37
+ variant={variant}
38
+ label={label}
39
+ />
297
40
  )
298
41
  })