@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&rsquo;t allow listing all
289
- namespaces. Other namespaces may be accessible but won&rsquo;t
290
- appear here until you switch context.
291
- </div>
292
- )}
293
- </div>,
294
- document.body,
295
- )}
296
- </>
297
- )
298
- })