@skyhook-io/k8s-ui 1.8.7 → 1.8.9
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 +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +37 -30
- package/src/components/checks/ChecksView.tsx +25 -14
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +163 -88
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +20 -81
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +12 -0
- package/src/types/core.ts +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- package/src/utils/resource-hierarchy.ts +7 -4
|
@@ -42,11 +42,33 @@ export function diagnosticFactLabel(type: string): string {
|
|
|
42
42
|
return 'Affected workloads';
|
|
43
43
|
case 'pvc_blast_radius':
|
|
44
44
|
return 'Blocked pods';
|
|
45
|
+
case 'apiservice_hpa':
|
|
46
|
+
return 'Stalled autoscalers';
|
|
47
|
+
case 'secret_not_ready':
|
|
48
|
+
return 'Dependent pods';
|
|
45
49
|
default:
|
|
46
50
|
return type.replace(/_/g, ' ');
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// Operator-facing lead-in for the symptom→root pointer chip. Honest per fact
|
|
55
|
+
// type: a declared PVC/Secret edge is a cause; a co-located node is only
|
|
56
|
+
// "related" (node pressure can be a shared victim, not the root), so it must not
|
|
57
|
+
// claim "caused by".
|
|
58
|
+
export function incidentParentLabel(factType?: string, confidence?: string): string {
|
|
59
|
+
switch (factType) {
|
|
60
|
+
case 'pvc_blast_radius':
|
|
61
|
+
case 'secret_not_ready':
|
|
62
|
+
return 'Caused by';
|
|
63
|
+
case 'apiservice_hpa':
|
|
64
|
+
return 'Likely cause';
|
|
65
|
+
case 'node_blast_radius':
|
|
66
|
+
return 'Related';
|
|
67
|
+
default:
|
|
68
|
+
return confidence === 'high' ? 'Caused by' : 'Possible cause';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
// Plain-language gloss for the confidence chip's tooltip — the operator should
|
|
51
73
|
// know a medium link is "these are co-located, the node may be the cause", not a
|
|
52
74
|
// proven fact.
|
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
subjectRef,
|
|
13
13
|
memberRef,
|
|
14
14
|
} from './types';
|
|
15
|
-
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
15
|
+
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
16
16
|
export {
|
|
17
17
|
ISSUE_SEVERITY_LABEL,
|
|
18
18
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
2
4
|
import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
3
5
|
import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
|
|
6
|
+
import { IssueRow } from './IssuesView'
|
|
4
7
|
|
|
5
8
|
const base: Issue = {
|
|
6
9
|
id: 'id-0',
|
|
@@ -105,3 +108,21 @@ describe('image-pull message normalization', () => {
|
|
|
105
108
|
expect(parts.detail).toBe('')
|
|
106
109
|
})
|
|
107
110
|
})
|
|
111
|
+
|
|
112
|
+
describe('IssueRow diagnosis raw messages', () => {
|
|
113
|
+
it('shows raw_message when cleaned issue copy has no parsed cause', () => {
|
|
114
|
+
const issue = mk({
|
|
115
|
+
category: 'gitops_operation_failed',
|
|
116
|
+
category_group: 'configuration',
|
|
117
|
+
severity: 'critical',
|
|
118
|
+
reason: 'OperationFailed',
|
|
119
|
+
message: 'app path does not exist',
|
|
120
|
+
raw_message: 'rpc error: code = Unknown desc = app path does not exist',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const html = renderToString(createElement(IssueRow, { issue, open: true, onToggle: () => undefined, as: 'div' }))
|
|
124
|
+
|
|
125
|
+
expect(html).toContain('app path does not exist')
|
|
126
|
+
expect(html).toContain('rpc error: code = Unknown desc = app path does not exist')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -71,10 +71,26 @@ export interface IssueDiagnosticIssueRef {
|
|
|
71
71
|
reason?: string;
|
|
72
72
|
category?: string;
|
|
73
73
|
severity?: IssueSeverity;
|
|
74
|
+
/** How many affected resources fold into this linked issue from the root's
|
|
75
|
+
* perspective (e.g. 5 of a PVC's mounting pods under one Deployment issue).
|
|
76
|
+
* Absent when the link covers a single resource. */
|
|
77
|
+
count?: number;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
export type IssueDiagnosticConfidence = 'high' | 'medium' | 'low';
|
|
77
81
|
|
|
82
|
+
/** Reverse pointer from a symptom issue to the root issue that explains it
|
|
83
|
+
* (the inverse of diagnostic_context's root→symptom facts). Set only when a
|
|
84
|
+
* single root is unambiguous. `ref` is the parent subject for display + deep
|
|
85
|
+
* navigation (thread the issue's cluster_id onto it via memberRef). */
|
|
86
|
+
export interface IssueIncidentParent {
|
|
87
|
+
id: string;
|
|
88
|
+
ref: IssueResourceRef;
|
|
89
|
+
category?: string;
|
|
90
|
+
confidence?: IssueDiagnosticConfidence;
|
|
91
|
+
fact_type?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export interface IssueDiagnosticFact {
|
|
79
95
|
type: string;
|
|
80
96
|
message?: string;
|
|
@@ -158,6 +174,7 @@ export interface Issue {
|
|
|
158
174
|
|
|
159
175
|
reason: string;
|
|
160
176
|
message?: string;
|
|
177
|
+
raw_message?: string;
|
|
161
178
|
/** Parsed domain diagnosis: plain-English cause, suggested next step, and
|
|
162
179
|
* an optional structured one-click fix.
|
|
163
180
|
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
@@ -182,6 +199,7 @@ export interface Issue {
|
|
|
182
199
|
members?: IssueResourceRef[];
|
|
183
200
|
members_truncated?: boolean;
|
|
184
201
|
diagnostic_context?: IssueDiagnosticContext;
|
|
202
|
+
incident_parent?: IssueIncidentParent;
|
|
185
203
|
change_context?: IssueChangeContext;
|
|
186
204
|
|
|
187
205
|
// Pod crash context carried from the representative member.
|
|
@@ -0,0 +1,381 @@
|
|
|
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 { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
|
|
8
|
+
* response shape; the host supplies it however it fetches it (OSS via its API
|
|
9
|
+
* hooks, Radar Hub via the per-cluster apiBase).
|
|
10
|
+
*/
|
|
11
|
+
export interface NamespaceScopeView {
|
|
12
|
+
/** Currently-selected namespaces. Empty = cluster-wide ("All namespaces"). */
|
|
13
|
+
actives: string[]
|
|
14
|
+
/** Namespaces the user may pick from. */
|
|
15
|
+
accessibleNamespaces: string[]
|
|
16
|
+
mode?: 'cluster-wide' | 'namespace' | 'restricted' | string
|
|
17
|
+
/** Single-namespace cache-scope control instead of a multi-select filter. */
|
|
18
|
+
cacheScoped?: boolean
|
|
19
|
+
/** Under cacheScoped, whether the user may re-point the watched namespace. */
|
|
20
|
+
namespaceRescope?: boolean
|
|
21
|
+
cacheScopeNamespace?: string
|
|
22
|
+
kubeconfigNamespace?: string
|
|
23
|
+
canClearNamespace?: boolean
|
|
24
|
+
/** false when accessibleNamespaces is a best-effort short list (no list perm). */
|
|
25
|
+
authoritative?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NamespacePickerHandle {
|
|
29
|
+
open: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NamespacePickerProps {
|
|
33
|
+
/** null/undefined while loading — the picker renders nothing until it arrives. */
|
|
34
|
+
scope: NamespaceScopeView | null | undefined
|
|
35
|
+
/**
|
|
36
|
+
* Applied when the selection is committed (dropdown close / clear all).
|
|
37
|
+
* The picker only calls this when the selection actually changed and is
|
|
38
|
+
* valid (respects the cacheScoped single-namespace constraint).
|
|
39
|
+
*/
|
|
40
|
+
onApply: (namespaces: string[]) => void
|
|
41
|
+
loading?: boolean
|
|
42
|
+
/** A switch/mutation is in flight — trigger shows "Switching…" and is inert. */
|
|
43
|
+
pending?: boolean
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
disabledTooltip?: string
|
|
46
|
+
className?: string
|
|
47
|
+
/**
|
|
48
|
+
* 'chip' (default) renders a self-contained pill. 'segment' renders a
|
|
49
|
+
* borderless label+value cell for embedding in a shared bordered container
|
|
50
|
+
* (the unified cluster+namespace scope control), with an optional muted
|
|
51
|
+
* {@link label} before the value and a shorter value ("All" vs "All namespaces").
|
|
52
|
+
*/
|
|
53
|
+
variant?: 'chip' | 'segment'
|
|
54
|
+
/** Muted label shown before the value in the 'segment' variant (e.g. "Namespace"). */
|
|
55
|
+
label?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* NamespacePicker is the presentational namespace scope control shared by
|
|
60
|
+
* Radar OSS and Radar Hub (mirrors the ClusterSwitcher pattern — pure UI, data
|
|
61
|
+
* injected via props). It is normally a per-user multi-select view filter. When
|
|
62
|
+
* the scope reports cacheScoped=true, it becomes a single-namespace cache scope
|
|
63
|
+
* control.
|
|
64
|
+
*
|
|
65
|
+
* Three states reflect what the scope reports:
|
|
66
|
+
* - cluster-wide: empty trigger label "All namespaces"; picker lets the user
|
|
67
|
+
* narrow the view.
|
|
68
|
+
* - namespace: label shows the namespace count (or single name); picker
|
|
69
|
+
* offers other accessible namespaces and a clear-all reset.
|
|
70
|
+
* - restricted: user can't list namespaces and isn't pinned; picker
|
|
71
|
+
* surfaces only the kubeconfig context's namespace + any saved picks.
|
|
72
|
+
*
|
|
73
|
+
* Selection model: the dropdown keeps a draft Set<string>; toggling rows
|
|
74
|
+
* mutates the draft locally; closing the dropdown applies the draft in a single
|
|
75
|
+
* onApply. "Clear all" applies immediately and closes.
|
|
76
|
+
*/
|
|
77
|
+
export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
|
|
78
|
+
{ scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label },
|
|
79
|
+
ref,
|
|
80
|
+
) {
|
|
81
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
82
|
+
const [search, setSearch] = useState('')
|
|
83
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
|
84
|
+
const [draft, setDraft] = useState<Set<string>>(() => new Set())
|
|
85
|
+
|
|
86
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
87
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
88
|
+
|
|
89
|
+
const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
|
|
90
|
+
const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
|
|
91
|
+
|
|
92
|
+
// Sync the draft with the server's view whenever it changes (initial load,
|
|
93
|
+
// post-mutation refetch, eviction after RBAC drift).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setDraft(new Set(scopeActives))
|
|
96
|
+
}, [activesKey, scopeActives])
|
|
97
|
+
|
|
98
|
+
// Fully disable when the host disables the control (e.g. view-awareness
|
|
99
|
+
// navigating to a cluster-scoped surface): the trigger is inert and open()
|
|
100
|
+
// is blocked, but an already-open dropdown would still commit via Done /
|
|
101
|
+
// outside-click / Clear all — so close it (discarding the uncommitted draft)
|
|
102
|
+
// rather than letting a dead view apply a namespace change.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (disabled && isOpen) {
|
|
105
|
+
setIsOpen(false)
|
|
106
|
+
setSearch('')
|
|
107
|
+
// Discard the uncommitted draft so a later re-open reflects the current
|
|
108
|
+
// server actives, not stale toggles from before the control was disabled.
|
|
109
|
+
setDraft(new Set(scopeActives))
|
|
110
|
+
}
|
|
111
|
+
}, [disabled, isOpen, scopeActives])
|
|
112
|
+
|
|
113
|
+
const items = useMemo(() => {
|
|
114
|
+
if (!scope) return [] as string[]
|
|
115
|
+
return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
|
|
116
|
+
}, [scope])
|
|
117
|
+
|
|
118
|
+
const filteredItems = useMemo(() => {
|
|
119
|
+
const q = search.trim().toLowerCase()
|
|
120
|
+
if (!q) return items
|
|
121
|
+
return items.filter(n => n.toLowerCase().includes(q))
|
|
122
|
+
}, [items, search])
|
|
123
|
+
|
|
124
|
+
const applySelection = useCallback((next: Set<string>) => {
|
|
125
|
+
if (!scope) return
|
|
126
|
+
const nextArr = Array.from(next).sort()
|
|
127
|
+
if (scope.cacheScoped && nextArr.length !== 1) return
|
|
128
|
+
if (nextArr.join(',') === activesKey) return
|
|
129
|
+
onApply(nextArr)
|
|
130
|
+
}, [activesKey, scope, onApply])
|
|
131
|
+
|
|
132
|
+
const closeAndApply = useCallback(() => {
|
|
133
|
+
setIsOpen(false)
|
|
134
|
+
setSearch('')
|
|
135
|
+
applySelection(draft)
|
|
136
|
+
}, [applySelection, draft])
|
|
137
|
+
|
|
138
|
+
useImperativeHandle(ref, () => ({
|
|
139
|
+
open: () => {
|
|
140
|
+
if (disabled || loading || pending) return
|
|
141
|
+
setIsOpen(true)
|
|
142
|
+
},
|
|
143
|
+
}), [disabled, loading, pending])
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!isOpen) return
|
|
147
|
+
const trigger = triggerRef.current
|
|
148
|
+
if (!trigger) return
|
|
149
|
+
const r = trigger.getBoundingClientRect()
|
|
150
|
+
setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
|
|
151
|
+
}, [isOpen])
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!isOpen) return
|
|
155
|
+
function onClick(e: MouseEvent) {
|
|
156
|
+
if (
|
|
157
|
+
!dropdownRef.current?.contains(e.target as Node) &&
|
|
158
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
159
|
+
) {
|
|
160
|
+
closeAndApply()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function onKey(e: KeyboardEvent) {
|
|
164
|
+
if (e.key === 'Escape') closeAndApply()
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener('mousedown', onClick)
|
|
167
|
+
document.addEventListener('keydown', onKey)
|
|
168
|
+
return () => {
|
|
169
|
+
document.removeEventListener('mousedown', onClick)
|
|
170
|
+
document.removeEventListener('keydown', onKey)
|
|
171
|
+
}
|
|
172
|
+
}, [isOpen, closeAndApply])
|
|
173
|
+
|
|
174
|
+
if (!scope) return null
|
|
175
|
+
|
|
176
|
+
const toggle = (ns: string) => {
|
|
177
|
+
if (scope.cacheScoped) {
|
|
178
|
+
setDraft(new Set([ns]))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const next = new Set(draft)
|
|
182
|
+
if (next.has(ns)) next.delete(ns)
|
|
183
|
+
else next.add(ns)
|
|
184
|
+
setDraft(next)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const clearAll = () => {
|
|
188
|
+
if (scope.cacheScoped) return
|
|
189
|
+
setDraft(new Set())
|
|
190
|
+
setIsOpen(false)
|
|
191
|
+
setSearch('')
|
|
192
|
+
applySelection(new Set())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const selectAllVisible = () => {
|
|
196
|
+
const next = new Set(draft)
|
|
197
|
+
for (const ns of filteredItems) next.add(ns)
|
|
198
|
+
setDraft(next)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const clearVisible = () => {
|
|
202
|
+
const next = new Set(draft)
|
|
203
|
+
for (const ns of filteredItems) next.delete(ns)
|
|
204
|
+
setDraft(next)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const activeCount = scopeActives.length
|
|
208
|
+
const triggerLabel =
|
|
209
|
+
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
210
|
+
const isClusterWide = activeCount === 0
|
|
211
|
+
const restrictedHint = scope.mode === 'restricted'
|
|
212
|
+
const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
|
|
213
|
+
const isDisabled = disabled || loading || pending || cacheScopeLocked
|
|
214
|
+
const canClearAll = scope.canClearNamespace || activeCount === 0
|
|
215
|
+
const tooltipContent = disabled && disabledTooltip
|
|
216
|
+
? disabledTooltip
|
|
217
|
+
: scope.cacheScoped
|
|
218
|
+
? scope.namespaceRescope
|
|
219
|
+
? `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).`
|
|
220
|
+
: `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
|
|
221
|
+
: restrictedHint
|
|
222
|
+
? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
|
|
223
|
+
: isClusterWide
|
|
224
|
+
? 'Currently viewing all namespaces. Click to narrow the view.'
|
|
225
|
+
: activeCount === 1
|
|
226
|
+
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
227
|
+
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
228
|
+
|
|
229
|
+
// Counts used to label the bulk-action buttons; computed against the visible
|
|
230
|
+
// (filtered) set so the labels match what the action will affect.
|
|
231
|
+
const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
|
|
232
|
+
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<Tooltip
|
|
237
|
+
content={tooltipContent}
|
|
238
|
+
delay={300}
|
|
239
|
+
position="bottom"
|
|
240
|
+
>
|
|
241
|
+
<button
|
|
242
|
+
ref={triggerRef}
|
|
243
|
+
onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
|
|
244
|
+
disabled={isDisabled}
|
|
245
|
+
className={
|
|
246
|
+
variant === 'segment'
|
|
247
|
+
? `flex items-center justify-center gap-1.5 px-3 py-1.5 h-full min-w-[110px] max-w-[200px] text-[13px] text-theme-text-primary hover:bg-theme-hover disabled:opacity-60 transition-colors ${className}`
|
|
248
|
+
: `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}`
|
|
249
|
+
}
|
|
250
|
+
aria-label="Switch active namespaces"
|
|
251
|
+
>
|
|
252
|
+
{label && (
|
|
253
|
+
<span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
|
|
254
|
+
)}
|
|
255
|
+
{isClusterWide ? (
|
|
256
|
+
<Globe className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
257
|
+
) : restrictedHint ? (
|
|
258
|
+
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
259
|
+
) : null}
|
|
260
|
+
<span className={`font-medium truncate ${variant === 'segment' ? 'min-w-0' : 'max-w-[180px]'}`}>
|
|
261
|
+
{pending ? 'Switching…' : triggerLabel}
|
|
262
|
+
</span>
|
|
263
|
+
<ChevronDown className="w-3 h-3 shrink-0 opacity-60" />
|
|
264
|
+
</button>
|
|
265
|
+
</Tooltip>
|
|
266
|
+
|
|
267
|
+
{isOpen &&
|
|
268
|
+
createPortal(
|
|
269
|
+
<div
|
|
270
|
+
ref={dropdownRef}
|
|
271
|
+
style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
|
|
272
|
+
className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
|
|
273
|
+
>
|
|
274
|
+
{items.length > 6 && (
|
|
275
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
276
|
+
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
277
|
+
<input
|
|
278
|
+
autoFocus
|
|
279
|
+
value={search}
|
|
280
|
+
onChange={e => setSearch(e.target.value)}
|
|
281
|
+
placeholder="Filter namespaces"
|
|
282
|
+
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{scope.cacheScoped ? (
|
|
288
|
+
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
289
|
+
Radar is watching one namespace to stay fast on large clusters.
|
|
290
|
+
{scope.namespaceRescope
|
|
291
|
+
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
292
|
+
: ' This instance is locked to its startup namespace.'}
|
|
293
|
+
</div>
|
|
294
|
+
) : (
|
|
295
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
|
|
296
|
+
<button
|
|
297
|
+
onClick={canClearAll ? clearAll : undefined}
|
|
298
|
+
disabled={!canClearAll || activeCount === 0}
|
|
299
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
300
|
+
aria-label="Clear namespace selection"
|
|
301
|
+
>
|
|
302
|
+
<X className="w-3 h-3" />
|
|
303
|
+
Clear all
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
onClick={allVisibleSelected ? clearVisible : selectAllVisible}
|
|
307
|
+
disabled={filteredItems.length === 0}
|
|
308
|
+
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
309
|
+
>
|
|
310
|
+
{allVisibleSelected
|
|
311
|
+
? `Clear ${filteredItems.length} visible`
|
|
312
|
+
: search.trim()
|
|
313
|
+
? `Select ${filteredItems.length} visible`
|
|
314
|
+
: 'Select all'}
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
<ul className="max-h-80 overflow-y-auto py-1">
|
|
320
|
+
{filteredItems.length === 0 && (
|
|
321
|
+
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
322
|
+
{search ? 'No matches.' : 'No namespaces available.'}
|
|
323
|
+
</li>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{filteredItems.map(ns => {
|
|
327
|
+
const isChecked = draft.has(ns)
|
|
328
|
+
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
329
|
+
return (
|
|
330
|
+
<li key={ns}>
|
|
331
|
+
<label
|
|
332
|
+
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"
|
|
333
|
+
>
|
|
334
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
335
|
+
<input
|
|
336
|
+
type={scope.cacheScoped ? 'radio' : 'checkbox'}
|
|
337
|
+
name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
|
|
338
|
+
checked={isChecked}
|
|
339
|
+
onChange={() => toggle(ns)}
|
|
340
|
+
className="shrink-0 accent-current"
|
|
341
|
+
/>
|
|
342
|
+
<span className="truncate">{ns}</span>
|
|
343
|
+
{isContextDefault && (
|
|
344
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
345
|
+
kubeconfig
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</span>
|
|
349
|
+
</label>
|
|
350
|
+
</li>
|
|
351
|
+
)
|
|
352
|
+
})}
|
|
353
|
+
</ul>
|
|
354
|
+
|
|
355
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
356
|
+
<span>
|
|
357
|
+
{scope.cacheScoped
|
|
358
|
+
? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
|
|
359
|
+
: draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
|
|
360
|
+
</span>
|
|
361
|
+
<button
|
|
362
|
+
onClick={closeAndApply}
|
|
363
|
+
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
364
|
+
>
|
|
365
|
+
Done
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{!scope.authoritative && (
|
|
370
|
+
<div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
|
|
371
|
+
Limited list — your RBAC doesn’t allow listing all
|
|
372
|
+
namespaces. Other namespaces may be accessible but won’t
|
|
373
|
+
appear here until you switch context.
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>,
|
|
377
|
+
document.body,
|
|
378
|
+
)}
|
|
379
|
+
</>
|
|
380
|
+
)
|
|
381
|
+
})
|