@skyhook-io/radar-app 0.2.2 → 1.0.1
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 +143 -36
- package/src/api/client.ts +121 -4
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/audit/AuditSettingsDialog.tsx +49 -10
- package/src/components/helm/ChartBrowser.tsx +11 -11
- package/src/components/helm/HelmReleaseDrawer.tsx +35 -19
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/helm/InstallWizard.tsx +79 -22
- package/src/components/home/HomeView.tsx +13 -1
- package/src/components/portforward/PortForwardButton.tsx +37 -16
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +17 -18
- package/src/components/ui/DiagnosticsOverlay.tsx +93 -2
- package/src/components/ui/UpdateNotification.tsx +7 -7
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
1
|
+
import { useMemo, useState, forwardRef } from 'react'
|
|
2
2
|
import { AlertTriangle } from 'lucide-react'
|
|
3
3
|
import {
|
|
4
4
|
ClusterSwitcher,
|
|
@@ -16,11 +16,15 @@ interface ContextSwitcherProps {
|
|
|
16
16
|
className?: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface ContextSwitcherHandle {
|
|
20
|
+
open: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
interface ParsedContext extends ParsedContextName {
|
|
20
24
|
context: ContextInfo
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
export
|
|
27
|
+
export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
|
|
24
28
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
25
29
|
const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
|
|
26
30
|
const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
|
|
@@ -33,13 +37,37 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
33
37
|
const { tabs } = useDock()
|
|
34
38
|
|
|
35
39
|
// Parse contexts and decide whether to render group headers (multi-account only).
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// hasMultipleSources gates the kubeconfig-source chip — only useful when 2+
|
|
41
|
+
// distinct kubeconfig files are in play. Single-source setups (the common
|
|
42
|
+
// case) skip the chip entirely so the dropdown stays clean.
|
|
43
|
+
const { parsedById, hasMultipleAccounts, hasMultipleSources } = useMemo(() => {
|
|
44
|
+
if (!contexts) return {
|
|
45
|
+
parsedById: new Map<string, ParsedContext>(),
|
|
46
|
+
hasMultipleAccounts: false,
|
|
47
|
+
hasMultipleSources: false,
|
|
48
|
+
}
|
|
49
|
+
// Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
|
|
50
|
+
// before parsing — qualified names won't match the GKE/EKS/AKS regexes
|
|
51
|
+
// otherwise, and the suffix is redundant with the source chip we
|
|
52
|
+
// render separately.
|
|
53
|
+
const stripSourceSuffix = (name: string, source?: string): string => {
|
|
54
|
+
if (!source) return name
|
|
55
|
+
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
56
|
+
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
|
|
57
|
+
}
|
|
58
|
+
const parsed: ParsedContext[] = contexts.map(ctx => ({
|
|
59
|
+
context: ctx,
|
|
60
|
+
...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
|
|
61
|
+
}))
|
|
39
62
|
const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
|
|
63
|
+
const sources = new Set(contexts.map(c => c.source).filter(Boolean))
|
|
40
64
|
const byId = new Map<string, ParsedContext>()
|
|
41
65
|
for (const p of parsed) byId.set(p.context.name, p)
|
|
42
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
parsedById: byId,
|
|
68
|
+
hasMultipleAccounts: accounts.size > 1,
|
|
69
|
+
hasMultipleSources: sources.size > 1,
|
|
70
|
+
}
|
|
43
71
|
}, [contexts])
|
|
44
72
|
|
|
45
73
|
// Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
|
|
@@ -61,20 +89,16 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
61
89
|
: hasMultipleAccounts
|
|
62
90
|
? 'Other'
|
|
63
91
|
: undefined
|
|
64
|
-
// `name` is the raw context — ClusterSwitcher renders it through
|
|
65
|
-
// ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
|
|
66
|
-
// tail. `secondary` shows the original raw when we collapsed it,
|
|
67
|
-
// so users always see the full context at a glance (rather than
|
|
68
|
-
// having to hover to reveal it).
|
|
69
92
|
return {
|
|
70
93
|
id: p.context.name,
|
|
71
|
-
name: p.
|
|
94
|
+
name: p.raw,
|
|
72
95
|
secondary: p.provider ? p.raw : undefined,
|
|
73
96
|
badge: p.region || undefined,
|
|
97
|
+
sourceLabel: hasMultipleSources ? p.context.source : undefined,
|
|
74
98
|
group: { key: groupKey, label: groupLabel },
|
|
75
99
|
}
|
|
76
100
|
})
|
|
77
|
-
}, [parsedById, hasMultipleAccounts])
|
|
101
|
+
}, [parsedById, hasMultipleAccounts, hasMultipleSources])
|
|
78
102
|
|
|
79
103
|
const performSwitch = async (parsed: ParsedContext) => {
|
|
80
104
|
startSwitch({
|
|
@@ -152,15 +176,24 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
152
176
|
)
|
|
153
177
|
}
|
|
154
178
|
|
|
155
|
-
const
|
|
156
|
-
const currentId =
|
|
179
|
+
const currentCtx = contexts?.find(c => c.isCurrent)
|
|
180
|
+
const currentId = currentCtx?.name
|
|
181
|
+
// Use parsed.raw (the source-stripped form) for the trigger so the
|
|
182
|
+
// disambiguation suffix doesn't double up with the source chip.
|
|
183
|
+
// Fall back to clusterInfo.context for the very-early window before
|
|
184
|
+
// /api/contexts has resolved.
|
|
185
|
+
const currentParsed = currentId ? parsedById.get(currentId) : undefined
|
|
186
|
+
const currentRaw = currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
|
|
187
|
+
const currentSourceLabel = hasMultipleSources ? currentCtx?.source || undefined : undefined
|
|
157
188
|
|
|
158
189
|
return (
|
|
159
190
|
<>
|
|
160
191
|
<ClusterSwitcher
|
|
192
|
+
ref={ref}
|
|
161
193
|
className={className}
|
|
162
194
|
currentId={currentId}
|
|
163
195
|
currentName={currentRaw}
|
|
196
|
+
currentSourceLabel={currentSourceLabel}
|
|
164
197
|
items={items}
|
|
165
198
|
onSelect={handleSelect}
|
|
166
199
|
loading={switchContext.isPending}
|
|
@@ -222,4 +255,4 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
222
255
|
)}
|
|
223
256
|
</>
|
|
224
257
|
)
|
|
225
|
-
}
|
|
258
|
+
})
|
|
@@ -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,7 +1,9 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { X, Plus, Trash2 } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
3
4
|
import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
|
|
4
5
|
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
|
|
5
7
|
|
|
6
8
|
interface AuditSettingsDialogProps {
|
|
7
9
|
namespaces: string[]
|
|
@@ -28,12 +30,23 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
28
30
|
? Object.values(auditData.checks).sort((a, b) => a.title.localeCompare(b.title))
|
|
29
31
|
: []
|
|
30
32
|
|
|
33
|
+
// Validate the staged namespace input against RFC 1123. Saving a bogus
|
|
34
|
+
// entry would silently never match anything in the audit pipeline,
|
|
35
|
+
// leaving the user thinking the ignore filter doesn't work.
|
|
36
|
+
const newNsTrimmed = newNs.trim()
|
|
37
|
+
const newNsValidation = useMemo<ValidationResult>(
|
|
38
|
+
() => (newNsTrimmed === '' ? { valid: true } : validateRFC1123Label(newNsTrimmed)),
|
|
39
|
+
[newNsTrimmed],
|
|
40
|
+
)
|
|
41
|
+
const newNsError = newNsValidation.valid ? null : newNsValidation.error
|
|
42
|
+
const newNsDuplicate = newNsTrimmed !== '' && ignoredNs.includes(newNsTrimmed)
|
|
43
|
+
const canAddNamespace =
|
|
44
|
+
newNsTrimmed !== '' && newNsValidation.valid && !newNsDuplicate
|
|
45
|
+
|
|
31
46
|
const addNamespace = () => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
setNewNs('')
|
|
36
|
-
}
|
|
47
|
+
if (!canAddNamespace) return
|
|
48
|
+
setIgnoredNs([...ignoredNs, newNsTrimmed])
|
|
49
|
+
setNewNs('')
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
const toggleCheck = (checkID: string) => {
|
|
@@ -95,16 +108,30 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
95
108
|
onChange={e => setNewNs(e.target.value)}
|
|
96
109
|
onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
|
|
97
110
|
placeholder="Add namespace..."
|
|
98
|
-
|
|
111
|
+
aria-invalid={newNsError ? true : undefined}
|
|
112
|
+
aria-describedby="new-ns-help"
|
|
113
|
+
className={clsx(
|
|
114
|
+
'flex-1 px-3 py-1.5 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
|
|
115
|
+
newNsError || newNsDuplicate
|
|
116
|
+
? 'border-red-500/60 focus:ring-red-500'
|
|
117
|
+
: 'border-theme-border-light focus:ring-skyhook-500',
|
|
118
|
+
)}
|
|
99
119
|
/>
|
|
100
120
|
<button
|
|
101
121
|
onClick={addNamespace}
|
|
102
|
-
disabled={!
|
|
122
|
+
disabled={!canAddNamespace}
|
|
103
123
|
className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
104
124
|
>
|
|
105
125
|
<Plus className="w-4 h-4" />
|
|
106
126
|
</button>
|
|
107
127
|
</div>
|
|
128
|
+
{(newNsError || newNsDuplicate) && (
|
|
129
|
+
<p id="new-ns-help" className="mt-1.5 text-xs text-red-400">
|
|
130
|
+
{newNsDuplicate
|
|
131
|
+
? `"${newNsTrimmed}" is already in the list.`
|
|
132
|
+
: `Namespace ${newNsError}.`}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
108
135
|
</div>
|
|
109
136
|
|
|
110
137
|
{/* Disabled Checks */}
|
|
@@ -150,8 +177,20 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
150
177
|
</button>
|
|
151
178
|
<button
|
|
152
179
|
onClick={handleSave}
|
|
153
|
-
|
|
154
|
-
|
|
180
|
+
// Block save while the namespace input has unfixed pending
|
|
181
|
+
// text — otherwise the user clicks Save expecting their
|
|
182
|
+
// entry to be included and it's silently dropped.
|
|
183
|
+
disabled={
|
|
184
|
+
updateSettings.isPending || newNsError !== null || newNsDuplicate
|
|
185
|
+
}
|
|
186
|
+
title={
|
|
187
|
+
newNsError
|
|
188
|
+
? 'Fix or clear the pending namespace input before saving'
|
|
189
|
+
: newNsDuplicate
|
|
190
|
+
? 'Clear the duplicate pending input before saving'
|
|
191
|
+
: undefined
|
|
192
|
+
}
|
|
193
|
+
className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
155
194
|
>
|
|
156
195
|
{updateSettings.isPending ? 'Saving...' : 'Save'}
|
|
157
196
|
</button>
|
|
@@ -148,7 +148,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
148
148
|
onClick={() => { setSelectedRepo('all'); setRepoDropdownOpen(false) }}
|
|
149
149
|
className={clsx(
|
|
150
150
|
'w-full px-3 py-2 text-left text-sm hover:bg-theme-hover flex items-center justify-between',
|
|
151
|
-
selectedRepo === 'all' ? 'text-
|
|
151
|
+
selectedRepo === 'all' ? 'text-accent' : 'text-theme-text-primary'
|
|
152
152
|
)}
|
|
153
153
|
>
|
|
154
154
|
<span>All Repositories</span>
|
|
@@ -186,9 +186,9 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
186
186
|
type="checkbox"
|
|
187
187
|
checked={showOfficialOnly}
|
|
188
188
|
onChange={(e) => setShowOfficialOnly(e.target.checked)}
|
|
189
|
-
className="rounded border-theme-border text-
|
|
189
|
+
className="rounded border-theme-border text-accent focus:ring-accent"
|
|
190
190
|
/>
|
|
191
|
-
<BadgeCheck className="w-3.5 h-3.5 text-
|
|
191
|
+
<BadgeCheck className="w-3.5 h-3.5 text-accent" />
|
|
192
192
|
Official
|
|
193
193
|
</label>
|
|
194
194
|
<label className="flex items-center gap-1.5 text-sm text-theme-text-secondary">
|
|
@@ -196,7 +196,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
196
196
|
type="checkbox"
|
|
197
197
|
checked={showVerifiedOnly}
|
|
198
198
|
onChange={(e) => setShowVerifiedOnly(e.target.checked)}
|
|
199
|
-
className="rounded border-theme-border text-
|
|
199
|
+
className="rounded border-theme-border text-accent focus:ring-accent"
|
|
200
200
|
/>
|
|
201
201
|
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
202
202
|
Verified
|
|
@@ -207,7 +207,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
207
207
|
<select
|
|
208
208
|
value={artifactHubSort}
|
|
209
209
|
onChange={(e) => setArtifactHubSort(e.target.value as ArtifactHubSortOption)}
|
|
210
|
-
className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-
|
|
210
|
+
className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
|
211
211
|
>
|
|
212
212
|
<option value="relevance">Relevance</option>
|
|
213
213
|
<option value="stars">Stars</option>
|
|
@@ -225,7 +225,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
225
225
|
placeholder={chartSource === 'local' ? "Search charts..." : "Search ArtifactHub..."}
|
|
226
226
|
value={searchTerm}
|
|
227
227
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
228
|
-
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-
|
|
228
|
+
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent"
|
|
229
229
|
/>
|
|
230
230
|
</div>
|
|
231
231
|
|
|
@@ -237,7 +237,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
237
237
|
type="checkbox"
|
|
238
238
|
checked={showAllVersions}
|
|
239
239
|
onChange={(e) => setShowAllVersions(e.target.checked)}
|
|
240
|
-
className="rounded border-theme-border text-
|
|
240
|
+
className="rounded border-theme-border text-accent focus:ring-accent"
|
|
241
241
|
/>
|
|
242
242
|
All versions
|
|
243
243
|
</label>
|
|
@@ -276,7 +276,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
276
276
|
Add repositories using <code className="bg-theme-elevated px-1 rounded">helm repo add</code>
|
|
277
277
|
</p>
|
|
278
278
|
<p className="mt-2">
|
|
279
|
-
Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-
|
|
279
|
+
Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-accent-text hover:underline">ArtifactHub</button>
|
|
280
280
|
</p>
|
|
281
281
|
</div>
|
|
282
282
|
) : (
|
|
@@ -375,7 +375,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
|
|
|
375
375
|
onClick={onSelect}
|
|
376
376
|
className={clsx(
|
|
377
377
|
'flex-1 text-left text-sm truncate',
|
|
378
|
-
isSelected ? 'text-
|
|
378
|
+
isSelected ? 'text-accent' : 'text-theme-text-primary'
|
|
379
379
|
)}
|
|
380
380
|
>
|
|
381
381
|
{repo.name}
|
|
@@ -413,7 +413,7 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
|
|
|
413
413
|
<img
|
|
414
414
|
src={chart.icon}
|
|
415
415
|
alt=""
|
|
416
|
-
className="w-10 h-10 rounded object-contain bg-
|
|
416
|
+
className="w-10 h-10 rounded object-contain bg-theme-elevated p-1"
|
|
417
417
|
onError={(e) => {
|
|
418
418
|
(e.target as HTMLImageElement).style.display = 'none'
|
|
419
419
|
}}
|
|
@@ -476,7 +476,7 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
|
|
|
476
476
|
<img
|
|
477
477
|
src={chart.logoUrl}
|
|
478
478
|
alt=""
|
|
479
|
-
className="w-12 h-12 rounded object-contain bg-
|
|
479
|
+
className="w-12 h-12 rounded object-contain bg-theme-elevated p-1 shrink-0"
|
|
480
480
|
onError={(e) => {
|
|
481
481
|
(e.target as HTMLImageElement).style.display = 'none'
|
|
482
482
|
}}
|