@skyhook-io/radar-app 1.0.1 → 1.0.2
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.
|
@@ -1,298 +0,0 @@
|
|
|
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
|
-
})
|