@skyhook-io/radar-app 1.8.1 → 1.8.3
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 +1 -1
- package/src/App.tsx +167 -56
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +173 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +50 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +10 -10
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
package/src/api/client.ts
CHANGED
|
@@ -16,8 +16,11 @@ import type {
|
|
|
16
16
|
HelmReleaseDetail,
|
|
17
17
|
HelmValues,
|
|
18
18
|
ManifestDiff,
|
|
19
|
+
NotesDiff,
|
|
20
|
+
ResourceDiff,
|
|
19
21
|
UpgradeInfo,
|
|
20
22
|
BatchUpgradeInfo,
|
|
23
|
+
ValuesDiff,
|
|
21
24
|
ValuesPreviewResponse,
|
|
22
25
|
HelmRepository,
|
|
23
26
|
ChartSearchResult,
|
|
@@ -377,6 +380,28 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
|
|
|
377
380
|
})
|
|
378
381
|
}
|
|
379
382
|
|
|
383
|
+
// Live Issues that touch ONE resource — its own issues plus, for a workload, its
|
|
384
|
+
// owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
|
|
385
|
+
// the "Operational Issues" section in the resource detail. Cluster-scoped
|
|
386
|
+
// resources pass "_" for namespace; namespaced ones also scope the scan via
|
|
387
|
+
// ?namespaces= for a cheap, bounded Compose.
|
|
388
|
+
export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
|
|
389
|
+
const clusterScoped = !namespace
|
|
390
|
+
const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
|
|
391
|
+
const params = new URLSearchParams()
|
|
392
|
+
if (group) params.set('group', group)
|
|
393
|
+
const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
|
|
394
|
+
const qs = params.toString()
|
|
395
|
+
return useQuery<Issue[]>({
|
|
396
|
+
queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
|
|
397
|
+
queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
|
|
398
|
+
// No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
|
|
399
|
+
// on reopen without re-running an uncapped Compose every 30s.
|
|
400
|
+
staleTime: 30000,
|
|
401
|
+
enabled: enabled && !!kind && !!name,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
380
405
|
// Audit settings
|
|
381
406
|
export interface AuditSettings {
|
|
382
407
|
ignoredNamespaces: string[]
|
|
@@ -2358,11 +2383,14 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
|
|
|
2358
2383
|
}
|
|
2359
2384
|
|
|
2360
2385
|
// Get values for a Helm release. `enabled` see useHelmManifest.
|
|
2361
|
-
export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
|
|
2362
|
-
const params =
|
|
2386
|
+
export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
|
|
2387
|
+
const params = new URLSearchParams()
|
|
2388
|
+
if (allValues) params.set('all', 'true')
|
|
2389
|
+
if (revision && revision > 0) params.set('revision', String(revision))
|
|
2390
|
+
const query = params.toString() ? `?${params.toString()}` : ''
|
|
2363
2391
|
return useQuery<HelmValues>({
|
|
2364
|
-
queryKey: ['helm-values', namespace, name, allValues],
|
|
2365
|
-
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${
|
|
2392
|
+
queryKey: ['helm-values', namespace, name, allValues, revision],
|
|
2393
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
|
|
2366
2394
|
enabled: Boolean(namespace && name && enabled),
|
|
2367
2395
|
staleTime: 60000,
|
|
2368
2396
|
})
|
|
@@ -2385,6 +2413,61 @@ export function useHelmManifestDiff(
|
|
|
2385
2413
|
})
|
|
2386
2414
|
}
|
|
2387
2415
|
|
|
2416
|
+
export function useHelmValuesDiff(
|
|
2417
|
+
namespace: string,
|
|
2418
|
+
name: string,
|
|
2419
|
+
revision1: number,
|
|
2420
|
+
revision2: number,
|
|
2421
|
+
allValues = false,
|
|
2422
|
+
enabled = true,
|
|
2423
|
+
) {
|
|
2424
|
+
return useQuery<ValuesDiff>({
|
|
2425
|
+
queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
|
|
2426
|
+
queryFn: () => {
|
|
2427
|
+
const params = new URLSearchParams({
|
|
2428
|
+
revision1: String(revision1),
|
|
2429
|
+
revision2: String(revision2),
|
|
2430
|
+
})
|
|
2431
|
+
if (allValues) params.set('all', 'true')
|
|
2432
|
+
return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
|
|
2433
|
+
},
|
|
2434
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2435
|
+
staleTime: 60000,
|
|
2436
|
+
})
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
export function useHelmNotesDiff(
|
|
2440
|
+
namespace: string,
|
|
2441
|
+
name: string,
|
|
2442
|
+
revision1: number,
|
|
2443
|
+
revision2: number,
|
|
2444
|
+
enabled = true,
|
|
2445
|
+
) {
|
|
2446
|
+
return useQuery<NotesDiff>({
|
|
2447
|
+
queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
|
|
2448
|
+
queryFn: () =>
|
|
2449
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
2450
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2451
|
+
staleTime: 60000,
|
|
2452
|
+
})
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
export function useHelmResourceDiff(
|
|
2456
|
+
namespace: string,
|
|
2457
|
+
name: string,
|
|
2458
|
+
revision1: number,
|
|
2459
|
+
revision2: number,
|
|
2460
|
+
enabled = true,
|
|
2461
|
+
) {
|
|
2462
|
+
return useQuery<ResourceDiff>({
|
|
2463
|
+
queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
|
|
2464
|
+
queryFn: () =>
|
|
2465
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
2466
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2467
|
+
staleTime: 60000,
|
|
2468
|
+
})
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2388
2471
|
// Check for upgrade availability (lazy - called when drawer opens)
|
|
2389
2472
|
export function useHelmUpgradeInfo(namespace: string, name: string, enabled = true) {
|
|
2390
2473
|
return useQuery<UpgradeInfo>({
|
|
@@ -2396,6 +2479,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
|
|
|
2396
2479
|
})
|
|
2397
2480
|
}
|
|
2398
2481
|
|
|
2482
|
+
// Available chart versions for a release (newest-first), for the upgrade dialog's
|
|
2483
|
+
// version picker. Empty when the source can't be resolved — the dialog then falls
|
|
2484
|
+
// back to the latest version from upgrade-info.
|
|
2485
|
+
export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
|
|
2486
|
+
return useQuery<string[]>({
|
|
2487
|
+
queryKey: ['helm-release-versions', namespace, name],
|
|
2488
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
|
|
2489
|
+
enabled: Boolean(namespace && name && enabled),
|
|
2490
|
+
staleTime: 30000,
|
|
2491
|
+
retry: false,
|
|
2492
|
+
})
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2399
2495
|
// Batch check for upgrade availability (for list view)
|
|
2400
2496
|
export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
|
|
2401
2497
|
const params = helmNamespaceParams(namespaces)
|
|
@@ -2668,6 +2764,54 @@ export function useUpdateRepositorySilent() {
|
|
|
2668
2764
|
})
|
|
2669
2765
|
}
|
|
2670
2766
|
|
|
2767
|
+
// Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
|
|
2768
|
+
// track upgrades for the user's own OCI-published charts.
|
|
2769
|
+
export function useHelmOCISources() {
|
|
2770
|
+
return useQuery<string[]>({
|
|
2771
|
+
queryKey: ['helm-oci-sources'],
|
|
2772
|
+
queryFn: () => fetchJSON('/helm/oci-sources'),
|
|
2773
|
+
})
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
|
|
2777
|
+
const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
|
|
2778
|
+
method,
|
|
2779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2780
|
+
body: JSON.stringify({ source }),
|
|
2781
|
+
})
|
|
2782
|
+
if (!response.ok) {
|
|
2783
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2784
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2785
|
+
}
|
|
2786
|
+
return response.json()
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
// Invalidate the upgrade-info queries so a newly-registered source is probed
|
|
2790
|
+
// immediately and "source not tracked" re-resolves.
|
|
2791
|
+
function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
|
|
2792
|
+
queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
|
|
2793
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
|
|
2794
|
+
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
export function useAddOCISource() {
|
|
2798
|
+
const queryClient = useQueryClient()
|
|
2799
|
+
return useMutation({
|
|
2800
|
+
mutationFn: (source: string) => mutateOCISource('POST', source),
|
|
2801
|
+
meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
|
|
2802
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2803
|
+
})
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
export function useRemoveOCISource() {
|
|
2807
|
+
const queryClient = useQueryClient()
|
|
2808
|
+
return useMutation({
|
|
2809
|
+
mutationFn: (source: string) => mutateOCISource('DELETE', source),
|
|
2810
|
+
meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
|
|
2811
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2812
|
+
})
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2671
2815
|
// Search charts across all repositories
|
|
2672
2816
|
export function useSearchCharts(query: string, allVersions = false, enabled = true) {
|
|
2673
2817
|
return useQuery<ChartSearchResult>({
|
|
@@ -3086,6 +3230,11 @@ export interface NamespaceScope {
|
|
|
3086
3230
|
authoritative: boolean
|
|
3087
3231
|
/** false when clearing would leave no usable namespace fallback. */
|
|
3088
3232
|
canClearNamespace: boolean
|
|
3233
|
+
/** true when the backend informer cache is pinned to a namespace. */
|
|
3234
|
+
cacheScoped: boolean
|
|
3235
|
+
cacheScopeNamespace?: string
|
|
3236
|
+
/** true when this client may rebuild the local cache for another namespace. */
|
|
3237
|
+
namespaceRescope: boolean
|
|
3089
3238
|
}
|
|
3090
3239
|
|
|
3091
3240
|
export function useNamespaceScope() {
|
|
@@ -3097,6 +3246,7 @@ export function useNamespaceScope() {
|
|
|
3097
3246
|
}
|
|
3098
3247
|
|
|
3099
3248
|
const NAMESPACE_SWITCH_TIMEOUT = 5000
|
|
3249
|
+
const NAMESPACE_RESCOPE_TIMEOUT = 120000
|
|
3100
3250
|
|
|
3101
3251
|
export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
|
|
3102
3252
|
if (typeof window === 'undefined') return
|
|
@@ -3123,7 +3273,16 @@ export function useSetActiveNamespace() {
|
|
|
3123
3273
|
mutationFn: async ({ namespaces }) => {
|
|
3124
3274
|
debugNamespaceLog('mutation:start', { namespaces })
|
|
3125
3275
|
const controller = new AbortController()
|
|
3126
|
-
const
|
|
3276
|
+
const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
|
|
3277
|
+
// cacheScoped is a stable per-process property (the server's --namespace-scope
|
|
3278
|
+
// flag). If the scope query is missing/stale we can't yet tell a cheap
|
|
3279
|
+
// view-filter change from a cache-rebuilding rescope, so bias to the long
|
|
3280
|
+
// timeout — only a confirmed non-scoped session gets the fast switch timeout.
|
|
3281
|
+
// Aborting a real rebuild at 5s surfaces a spurious failure while the server
|
|
3282
|
+
// keeps going.
|
|
3283
|
+
const isRescope = currentScope?.cacheScoped !== false
|
|
3284
|
+
const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
|
|
3285
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
3127
3286
|
const startedAt = performance.now()
|
|
3128
3287
|
try {
|
|
3129
3288
|
const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
|
|
@@ -3151,7 +3310,9 @@ export function useSetActiveNamespace() {
|
|
|
3151
3310
|
error: error instanceof Error ? error.message : String(error),
|
|
3152
3311
|
})
|
|
3153
3312
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3154
|
-
throw new Error(
|
|
3313
|
+
throw new Error(isRescope
|
|
3314
|
+
? 'Namespace rescope timed out. The cluster may still be loading.'
|
|
3315
|
+
: 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3155
3316
|
}
|
|
3156
3317
|
throw error
|
|
3157
3318
|
}
|
|
@@ -3162,7 +3323,13 @@ export function useSetActiveNamespace() {
|
|
|
3162
3323
|
mode: scope.mode,
|
|
3163
3324
|
accessibleCount: scope.accessibleNamespaces.length,
|
|
3164
3325
|
})
|
|
3326
|
+
if (scope.cacheScoped) {
|
|
3327
|
+
queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
|
|
3328
|
+
}
|
|
3165
3329
|
queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
|
|
3330
|
+
if (scope.cacheScoped) {
|
|
3331
|
+
queryClient.invalidateQueries()
|
|
3332
|
+
}
|
|
3166
3333
|
debugNamespaceLog('mutation:success-after-scope-cache-write')
|
|
3167
3334
|
},
|
|
3168
3335
|
onError: () => {
|
|
@@ -15,10 +15,9 @@ interface NamespaceSwitcherProps {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* NamespaceSwitcher is a per-user multi-select view filter
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* namespaces on each read.
|
|
18
|
+
* NamespaceSwitcher is normally a per-user multi-select view filter. When the
|
|
19
|
+
* backend reports cacheScoped=true, it becomes a single-namespace cache scope
|
|
20
|
+
* control; local sessions may rebuild the cache for a different namespace.
|
|
22
21
|
*
|
|
23
22
|
* Three states reflect what the backend reports:
|
|
24
23
|
* - cluster-wide: empty trigger label "All namespaces", picker lets the
|
|
@@ -71,6 +70,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
71
70
|
const applySelection = useCallback((next: Set<string>) => {
|
|
72
71
|
if (!scope) return
|
|
73
72
|
const nextArr = Array.from(next).sort()
|
|
73
|
+
if (scope.cacheScoped && nextArr.length !== 1) return
|
|
74
74
|
if (nextArr.join(',') === activesKey) return
|
|
75
75
|
setActive.mutate({ namespaces: nextArr })
|
|
76
76
|
}, [activesKey, scope, setActive])
|
|
@@ -120,6 +120,10 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
120
120
|
if (!scope) return null
|
|
121
121
|
|
|
122
122
|
const toggle = (ns: string) => {
|
|
123
|
+
if (scope.cacheScoped) {
|
|
124
|
+
setDraft(new Set([ns]))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
123
127
|
const next = new Set(draft)
|
|
124
128
|
if (next.has(ns)) next.delete(ns)
|
|
125
129
|
else next.add(ns)
|
|
@@ -127,6 +131,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
const clearAll = () => {
|
|
134
|
+
if (scope.cacheScoped) return
|
|
130
135
|
setDraft(new Set())
|
|
131
136
|
setIsOpen(false)
|
|
132
137
|
setSearch('')
|
|
@@ -150,11 +155,16 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
150
155
|
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
151
156
|
const isClusterWide = activeCount === 0
|
|
152
157
|
const restrictedHint = scope.mode === 'restricted'
|
|
153
|
-
const
|
|
158
|
+
const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
|
|
159
|
+
const isDisabled = disabled || isLoading || setActive.isPending || cacheScopeLocked
|
|
154
160
|
const canClearAll = scope.canClearNamespace || activeCount === 0
|
|
155
161
|
const tooltipContent = disabled && disabledTooltip
|
|
156
162
|
? disabledTooltip
|
|
157
|
-
:
|
|
163
|
+
: scope.cacheScoped
|
|
164
|
+
? scope.namespaceRescope
|
|
165
|
+
? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
|
|
166
|
+
: `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
|
|
167
|
+
: restrictedHint
|
|
158
168
|
? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
|
|
159
169
|
: isClusterWide
|
|
160
170
|
? 'Currently viewing all namespaces. Click to narrow the view.'
|
|
@@ -213,28 +223,37 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
213
223
|
</div>
|
|
214
224
|
)}
|
|
215
225
|
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
226
|
+
{scope.cacheScoped ? (
|
|
227
|
+
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
228
|
+
Radar is watching one namespace to stay fast on large clusters.
|
|
229
|
+
{scope.namespaceRescope
|
|
230
|
+
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
231
|
+
: ' This instance is locked to its startup namespace.'}
|
|
232
|
+
</div>
|
|
233
|
+
) : (
|
|
234
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
|
|
235
|
+
<button
|
|
236
|
+
onClick={canClearAll ? clearAll : undefined}
|
|
237
|
+
disabled={!canClearAll || activeCount === 0}
|
|
238
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
239
|
+
aria-label="Clear namespace selection"
|
|
240
|
+
>
|
|
241
|
+
<X className="w-3 h-3" />
|
|
242
|
+
Clear all
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
onClick={allVisibleSelected ? clearVisible : selectAllVisible}
|
|
246
|
+
disabled={filteredItems.length === 0}
|
|
247
|
+
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
248
|
+
>
|
|
249
|
+
{allVisibleSelected
|
|
250
|
+
? `Clear ${filteredItems.length} visible`
|
|
251
|
+
: search.trim()
|
|
252
|
+
? `Select ${filteredItems.length} visible`
|
|
253
|
+
: 'Select all'}
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
238
257
|
|
|
239
258
|
<ul className="max-h-80 overflow-y-auto py-1">
|
|
240
259
|
{filteredItems.length === 0 && (
|
|
@@ -253,7 +272,8 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
253
272
|
>
|
|
254
273
|
<span className="flex items-center gap-2 min-w-0">
|
|
255
274
|
<input
|
|
256
|
-
type=
|
|
275
|
+
type={scope.cacheScoped ? 'radio' : 'checkbox'}
|
|
276
|
+
name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
|
|
257
277
|
checked={isChecked}
|
|
258
278
|
onChange={() => toggle(ns)}
|
|
259
279
|
className="shrink-0 accent-current"
|
|
@@ -273,7 +293,9 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
|
|
|
273
293
|
|
|
274
294
|
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
275
295
|
<span>
|
|
276
|
-
{
|
|
296
|
+
{scope.cacheScoped
|
|
297
|
+
? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
|
|
298
|
+
: draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
|
|
277
299
|
</span>
|
|
278
300
|
<button
|
|
279
301
|
onClick={closeAndApply}
|