@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.
- package/package.json +5 -5
- package/src/App.tsx +412 -146
- package/src/RadarApp.tsx +21 -1
- package/src/api/client.ts +144 -12
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -278
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +23 -17
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +18 -7
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +45 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- 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
|
-
<
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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${
|
|
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
|
|
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(
|
|
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-
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
</>
|
|
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
|
})
|