@skyhook-io/radar-app 1.5.0 → 1.6.0
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 +4 -4
- package/src/App.tsx +168 -42
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +65 -2
- package/src/components/UserMenu.tsx +56 -10
- package/src/components/applications/ApplicationsView.tsx +27 -19
- package/src/components/audit/AuditSettingsDialog.tsx +1 -1
- package/src/components/audit/AuditView.tsx +23 -35
- package/src/components/gitops/GitOpsView.tsx +24 -2
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +34 -0
- package/src/components/issues/IssuesPane.tsx +82 -28
- package/src/components/nav/PrimaryNavRail.tsx +282 -0
- package/src/components/resource/HPACharts.tsx +7 -2
- package/src/components/resource/RestartChart.tsx +8 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +18 -1
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +493 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/utils/navigation.ts +11 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { getResourceIcon } from '../../utils/resource-icons'
|
|
7
|
+
import { useSearch, useNamespaceScope, useContexts, type SearchHit, type SearchMatchedField } from '../../api/client'
|
|
8
|
+
import { useAPIResources } from '../../api/apiResources'
|
|
9
|
+
import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
|
|
10
|
+
import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
|
|
11
|
+
import { SearchSyntaxHelp } from './SearchSyntaxHelp'
|
|
12
|
+
|
|
13
|
+
// Health → dot color (summaryContext.health is the same vocabulary as the rest
|
|
14
|
+
// of Radar). Kept local + tiny to avoid pulling the full status-tone system.
|
|
15
|
+
function healthDot(health?: string): string | null {
|
|
16
|
+
switch (health) {
|
|
17
|
+
case 'healthy': return 'bg-emerald-500'
|
|
18
|
+
case 'degraded': return 'bg-amber-500'
|
|
19
|
+
case 'unhealthy': return 'bg-red-500'
|
|
20
|
+
case 'unknown': return 'bg-theme-text-tertiary'
|
|
21
|
+
default: return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeRe(s: string): string {
|
|
26
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Wrap matched substrings in a brand-tinted, bold run so the user can see WHY a
|
|
30
|
+
// result matched — including when the match is on the namespace/kind, not the
|
|
31
|
+
// name. Longest tokens first so "staging" wins over a stray "s".
|
|
32
|
+
function highlight(text: string, tokens: string[]): React.ReactNode {
|
|
33
|
+
const toks = [...new Set(tokens.filter(Boolean))].sort((a, b) => b.length - a.length)
|
|
34
|
+
if (!toks.length || !text) return text
|
|
35
|
+
const re = new RegExp(`(${toks.map(escapeRe).join('|')})`, 'ig')
|
|
36
|
+
const parts: React.ReactNode[] = []
|
|
37
|
+
let last = 0
|
|
38
|
+
for (const m of text.matchAll(re)) {
|
|
39
|
+
const i = m.index ?? 0
|
|
40
|
+
if (i > last) parts.push(text.slice(last, i))
|
|
41
|
+
parts.push(<mark key={i} className="bg-transparent font-semibold text-[var(--color-brand)]">{m[0]}</mark>)
|
|
42
|
+
last = i + m[0].length
|
|
43
|
+
}
|
|
44
|
+
if (!parts.length) return text
|
|
45
|
+
if (last < text.length) parts.push(text.slice(last))
|
|
46
|
+
return parts
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The query tokens that the search engine recorded as landing on a given field
|
|
50
|
+
// (site), so each displayed field highlights only what actually matched it.
|
|
51
|
+
function tokensForSite(matched: SearchMatchedField[] | undefined, ...sites: string[]): string[] {
|
|
52
|
+
if (!matched) return []
|
|
53
|
+
return matched.filter((m) => sites.includes(m.site)).map((m) => m.token)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useDebounced<T>(value: T, ms: number): T {
|
|
57
|
+
const [v, setV] = useState(value)
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const t = setTimeout(() => setV(value), ms)
|
|
60
|
+
return () => clearTimeout(t)
|
|
61
|
+
}, [value, ms])
|
|
62
|
+
return v
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OmnibarHandle {
|
|
66
|
+
focus: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface OmnibarProps extends CommandItemCallbacks {
|
|
70
|
+
/** Open a resource hit (route-based — sets the URL + opens the drawer). */
|
|
71
|
+
onOpenResource: (hit: SearchHit) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Row =
|
|
75
|
+
| { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
|
|
76
|
+
| { id: string; kind: 'command'; command: CommandItem }
|
|
77
|
+
|
|
78
|
+
const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
|
|
79
|
+
const PAGE = 8
|
|
80
|
+
const STRONG_KIND = 100 // exact (150) or prefix (100) kind-name match
|
|
81
|
+
|
|
82
|
+
function pillsToQuery(pills: SearchModifier[]): string {
|
|
83
|
+
return pills.map((p) => `${p.key}:${p.value}`).join(' ')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The standalone omnibar: a persistent top-center search box that IS the ⌘K
|
|
87
|
+
// surface. Typing runs the live, GLOBAL resource search (/api/search) alongside
|
|
88
|
+
// the command-palette items; modifiers (ns:, kind:, …) become removable pills.
|
|
89
|
+
// Resources lead, commands follow. ⌘K focuses it.
|
|
90
|
+
export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
91
|
+
{ onOpenResource, ...callbacks },
|
|
92
|
+
ref,
|
|
93
|
+
) {
|
|
94
|
+
const [text, setText] = useState('')
|
|
95
|
+
const [pills, setPills] = useState<SearchModifier[]>([])
|
|
96
|
+
const [open, setOpen] = useState(false)
|
|
97
|
+
const [suggesting, setSuggesting] = useState(false)
|
|
98
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
99
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
100
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
102
|
+
// The dropdown is portaled to <body> (so the header's stacking context can't
|
|
103
|
+
// trap the dim overlay). `centerX` aligns the panel under the input; `top` is
|
|
104
|
+
// the HEADER's bottom (not the input's) so the dim starts cleanly below the
|
|
105
|
+
// whole top bar — the input is shorter than the bar, so anchoring to it would
|
|
106
|
+
// slice the dim through the taller right-side controls.
|
|
107
|
+
const [anchor, setAnchor] = useState<{ centerX: number; top: number } | null>(null)
|
|
108
|
+
|
|
109
|
+
useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
|
|
110
|
+
|
|
111
|
+
const { data: nsScope } = useNamespaceScope()
|
|
112
|
+
const { data: apiResources } = useAPIResources()
|
|
113
|
+
const { data: contexts } = useContexts()
|
|
114
|
+
// Recents are partitioned by the current cluster so a context switch never
|
|
115
|
+
// surfaces (or opens) the previous cluster's resources.
|
|
116
|
+
const contextKey = useMemo(() => contexts?.find((c) => c.isCurrent)?.name ?? '', [contexts])
|
|
117
|
+
// ns + kind are the bounded, knowable modifier value sets worth autocompleting.
|
|
118
|
+
const modifierOptions = useMemo(() => ({
|
|
119
|
+
ns: nsScope?.accessibleNamespaces ?? [],
|
|
120
|
+
kind: apiResources ? [...new Set(apiResources.filter((r) => r.verbs?.includes('list')).map((r) => r.kind))].sort() : [],
|
|
121
|
+
}), [nsScope, apiResources])
|
|
122
|
+
|
|
123
|
+
// Reflect the current view scope as an editable `ns:` pill on open, so a
|
|
124
|
+
// deliberately broad ⌘K search shows (and lets you remove) the namespace it's
|
|
125
|
+
// narrowed to instead of silently scoping. Seeded once per open, only from a
|
|
126
|
+
// truly empty launcher state.
|
|
127
|
+
const actives = nsScope?.actives
|
|
128
|
+
const seededRef = useRef(false)
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!open) { seededRef.current = false; return }
|
|
131
|
+
if (seededRef.current || actives === undefined) return
|
|
132
|
+
seededRef.current = true
|
|
133
|
+
if (pills.length === 0 && text === '' && actives.length > 0) {
|
|
134
|
+
setPills(actives.map((ns) => ({ key: 'ns', value: ns })))
|
|
135
|
+
}
|
|
136
|
+
}, [open, actives, pills.length, text])
|
|
137
|
+
|
|
138
|
+
const freeText = text.trim()
|
|
139
|
+
const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
|
|
140
|
+
const searchActive = queryString.length >= 2
|
|
141
|
+
// Small debounce: /api/search is a local in-memory index, so this exists only
|
|
142
|
+
// to coalesce fast keystrokes (less list reshuffle), not to cut network cost —
|
|
143
|
+
// kept under the ~100-150ms "feels instant" threshold. keepPreviousData +
|
|
144
|
+
// AbortSignal (see useSearch) handle the smoothness; commands aren't debounced.
|
|
145
|
+
const debounced = useDebounced(queryString, 120)
|
|
146
|
+
// globalNs: ⌘K searches the user's full RBAC ceiling; scope comes only from
|
|
147
|
+
// `ns:` pills, never the silent view filter.
|
|
148
|
+
const { data: searchData, isFetching, isPlaceholderData, isError } = useSearch(debounced, { enabled: open, globalNs: true })
|
|
149
|
+
|
|
150
|
+
const commandItems = useCommandItems(callbacks)
|
|
151
|
+
|
|
152
|
+
// Commands score against the FREE text only — modifiers live in pills, so the
|
|
153
|
+
// launcher never sees "ns:" polluting a "go to topology" match. Empty + no
|
|
154
|
+
// pills → Views + Actions (launcher default); with pills but no text the user
|
|
155
|
+
// is browsing a scope, so suppress the command default.
|
|
156
|
+
const scoredCommands = useMemo(() => {
|
|
157
|
+
if (!freeText) {
|
|
158
|
+
return pills.length ? [] : commandItems.filter((i) => i.category === 'Views' || i.category === 'Actions').map((item) => ({ item, score: 1 }))
|
|
159
|
+
}
|
|
160
|
+
return commandItems.map((item) => ({ item, score: bestScore(item, freeText) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
|
|
161
|
+
}, [commandItems, freeText, pills.length])
|
|
162
|
+
|
|
163
|
+
// Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
|
|
164
|
+
// resource instances: "⌘K → deployment → Deployments list" is a navigation
|
|
165
|
+
// flow the instance hits otherwise bury.
|
|
166
|
+
const leadingKinds = useMemo<CommandItem[]>(
|
|
167
|
+
() => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
|
|
168
|
+
[scoredCommands, freeText],
|
|
169
|
+
)
|
|
170
|
+
const leadingIds = useMemo(() => new Set(leadingKinds.map((i) => i.id)), [leadingKinds])
|
|
171
|
+
|
|
172
|
+
const resourceRows = useMemo<Row[]>(() => {
|
|
173
|
+
const hits = searchData?.hits ?? []
|
|
174
|
+
return hits.map((hit) => ({ id: `res:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
|
|
175
|
+
}, [searchData])
|
|
176
|
+
|
|
177
|
+
// Launcher recents: only in the truly-empty state (no text, no pills). Read
|
|
178
|
+
// fresh from localStorage each open.
|
|
179
|
+
const recentRows = useMemo<Row[]>(() => {
|
|
180
|
+
if (!open || freeText || pills.length > 0) return []
|
|
181
|
+
return loadRecentResources(contextKey).map((r) => ({
|
|
182
|
+
id: `recent:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
|
|
183
|
+
kind: 'resource' as const,
|
|
184
|
+
recent: true,
|
|
185
|
+
hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name } as SearchHit,
|
|
186
|
+
}))
|
|
187
|
+
}, [open, freeText, pills.length, contextKey])
|
|
188
|
+
|
|
189
|
+
// Remaining matched commands (leading kinds removed so they don't repeat),
|
|
190
|
+
// grouped by their real category in a fixed order.
|
|
191
|
+
const commandGroups = useMemo(() => {
|
|
192
|
+
const rest = scoredCommands.filter((x) => !leadingIds.has(x.item.id)).slice(0, 8).map((x) => x.item)
|
|
193
|
+
const byCat = new Map<string, CommandItem[]>()
|
|
194
|
+
for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
|
|
195
|
+
return COMMAND_CATEGORY_ORDER.filter((cat) => byCat.has(cat)).map((cat) => ({ category: cat, items: byCat.get(cat)! }))
|
|
196
|
+
}, [scoredCommands, leadingIds])
|
|
197
|
+
|
|
198
|
+
const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
|
|
199
|
+
|
|
200
|
+
// Free-text tokens for highlighting command labels (commands are scored
|
|
201
|
+
// client-side, so there's no server `matched`).
|
|
202
|
+
const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
|
|
203
|
+
|
|
204
|
+
// Ordered, id-stable list (render order == keyboard model): recents (launcher
|
|
205
|
+
// only), then leading kinds, then resources (when searchActive), then commands.
|
|
206
|
+
const rows = useMemo<Row[]>(() => {
|
|
207
|
+
const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
|
|
208
|
+
if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
|
|
209
|
+
return [...leadingKinds.map(toCmdRow), ...(searchActive ? resourceRows : []), ...cmds]
|
|
210
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
|
+
}, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive])
|
|
212
|
+
|
|
213
|
+
// Selection tracked by stable id (not array index) so Enter can never fire a
|
|
214
|
+
// stale row when the set shifts. Auto-follows the TOP result until the user
|
|
215
|
+
// arrow-keys; a new query re-enables auto-follow.
|
|
216
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
217
|
+
const userMovedRef = useRef(false)
|
|
218
|
+
useEffect(() => { userMovedRef.current = false }, [queryString])
|
|
219
|
+
const rowsKey = rows.map((r) => r.id).join('|')
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
setSelectedId((cur) => {
|
|
222
|
+
if (!userMovedRef.current) return rows[0]?.id ?? null
|
|
223
|
+
return cur && rows.some((r) => r.id === cur) ? cur : rows[0]?.id ?? null
|
|
224
|
+
})
|
|
225
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
226
|
+
}, [rowsKey])
|
|
227
|
+
const selectedIndex = rows.findIndex((r) => r.id === selectedId)
|
|
228
|
+
const moveSelection = (delta: number) => {
|
|
229
|
+
userMovedRef.current = true
|
|
230
|
+
setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
|
|
231
|
+
}
|
|
232
|
+
const selectRow = (id: string) => { userMovedRef.current = true; setSelectedId(id) }
|
|
233
|
+
// Page by a full screenful of visible rows (minus one for context overlap),
|
|
234
|
+
// measured from the scroll container — a fixed count feels short on tall lists.
|
|
235
|
+
const pageStep = () => {
|
|
236
|
+
const list = listRef.current
|
|
237
|
+
const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
|
|
238
|
+
if (!list || !rowH) return PAGE
|
|
239
|
+
return Math.max(1, Math.floor(list.clientHeight / rowH) - 1)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const execute = useCallback((row: Row) => {
|
|
243
|
+
if (row.kind === 'command') {
|
|
244
|
+
row.command.action()
|
|
245
|
+
} else {
|
|
246
|
+
const h = row.hit
|
|
247
|
+
recordRecentResource({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name }, contextKey)
|
|
248
|
+
onOpenResource(h)
|
|
249
|
+
}
|
|
250
|
+
setOpen(false)
|
|
251
|
+
setText('')
|
|
252
|
+
setPills([])
|
|
253
|
+
inputRef.current?.blur()
|
|
254
|
+
}, [onOpenResource, contextKey])
|
|
255
|
+
|
|
256
|
+
// The resources shown don't (yet) belong to the current query: the debounce
|
|
257
|
+
// hasn't fired, the data is React Query placeholder from a prior query, or
|
|
258
|
+
// results for this query haven't landed. Swallow Enter so it can't open a
|
|
259
|
+
// stale hit or a command standing in for an imminent resource.
|
|
260
|
+
const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
|
|
261
|
+
|
|
262
|
+
// Forwarded from SearchPillInput for keys it doesn't consume (it owns Space →
|
|
263
|
+
// pill, Backspace → pop pill, and suggestion nav).
|
|
264
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
265
|
+
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
|
|
266
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
|
|
267
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
|
|
268
|
+
else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
|
|
269
|
+
else if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-pageStep()) }
|
|
270
|
+
// Home/End deliberately left native so they move the text caret, not the list.
|
|
271
|
+
else if (e.key === 'Enter') {
|
|
272
|
+
e.preventDefault()
|
|
273
|
+
const row = rows[selectedIndex]
|
|
274
|
+
if (!row) return
|
|
275
|
+
// Block Enter only for a stale RESOURCE row (could open a hidden/stale
|
|
276
|
+
// hit); commands and ready resources fire immediately.
|
|
277
|
+
if (row.kind === 'resource' && resourcesStale) return
|
|
278
|
+
execute(row)
|
|
279
|
+
}
|
|
280
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
281
|
+
}, [rows, selectedIndex, execute, resourcesStale])
|
|
282
|
+
|
|
283
|
+
// Keep the selected row in view.
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
|
286
|
+
}, [selectedId])
|
|
287
|
+
|
|
288
|
+
// Close on outside click — the panel is portaled out of the container, so it
|
|
289
|
+
// must be excluded explicitly or clicking a row would count as "outside".
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!open) return
|
|
292
|
+
const onDown = (e: MouseEvent) => {
|
|
293
|
+
const t = e.target as Node
|
|
294
|
+
if (!containerRef.current?.contains(t) && !panelRef.current?.contains(t)) setOpen(false)
|
|
295
|
+
}
|
|
296
|
+
document.addEventListener('mousedown', onDown)
|
|
297
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
298
|
+
}, [open])
|
|
299
|
+
|
|
300
|
+
// Track the input's position so the portaled panel stays anchored under it
|
|
301
|
+
// through scroll / resize / layout shifts.
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (!open) { setAnchor(null); return }
|
|
304
|
+
const update = () => {
|
|
305
|
+
const el = containerRef.current
|
|
306
|
+
if (!el) return
|
|
307
|
+
const r = el.getBoundingClientRect()
|
|
308
|
+
const header = el.closest('header')
|
|
309
|
+
setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom })
|
|
310
|
+
}
|
|
311
|
+
update()
|
|
312
|
+
window.addEventListener('resize', update)
|
|
313
|
+
window.addEventListener('scroll', update, true)
|
|
314
|
+
return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true) }
|
|
315
|
+
}, [open])
|
|
316
|
+
|
|
317
|
+
const mac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac')
|
|
318
|
+
const total = searchData?.total ?? 0
|
|
319
|
+
const totalMatched = searchData?.total_matched ?? 0
|
|
320
|
+
const hasNsPill = pills.some((p) => p.key === 'ns')
|
|
321
|
+
const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
|
|
322
|
+
|
|
323
|
+
const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<div ref={containerRef} className="relative w-full max-w-xl">
|
|
327
|
+
<SearchPillInput
|
|
328
|
+
className="min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-colors"
|
|
329
|
+
text={text}
|
|
330
|
+
pills={pills}
|
|
331
|
+
onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
|
|
332
|
+
onKeyDown={handleKeyDown}
|
|
333
|
+
onFocus={() => setOpen(true)}
|
|
334
|
+
onSuggestingChange={setSuggesting}
|
|
335
|
+
modifierOptions={modifierOptions}
|
|
336
|
+
placeholder="Search resources & commands…"
|
|
337
|
+
aria-label="Search resources and commands"
|
|
338
|
+
inputRef={inputRef}
|
|
339
|
+
leftSlot={<Search className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />}
|
|
340
|
+
rightSlot={
|
|
341
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
342
|
+
<SearchSyntaxHelp />
|
|
343
|
+
{!text && pills.length === 0 && (
|
|
344
|
+
<kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
|
|
345
|
+
{mac ? '⌘' : 'Ctrl+'}K
|
|
346
|
+
</kbd>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
}
|
|
350
|
+
/>
|
|
351
|
+
|
|
352
|
+
{open && anchor && (dropdownOpen || suggesting) && createPortal(
|
|
353
|
+
<>
|
|
354
|
+
{/* Dim + blur the busy dashboard behind so results read as a focused
|
|
355
|
+
search surface (Spotlight/Linear pattern), not a weak float. Starts
|
|
356
|
+
at the header's bottom edge so the search box + top bar stay crisp.
|
|
357
|
+
Tied to `open`, NOT the results panel, so completing a modifier (the
|
|
358
|
+
panel briefly yields to the autocomplete) doesn't strobe the dim. */}
|
|
359
|
+
<div
|
|
360
|
+
className="fixed left-0 right-0 bottom-0 z-[120] bg-black/25 dark:bg-black/55 backdrop-blur-[2px]"
|
|
361
|
+
style={{ top: anchor.top }}
|
|
362
|
+
onClick={() => { setOpen(false); inputRef.current?.blur() }}
|
|
363
|
+
/>
|
|
364
|
+
{dropdownOpen && (
|
|
365
|
+
<div
|
|
366
|
+
ref={panelRef}
|
|
367
|
+
style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: 640, maxWidth: 'calc(100vw - 2rem)' }}
|
|
368
|
+
className="z-[121] dialog shadow-theme-lg overflow-hidden"
|
|
369
|
+
>
|
|
370
|
+
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
|
|
371
|
+
{/* Recently viewed — launcher state only. */}
|
|
372
|
+
{recentRows.length > 0 && (
|
|
373
|
+
<div>
|
|
374
|
+
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
|
|
375
|
+
{recentRows.map((row) => row.kind === 'resource' && (
|
|
376
|
+
<ResourceRow key={row.id} hit={row.hit} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => execute(row)} />
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* Leading kinds — strong kind-name matches lead so ⌘K navigation
|
|
382
|
+
to a kind isn't buried under instance hits. */}
|
|
383
|
+
{leadingKinds.length > 0 && (
|
|
384
|
+
<div>
|
|
385
|
+
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
|
|
386
|
+
{leadingKinds.map((item) => {
|
|
387
|
+
const id = `cmd:${item.id}`
|
|
388
|
+
return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute(toCmdRow(item))} />
|
|
389
|
+
})}
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
{/* Resources section */}
|
|
394
|
+
{searchActive && (
|
|
395
|
+
<>
|
|
396
|
+
<div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
397
|
+
<span>Resources</span>
|
|
398
|
+
{isFetching && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
399
|
+
{!isFetching && !isError && totalMatched > total && <span className="normal-case font-normal">showing {total} of {totalMatched}</span>}
|
|
400
|
+
</div>
|
|
401
|
+
{isError ? (
|
|
402
|
+
<div className="flex items-center gap-2 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
|
403
|
+
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> Search is unavailable right now.
|
|
404
|
+
</div>
|
|
405
|
+
) : resourceRows.length === 0 && !isFetching ? (
|
|
406
|
+
<div className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
407
|
+
No resources match{freeText ? <> “{freeText}”</> : ''}.
|
|
408
|
+
{hasNsPill && (
|
|
409
|
+
<button onMouseDown={(e) => { e.preventDefault(); clearNsPills() }} className="ml-1.5 text-[var(--color-brand)] hover:underline">
|
|
410
|
+
Search all namespaces
|
|
411
|
+
</button>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
) : (
|
|
415
|
+
resourceRows.map((row) => row.kind === 'resource' && (
|
|
416
|
+
// Mirror the Enter guard: ignore clicks on stale rows (prior
|
|
417
|
+
// query's results during debounce/placeholder) so a click can't
|
|
418
|
+
// open/record the wrong resource. Dim them so it reads as pending.
|
|
419
|
+
<ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
|
|
420
|
+
))
|
|
421
|
+
)}
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{/* Command groups, each under its real category header. */}
|
|
426
|
+
{commandGroups.map((group) => (
|
|
427
|
+
<div key={group.category}>
|
|
428
|
+
<div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
|
|
429
|
+
{group.items.map((item) => {
|
|
430
|
+
const id = `cmd:${item.id}`
|
|
431
|
+
return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute({ id, kind: 'command', command: item })} />
|
|
432
|
+
})}
|
|
433
|
+
</div>
|
|
434
|
+
))}
|
|
435
|
+
</div>
|
|
436
|
+
<div className="flex items-center gap-3 px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
437
|
+
<span className="flex items-center gap-1"><CornerDownLeft className="w-3 h-3" /> open</span>
|
|
438
|
+
<span>↑↓ navigate</span>
|
|
439
|
+
<span>⇞⇟ page</span>
|
|
440
|
+
<span>esc close</span>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
</>,
|
|
445
|
+
document.body,
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: SearchHit; selected: boolean; stale?: boolean; onSelect: () => void; onActivate: () => void }) {
|
|
452
|
+
const Icon = getResourceIcon(hit.kind)
|
|
453
|
+
const dot = healthDot(hit.summaryContext?.health)
|
|
454
|
+
const issues = hit.summaryContext?.issueCount ?? 0
|
|
455
|
+
// Lead is a name match; flag content-only matches so a name search isn't
|
|
456
|
+
// silently padded with body hits.
|
|
457
|
+
const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
|
|
458
|
+
return (
|
|
459
|
+
<button
|
|
460
|
+
data-selected={selected}
|
|
461
|
+
onClick={onActivate}
|
|
462
|
+
onMouseMove={onSelect}
|
|
463
|
+
className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40', stale && 'opacity-50')}
|
|
464
|
+
>
|
|
465
|
+
<Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
|
|
466
|
+
<span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(hit.name, tokensForSite(hit.matched, 'name'))}</span>
|
|
467
|
+
{dot && <span className={clsx('h-1.5 w-1.5 rounded-full shrink-0', dot)} />}
|
|
468
|
+
<span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
|
|
469
|
+
{highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
|
|
470
|
+
{hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
|
|
471
|
+
</span>
|
|
472
|
+
{contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
|
|
473
|
+
{issues > 0 && <span className="ml-auto shrink-0 text-[10px] font-medium text-amber-600 dark:text-amber-400">{issues} issue{issues === 1 ? '' : 's'}</span>}
|
|
474
|
+
</button>
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function CommandRow({ item, tokens, selected, onSelect, onActivate }: { item: CommandItem; tokens: string[]; selected: boolean; onSelect: () => void; onActivate: () => void }) {
|
|
479
|
+
const Icon = item.icon
|
|
480
|
+
return (
|
|
481
|
+
<button
|
|
482
|
+
data-selected={selected}
|
|
483
|
+
onClick={onActivate}
|
|
484
|
+
onMouseMove={onSelect}
|
|
485
|
+
className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40')}
|
|
486
|
+
>
|
|
487
|
+
{Icon ? <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" /> : <span className="w-4 shrink-0" />}
|
|
488
|
+
<span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(item.label, tokens)}</span>
|
|
489
|
+
{item.sublabel && <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">{highlight(item.sublabel, tokens)}</span>}
|
|
490
|
+
{item.shortcut && <kbd className="ml-auto shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded border border-theme-border-light">{item.shortcut}</kbd>}
|
|
491
|
+
</button>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { HelpCircle } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
|
|
6
|
+
// The query operators recognized by the search engine (internal/search/parse.go).
|
|
7
|
+
const OPERATORS: { syntax: string; desc: string }[] = [
|
|
8
|
+
{ syntax: 'ns:prod', desc: 'filter by namespace' },
|
|
9
|
+
{ syntax: 'kind:Deployment', desc: 'filter by resource kind' },
|
|
10
|
+
{ syntax: 'label:app=api', desc: 'filter by label' },
|
|
11
|
+
{ syntax: 'image:nginx', desc: 'match container image' },
|
|
12
|
+
{ syntax: 'redis cache', desc: 'multiple terms — all must match' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// A "?" affordance that reveals the search query syntax on HOVER (a transient
|
|
16
|
+
// reference tooltip, not a second click-to-open panel stacked over the results).
|
|
17
|
+
// Self-contained + portaled to <body> so the header's stacking context can't
|
|
18
|
+
// trap it; a short close grace lets the pointer travel onto the card.
|
|
19
|
+
export function SearchSyntaxHelp() {
|
|
20
|
+
const [open, setOpen] = useState(false)
|
|
21
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
22
|
+
const popRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
24
|
+
const [anchor, setAnchor] = useState<{ right: number; top: number } | null>(null)
|
|
25
|
+
|
|
26
|
+
const show = useCallback(() => { clearTimeout(closeTimer.current); setOpen(true) }, [])
|
|
27
|
+
const scheduleHide = useCallback(() => {
|
|
28
|
+
clearTimeout(closeTimer.current)
|
|
29
|
+
closeTimer.current = setTimeout(() => setOpen(false), 120)
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open) { setAnchor(null); return }
|
|
36
|
+
const update = () => {
|
|
37
|
+
const el = btnRef.current
|
|
38
|
+
if (el) { const r = el.getBoundingClientRect(); setAnchor({ right: window.innerWidth - r.right, top: r.bottom + 6 }) }
|
|
39
|
+
}
|
|
40
|
+
update()
|
|
41
|
+
window.addEventListener('resize', update)
|
|
42
|
+
window.addEventListener('scroll', update, true)
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener('resize', update)
|
|
45
|
+
window.removeEventListener('scroll', update, true)
|
|
46
|
+
}
|
|
47
|
+
}, [open])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<button
|
|
52
|
+
ref={btnRef}
|
|
53
|
+
type="button"
|
|
54
|
+
tabIndex={-1}
|
|
55
|
+
aria-label="Search syntax help"
|
|
56
|
+
onMouseEnter={show}
|
|
57
|
+
onMouseLeave={scheduleHide}
|
|
58
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
59
|
+
className={clsx('shrink-0 rounded p-0.5 cursor-help transition-colors', open ? 'text-theme-text-secondary' : 'text-theme-text-tertiary hover:text-theme-text-secondary')}
|
|
60
|
+
>
|
|
61
|
+
<HelpCircle className="w-3.5 h-3.5" />
|
|
62
|
+
</button>
|
|
63
|
+
{open && anchor && createPortal(
|
|
64
|
+
<div
|
|
65
|
+
ref={popRef}
|
|
66
|
+
onMouseEnter={show}
|
|
67
|
+
onMouseLeave={scheduleHide}
|
|
68
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
69
|
+
style={{ position: 'fixed', top: anchor.top, right: anchor.right, width: 280 }}
|
|
70
|
+
className="z-[130] dialog shadow-theme-lg p-3"
|
|
71
|
+
>
|
|
72
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary mb-2">Search syntax</div>
|
|
73
|
+
<div className="space-y-1.5">
|
|
74
|
+
{OPERATORS.map((op) => (
|
|
75
|
+
<div key={op.syntax} className="flex items-baseline gap-2">
|
|
76
|
+
<code className="shrink-0 rounded bg-theme-elevated border border-theme-border-light px-1.5 py-0.5 text-xs text-theme-text-primary">{op.syntax}</code>
|
|
77
|
+
<span className="text-xs text-theme-text-secondary">{op.desc}</span>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
<div className="mt-2.5 pt-2 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
82
|
+
Type <code className="text-theme-text-secondary">ns:</code> or <code className="text-theme-text-secondary">kind:</code> for value suggestions.
|
|
83
|
+
</div>
|
|
84
|
+
</div>,
|
|
85
|
+
document.body,
|
|
86
|
+
)}
|
|
87
|
+
</>
|
|
88
|
+
)
|
|
89
|
+
}
|