@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/package.json +6 -6
- package/src/App.tsx +214 -39
- package/src/api/client.ts +235 -3
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/index.ts +1 -1
- package/src/components/gitops/GitOpsView.tsx +2441 -0
- package/src/components/gitops/RollbackDialog.tsx +107 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +144 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +20 -3
- package/src/components/helm/HelmView.tsx +9 -1
- package/src/components/helm/OwnedResources.tsx +2 -2
- package/src/components/home/GitOpsControllersCard.tsx +108 -0
- package/src/components/home/HomeView.tsx +9 -1
- package/src/components/portforward/PortForwardManager.tsx +135 -109
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +20 -6
- package/src/components/ui/CommandPalette.tsx +6 -4
- package/src/components/ui/ShortcutHelpOverlay.tsx +2 -1
- package/src/components/ui/UpdateNotification.tsx +36 -19
- package/src/components/workload/WorkloadView.tsx +126 -10
- package/src/types.ts +2 -0
- package/src/utils/navigation.ts +14 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -446
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<
|
|
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
|
-
|
|
2376
|
-
|
|
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’t allow listing all
|
|
289
|
+
namespaces. Other namespaces may be accessible but won’t
|
|
290
|
+
appear here until you switch context.
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>,
|
|
294
|
+
document.body,
|
|
295
|
+
)}
|
|
296
|
+
</>
|
|
297
|
+
)
|
|
298
|
+
})
|
|
@@ -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'
|