@skyhook-io/radar-app 1.6.0 → 1.6.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.
- package/package.json +3 -2
- package/src/App.tsx +19 -22
- package/src/api/client.ts +29 -16
- package/src/components/gitops/GitOpsView.tsx +13 -6
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +12 -10
- package/src/components/home/HomeView.tsx +204 -56
- package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
- package/src/components/resources/ResourcesView.tsx +46 -8
- package/src/components/timeline/TimelineList.tsx +6 -1
- package/src/components/timeline/TimelineView.tsx +9 -0
- package/src/components/ui/Omnibar.tsx +225 -115
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +6 -1
- package/src/monaco-setup.ts +17 -10
|
@@ -4,12 +4,29 @@ import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
|
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
5
|
import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { getResourceIcon } from '../../utils/resource-icons'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
|
|
10
|
-
import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
|
|
7
|
+
import type { SearchHit, SearchMatchedField } from '../../api/client'
|
|
8
|
+
import { bestScore, type CommandItem } from './command-items'
|
|
11
9
|
import { SearchSyntaxHelp } from './SearchSyntaxHelp'
|
|
12
10
|
|
|
11
|
+
// Minimal recent-resource shape the omnibar renders. Hosts own the storage +
|
|
12
|
+
// per-cluster partitioning behind loadRecents/recordRecent.
|
|
13
|
+
export interface OmnibarRecent {
|
|
14
|
+
kind: string
|
|
15
|
+
group?: string
|
|
16
|
+
namespace?: string
|
|
17
|
+
name: string
|
|
18
|
+
cluster?: string
|
|
19
|
+
clusterName?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Search results the host feeds in (it runs its own search hook keyed on the
|
|
23
|
+
// debounced query the omnibar emits via onQueryChange).
|
|
24
|
+
export interface OmnibarSearchResult {
|
|
25
|
+
hits: SearchHit[]
|
|
26
|
+
total?: number
|
|
27
|
+
total_matched?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
// Health → dot color (summaryContext.health is the same vocabulary as the rest
|
|
14
31
|
// of Radar). Kept local + tiny to avoid pulling the full status-tone system.
|
|
15
32
|
function healthDot(health?: string): string | null {
|
|
@@ -66,14 +83,50 @@ export interface OmnibarHandle {
|
|
|
66
83
|
focus: () => void
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
interface OmnibarProps
|
|
70
|
-
/** Open a resource hit (route-based — sets the URL + opens the drawer). */
|
|
86
|
+
export interface OmnibarProps {
|
|
87
|
+
/** Open a resource hit (route-based — sets the URL + opens the drawer/page). */
|
|
71
88
|
onOpenResource: (hit: SearchHit) => void
|
|
89
|
+
/** Command-palette items, already built by the host (Views/Actions/Clusters/…).
|
|
90
|
+
* Scored + grouped internally; the host doesn't rank them. */
|
|
91
|
+
commandItems: CommandItem[]
|
|
92
|
+
/** The host runs its own search keyed on this debounced query; `open` lets it
|
|
93
|
+
* gate the request. */
|
|
94
|
+
onQueryChange?: (query: string, open: boolean) => void
|
|
95
|
+
/** Live search results for the current query (host-provided). */
|
|
96
|
+
searchData?: OmnibarSearchResult
|
|
97
|
+
isFetching?: boolean
|
|
98
|
+
isError?: boolean
|
|
99
|
+
/** True while React Query serves a previous query's data — gates Enter/clicks. */
|
|
100
|
+
isPlaceholderData?: boolean
|
|
101
|
+
/** Bounded modifier value sets to autocomplete (e.g. { ns: [...], kind: [...] }). */
|
|
102
|
+
modifierOptions?: Record<string, string[]>
|
|
103
|
+
/** Namespaces to seed as removable `ns:` pills when the launcher opens empty
|
|
104
|
+
* (reflects the current view scope). */
|
|
105
|
+
seedNamespaces?: string[]
|
|
106
|
+
/** Recently-viewed resources for the empty launcher (host owns storage). */
|
|
107
|
+
loadRecents?: () => OmnibarRecent[]
|
|
108
|
+
recordRecent?: (r: OmnibarRecent) => void
|
|
109
|
+
/** When set, a "See all N results" row appears below the resource hits while
|
|
110
|
+
* searching, handing the full (uncapped) query off to the host's dedicated
|
|
111
|
+
* search surface. Omit to keep the omnibar a pure launcher. */
|
|
112
|
+
onViewAllResults?: (query: string) => void
|
|
113
|
+
/** Empty-launcher content. `true` (default) lists Views + Actions — the
|
|
114
|
+
* cmd-K menu. `false` shows only Actions so recents/search lead and views
|
|
115
|
+
* surface on type (use when the views are already always-visible, e.g. a
|
|
116
|
+
* persistent nav rail). */
|
|
117
|
+
launcherShowsViews?: boolean
|
|
118
|
+
placeholder?: string
|
|
119
|
+
/** `hero` renders a large, centered field for landing surfaces (Home);
|
|
120
|
+
* `default` is the slim top-bar field. */
|
|
121
|
+
size?: 'default' | 'hero'
|
|
122
|
+
/** Focus the field on mount (Home hero — the primary action on the page). */
|
|
123
|
+
autoFocus?: boolean
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
type Row =
|
|
75
127
|
| { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
|
|
76
128
|
| { id: string; kind: 'command'; command: CommandItem }
|
|
129
|
+
| { id: string; kind: 'viewAll'; query: string; count: number }
|
|
77
130
|
|
|
78
131
|
const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
|
|
79
132
|
const PAGE = 8
|
|
@@ -83,12 +136,32 @@ function pillsToQuery(pills: SearchModifier[]): string {
|
|
|
83
136
|
return pills.map((p) => `${p.key}:${p.value}`).join(' ')
|
|
84
137
|
}
|
|
85
138
|
|
|
86
|
-
// The
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
139
|
+
// The omnibar: a persistent search box that IS the ⌘K surface. Typing runs the
|
|
140
|
+
// host's live resource search alongside its command-palette items; modifiers
|
|
141
|
+
// (ns:, kind:, …) become removable pills. Resources lead, commands follow.
|
|
142
|
+
//
|
|
143
|
+
// Injectable: all data — search, commands, recents, modifier options — flows in
|
|
144
|
+
// via props, so Radar standalone (cluster /api/search) and Radar Hub (fleet
|
|
145
|
+
// search) share the same UX. ⌘K focus is wired by the host.
|
|
90
146
|
export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
91
|
-
{
|
|
147
|
+
{
|
|
148
|
+
onOpenResource,
|
|
149
|
+
commandItems,
|
|
150
|
+
onQueryChange,
|
|
151
|
+
searchData,
|
|
152
|
+
isFetching = false,
|
|
153
|
+
isError = false,
|
|
154
|
+
isPlaceholderData = false,
|
|
155
|
+
modifierOptions,
|
|
156
|
+
seedNamespaces,
|
|
157
|
+
loadRecents,
|
|
158
|
+
recordRecent,
|
|
159
|
+
onViewAllResults,
|
|
160
|
+
launcherShowsViews = true,
|
|
161
|
+
placeholder = 'Search resources & commands…',
|
|
162
|
+
size = 'default',
|
|
163
|
+
autoFocus = false,
|
|
164
|
+
},
|
|
92
165
|
ref,
|
|
93
166
|
) {
|
|
94
167
|
const [text, setText] = useState('')
|
|
@@ -102,67 +175,59 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
102
175
|
// The dropdown is portaled to <body> (so the header's stacking context can't
|
|
103
176
|
// trap the dim overlay). `centerX` aligns the panel under the input; `top` is
|
|
104
177
|
// the HEADER's bottom (not the input's) so the dim starts cleanly below the
|
|
105
|
-
// whole top bar
|
|
106
|
-
|
|
107
|
-
const [anchor, setAnchor] = useState<{ centerX: number; top: number } | null>(null)
|
|
178
|
+
// whole top bar.
|
|
179
|
+
const [anchor, setAnchor] = useState<{ centerX: number; top: number; width: number } | null>(null)
|
|
108
180
|
|
|
109
181
|
useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
|
|
110
182
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
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])
|
|
183
|
+
// Hero autofocus parks the cursor in the field on mount (Home's primary
|
|
184
|
+
// action) but must NOT pop the dropdown — landing on the page shouldn't dim
|
|
185
|
+
// it behind a command palette. Suppress exactly the programmatic focus; a
|
|
186
|
+
// later user focus opens normally.
|
|
187
|
+
const skipFocusOpen = useRef(false)
|
|
188
|
+
useEffect(() => { if (autoFocus) { skipFocusOpen.current = true; inputRef.current?.focus() } }, [autoFocus])
|
|
122
189
|
|
|
123
190
|
// Reflect the current view scope as an editable `ns:` pill on open, so a
|
|
124
191
|
// deliberately broad ⌘K search shows (and lets you remove) the namespace it's
|
|
125
|
-
// narrowed to
|
|
126
|
-
// truly empty launcher state.
|
|
127
|
-
const actives = nsScope?.actives
|
|
192
|
+
// narrowed to. Seeded once per open, only from a truly empty launcher state.
|
|
128
193
|
const seededRef = useRef(false)
|
|
129
194
|
useEffect(() => {
|
|
130
195
|
if (!open) { seededRef.current = false; return }
|
|
131
|
-
if (seededRef.current ||
|
|
196
|
+
if (seededRef.current || seedNamespaces === undefined) return
|
|
132
197
|
seededRef.current = true
|
|
133
|
-
if (pills.length === 0 && text === '' &&
|
|
134
|
-
setPills(
|
|
198
|
+
if (pills.length === 0 && text === '' && seedNamespaces.length > 0) {
|
|
199
|
+
setPills(seedNamespaces.map((ns) => ({ key: 'ns', value: ns })))
|
|
135
200
|
}
|
|
136
|
-
}, [open,
|
|
201
|
+
}, [open, seedNamespaces, pills.length, text])
|
|
137
202
|
|
|
138
203
|
const freeText = text.trim()
|
|
139
204
|
const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
|
|
140
205
|
const searchActive = queryString.length >= 2
|
|
141
|
-
// Small debounce:
|
|
142
|
-
//
|
|
143
|
-
// kept under the ~100-150ms "feels instant" threshold. keepPreviousData +
|
|
144
|
-
// AbortSignal (see useSearch) handle the smoothness; commands aren't debounced.
|
|
206
|
+
// Small debounce: coalesce fast keystrokes (less list reshuffle). The host's
|
|
207
|
+
// search hook handles smoothness (keepPreviousData + AbortSignal).
|
|
145
208
|
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
209
|
|
|
150
|
-
|
|
210
|
+
// Tell the host which (debounced) query to search, and whether the surface is
|
|
211
|
+
// open (so it can gate the request).
|
|
212
|
+
useEffect(() => { onQueryChange?.(debounced, open) }, [debounced, open, onQueryChange])
|
|
151
213
|
|
|
152
214
|
// Commands score against the FREE text only — modifiers live in pills, so the
|
|
153
|
-
// launcher never sees "ns:" polluting a "go to topology" match.
|
|
154
|
-
//
|
|
155
|
-
//
|
|
215
|
+
// launcher never sees "ns:" polluting a "go to topology" match. With pills but
|
|
216
|
+
// no text the user is browsing a scope, so suppress the command default. Empty
|
|
217
|
+
// + no pills → the launcher default: Views + Actions, or (when the host opts
|
|
218
|
+
// into a lean launcher) Actions only, so recents/search lead and the views
|
|
219
|
+
// surface on type instead of walling the dropdown.
|
|
156
220
|
const scoredCommands = useMemo(() => {
|
|
157
221
|
if (!freeText) {
|
|
158
|
-
|
|
222
|
+
if (pills.length) return []
|
|
223
|
+
const cats = launcherShowsViews ? ['Views', 'Actions'] : ['Actions']
|
|
224
|
+
return commandItems.filter((i) => cats.includes(i.category)).map((item) => ({ item, score: 1 }))
|
|
159
225
|
}
|
|
160
226
|
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])
|
|
227
|
+
}, [commandItems, freeText, pills.length, launcherShowsViews])
|
|
162
228
|
|
|
163
229
|
// Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
|
|
164
|
-
// resource instances
|
|
165
|
-
// flow the instance hits otherwise bury.
|
|
230
|
+
// resource instances.
|
|
166
231
|
const leadingKinds = useMemo<CommandItem[]>(
|
|
167
232
|
() => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
|
|
168
233
|
[scoredCommands, freeText],
|
|
@@ -171,56 +236,79 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
171
236
|
|
|
172
237
|
const resourceRows = useMemo<Row[]>(() => {
|
|
173
238
|
const hits = searchData?.hits ?? []
|
|
174
|
-
return hits.map((hit) => ({ id: `res:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
|
|
239
|
+
return hits.map((hit) => ({ id: `res:${hit.cluster || ''}:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
|
|
175
240
|
}, [searchData])
|
|
176
241
|
|
|
177
|
-
// Launcher recents: only in the truly-empty state (no text, no pills).
|
|
178
|
-
// fresh from localStorage each open.
|
|
242
|
+
// Launcher recents: only in the truly-empty state (no text, no pills).
|
|
179
243
|
const recentRows = useMemo<Row[]>(() => {
|
|
180
|
-
if (!open || freeText || pills.length > 0) return []
|
|
181
|
-
return
|
|
182
|
-
id: `recent:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
|
|
244
|
+
if (!open || freeText || pills.length > 0 || !loadRecents) return []
|
|
245
|
+
return loadRecents().map((r) => ({
|
|
246
|
+
id: `recent:${r.cluster || ''}:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
|
|
183
247
|
kind: 'resource' as const,
|
|
184
248
|
recent: true,
|
|
185
|
-
hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name } as SearchHit,
|
|
249
|
+
hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name, cluster: r.cluster, clusterName: r.clusterName } as SearchHit,
|
|
186
250
|
}))
|
|
187
|
-
}, [open, freeText, pills.length,
|
|
251
|
+
}, [open, freeText, pills.length, loadRecents])
|
|
188
252
|
|
|
189
253
|
// Remaining matched commands (leading kinds removed so they don't repeat),
|
|
190
|
-
// grouped by their real category in a fixed order.
|
|
254
|
+
// grouped by their real category in a fixed order. The empty launcher IS the
|
|
255
|
+
// command menu, so show it in full; while searching, cap so resource hits stay
|
|
256
|
+
// prominent.
|
|
191
257
|
const commandGroups = useMemo(() => {
|
|
192
|
-
const
|
|
258
|
+
const launcher = !freeText && pills.length === 0
|
|
259
|
+
const filtered = scoredCommands.filter((x) => !leadingIds.has(x.item.id))
|
|
260
|
+
const rest = (launcher ? filtered : filtered.slice(0, 8)).map((x) => x.item)
|
|
193
261
|
const byCat = new Map<string, CommandItem[]>()
|
|
194
262
|
for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
|
|
195
|
-
|
|
196
|
-
|
|
263
|
+
// `CommandItem.category` is an open string, so a host can use one we don't
|
|
264
|
+
// rank — render those AFTER the known order rather than silently dropping
|
|
265
|
+
// their commands.
|
|
266
|
+
const known = COMMAND_CATEGORY_ORDER.filter((cat) => byCat.has(cat))
|
|
267
|
+
const extra = [...byCat.keys()].filter((cat) => !COMMAND_CATEGORY_ORDER.includes(cat))
|
|
268
|
+
return [...known, ...extra].map((cat) => ({ category: cat, items: byCat.get(cat)! }))
|
|
269
|
+
}, [scoredCommands, leadingIds, freeText, pills.length])
|
|
197
270
|
|
|
198
271
|
const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
|
|
199
272
|
|
|
200
|
-
// Free-text tokens for highlighting command labels (commands are scored
|
|
201
|
-
// client-side, so there's no server `matched`).
|
|
202
273
|
const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
|
|
203
274
|
|
|
204
|
-
// Ordered, id-stable list (render order == keyboard model)
|
|
205
|
-
// only), then leading kinds, then resources (when searchActive), then commands.
|
|
275
|
+
// Ordered, id-stable list (render order == keyboard model).
|
|
206
276
|
const rows = useMemo<Row[]>(() => {
|
|
207
277
|
const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
|
|
208
278
|
if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
|
|
209
|
-
|
|
279
|
+
const out: Row[] = [...leadingKinds.map(toCmdRow), ...(searchActive ? resourceRows : []), ...cmds]
|
|
280
|
+
// "See all results" tails the list when the host wired a full-search surface
|
|
281
|
+
// and there's something to expand to.
|
|
282
|
+
if (onViewAllResults && searchActive && resourceRows.length > 0) {
|
|
283
|
+
out.push({ id: 'view-all', kind: 'viewAll', query: queryString, count: searchData?.total_matched ?? resourceRows.length })
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
210
286
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
|
-
}, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive])
|
|
287
|
+
}, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive, onViewAllResults, queryString, searchData])
|
|
288
|
+
const viewAllRow = rows.find((r): r is Extract<Row, { kind: 'viewAll' }> => r.kind === 'viewAll')
|
|
212
289
|
|
|
213
290
|
// Selection tracked by stable id (not array index) so Enter can never fire a
|
|
214
291
|
// stale row when the set shifts. Auto-follows the TOP result until the user
|
|
215
292
|
// arrow-keys; a new query re-enables auto-follow.
|
|
293
|
+
// When the host wires a search page (onViewAllResults), Enter on an
|
|
294
|
+
// un-touched query goes THERE with the query rather than firing the top hit —
|
|
295
|
+
// so we never pre-select a row (the user opts into a specific result by
|
|
296
|
+
// arrowing/hovering). Without a search page (OSS), keep auto-follow-top so
|
|
297
|
+
// Enter still opens the best match.
|
|
298
|
+
const submitToSearch = !!onViewAllResults
|
|
216
299
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
217
300
|
const userMovedRef = useRef(false)
|
|
218
301
|
useEffect(() => { userMovedRef.current = false }, [queryString])
|
|
219
302
|
const rowsKey = rows.map((r) => r.id).join('|')
|
|
220
303
|
useEffect(() => {
|
|
304
|
+
// Only suppress the pre-selection while actively SEARCHING (so Enter goes to
|
|
305
|
+
// the search page, not a maybe-wrong top hit). In the empty launcher there's
|
|
306
|
+
// no search to defer to, so auto-select the first row — otherwise Enter is a
|
|
307
|
+
// no-op while the footer still reads "open".
|
|
308
|
+
const dflt = submitToSearch && searchActive ? null : (rows[0]?.id ?? null)
|
|
221
309
|
setSelectedId((cur) => {
|
|
222
|
-
if (!userMovedRef.current) return
|
|
223
|
-
return cur && rows.some((r) => r.id === cur) ? cur :
|
|
310
|
+
if (!userMovedRef.current) return dflt
|
|
311
|
+
return cur && rows.some((r) => r.id === cur) ? cur : dflt
|
|
224
312
|
})
|
|
225
313
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
226
314
|
}, [rowsKey])
|
|
@@ -230,8 +318,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
230
318
|
setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
|
|
231
319
|
}
|
|
232
320
|
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.
|
|
321
|
+
// Page by a full screenful of visible rows (minus one for context overlap).
|
|
235
322
|
const pageStep = () => {
|
|
236
323
|
const list = listRef.current
|
|
237
324
|
const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
|
|
@@ -242,51 +329,53 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
242
329
|
const execute = useCallback((row: Row) => {
|
|
243
330
|
if (row.kind === 'command') {
|
|
244
331
|
row.command.action()
|
|
332
|
+
} else if (row.kind === 'viewAll') {
|
|
333
|
+
onViewAllResults?.(row.query)
|
|
245
334
|
} else {
|
|
246
335
|
const h = row.hit
|
|
247
|
-
|
|
336
|
+
recordRecent?.({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name, cluster: h.cluster, clusterName: h.clusterName })
|
|
248
337
|
onOpenResource(h)
|
|
249
338
|
}
|
|
250
339
|
setOpen(false)
|
|
251
340
|
setText('')
|
|
252
341
|
setPills([])
|
|
253
342
|
inputRef.current?.blur()
|
|
254
|
-
}, [onOpenResource,
|
|
343
|
+
}, [onOpenResource, recordRecent, onViewAllResults])
|
|
255
344
|
|
|
256
345
|
// The resources shown don't (yet) belong to the current query: the debounce
|
|
257
|
-
// hasn't fired, the data is React Query placeholder
|
|
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.
|
|
346
|
+
// hasn't fired, the data is React Query placeholder, or results haven't landed.
|
|
260
347
|
const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
|
|
261
348
|
|
|
262
|
-
// Forwarded from SearchPillInput for keys it doesn't consume (it owns Space →
|
|
263
|
-
// pill, Backspace → pop pill, and suggestion nav).
|
|
264
349
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
265
350
|
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
|
|
266
351
|
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
|
|
267
352
|
else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
|
|
268
353
|
else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
|
|
269
354
|
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
355
|
else if (e.key === 'Enter') {
|
|
272
356
|
e.preventDefault()
|
|
273
357
|
const row = rows[selectedIndex]
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
358
|
+
if (row) {
|
|
359
|
+
if (row.kind === 'resource' && resourcesStale) return
|
|
360
|
+
execute(row)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
// No row chosen: submit the query to the full search page (the default
|
|
364
|
+
// for a host that wired one). viewAllRow carries the count when results
|
|
365
|
+
// are in; fall back to the raw query while they're still loading.
|
|
366
|
+
if (submitToSearch && searchActive) {
|
|
367
|
+
if (viewAllRow) execute(viewAllRow)
|
|
368
|
+
else { onViewAllResults?.(queryString); setOpen(false); setText(''); setPills([]); inputRef.current?.blur() }
|
|
369
|
+
}
|
|
279
370
|
}
|
|
280
371
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
281
|
-
}, [rows, selectedIndex, execute, resourcesStale])
|
|
372
|
+
}, [rows, selectedIndex, execute, resourcesStale, submitToSearch, searchActive, viewAllRow, onViewAllResults, queryString])
|
|
282
373
|
|
|
283
|
-
// Keep the selected row in view.
|
|
284
374
|
useEffect(() => {
|
|
285
375
|
listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
|
286
376
|
}, [selectedId])
|
|
287
377
|
|
|
288
|
-
// Close on outside click — the panel is portaled out of the container
|
|
289
|
-
// must be excluded explicitly or clicking a row would count as "outside".
|
|
378
|
+
// Close on outside click — the panel is portaled out of the container.
|
|
290
379
|
useEffect(() => {
|
|
291
380
|
if (!open) return
|
|
292
381
|
const onDown = (e: MouseEvent) => {
|
|
@@ -297,8 +386,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
297
386
|
return () => document.removeEventListener('mousedown', onDown)
|
|
298
387
|
}, [open])
|
|
299
388
|
|
|
300
|
-
// Track the input's position so the portaled panel stays anchored under it
|
|
301
|
-
// through scroll / resize / layout shifts.
|
|
389
|
+
// Track the input's position so the portaled panel stays anchored under it.
|
|
302
390
|
useEffect(() => {
|
|
303
391
|
if (!open) { setAnchor(null); return }
|
|
304
392
|
const update = () => {
|
|
@@ -306,7 +394,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
306
394
|
if (!el) return
|
|
307
395
|
const r = el.getBoundingClientRect()
|
|
308
396
|
const header = el.closest('header')
|
|
309
|
-
setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom })
|
|
397
|
+
setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom, width: r.width })
|
|
310
398
|
}
|
|
311
399
|
update()
|
|
312
400
|
window.addEventListener('resize', update)
|
|
@@ -322,25 +410,37 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
322
410
|
|
|
323
411
|
const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
|
|
324
412
|
|
|
413
|
+
const hero = size === 'hero'
|
|
414
|
+
|
|
325
415
|
return (
|
|
326
|
-
<div
|
|
416
|
+
<div
|
|
417
|
+
ref={containerRef}
|
|
418
|
+
className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-xl', open && hero && 'z-[16]')}
|
|
419
|
+
// Open on click even when the field is already focused — onFocus alone
|
|
420
|
+
// never fires again, so an autofocused hero (Home) wouldn't reveal the
|
|
421
|
+
// launcher on a click.
|
|
422
|
+
onMouseDown={() => setOpen(true)}
|
|
423
|
+
>
|
|
327
424
|
<SearchPillInput
|
|
328
|
-
className=
|
|
425
|
+
className={hero
|
|
426
|
+
? 'min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-colors focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]'
|
|
427
|
+
: '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'}
|
|
428
|
+
inputClassName={hero ? 'text-lg py-4' : undefined}
|
|
329
429
|
text={text}
|
|
330
430
|
pills={pills}
|
|
331
431
|
onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
|
|
332
432
|
onKeyDown={handleKeyDown}
|
|
333
|
-
onFocus={() => setOpen(true)}
|
|
433
|
+
onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
|
|
334
434
|
onSuggestingChange={setSuggesting}
|
|
335
435
|
modifierOptions={modifierOptions}
|
|
336
|
-
placeholder=
|
|
436
|
+
placeholder={placeholder}
|
|
337
437
|
aria-label="Search resources and commands"
|
|
338
438
|
inputRef={inputRef}
|
|
339
|
-
leftSlot={<Search className=
|
|
439
|
+
leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
|
|
340
440
|
rightSlot={
|
|
341
441
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
342
442
|
<SearchSyntaxHelp />
|
|
343
|
-
{!text && pills.length === 0 && (
|
|
443
|
+
{!hero && !text && pills.length === 0 && (
|
|
344
444
|
<kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
|
|
345
445
|
{mac ? '⌘' : 'Ctrl+'}K
|
|
346
446
|
</kbd>
|
|
@@ -351,24 +451,26 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
351
451
|
|
|
352
452
|
{open && anchor && (dropdownOpen || suggesting) && createPortal(
|
|
353
453
|
<>
|
|
354
|
-
{/*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
454
|
+
{/* Scrim — separates the dropdown from the page, consistently in both
|
|
455
|
+
modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
|
|
456
|
+
nav chrome stays lit while the content behind the panel dims+blurs:
|
|
457
|
+
a "spotlight on search", not a full-screen modal dim (which fits a
|
|
458
|
+
centered command palette, not an anchored omnibar). The hero covers
|
|
459
|
+
from the top (its box is in the content, lifted to z-[16]); the
|
|
460
|
+
top-bar launcher covers from below the field (its box is already in
|
|
461
|
+
the z-20 chrome). Click closes. */}
|
|
359
462
|
<div
|
|
360
|
-
className="fixed left-0 right-0 bottom-0 z-[
|
|
361
|
-
style={{ top: anchor.top }}
|
|
463
|
+
className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
|
|
464
|
+
style={{ top: hero ? 0 : anchor.top }}
|
|
362
465
|
onClick={() => { setOpen(false); inputRef.current?.blur() }}
|
|
363
466
|
/>
|
|
364
467
|
{dropdownOpen && (
|
|
365
468
|
<div
|
|
366
469
|
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"
|
|
470
|
+
style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
|
|
471
|
+
className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
|
|
369
472
|
>
|
|
370
473
|
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
|
|
371
|
-
{/* Recently viewed — launcher state only. */}
|
|
372
474
|
{recentRows.length > 0 && (
|
|
373
475
|
<div>
|
|
374
476
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
|
|
@@ -378,8 +480,6 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
378
480
|
</div>
|
|
379
481
|
)}
|
|
380
482
|
|
|
381
|
-
{/* Leading kinds — strong kind-name matches lead so ⌘K navigation
|
|
382
|
-
to a kind isn't buried under instance hits. */}
|
|
383
483
|
{leadingKinds.length > 0 && (
|
|
384
484
|
<div>
|
|
385
485
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
|
|
@@ -390,7 +490,6 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
390
490
|
</div>
|
|
391
491
|
)}
|
|
392
492
|
|
|
393
|
-
{/* Resources section */}
|
|
394
493
|
{searchActive && (
|
|
395
494
|
<>
|
|
396
495
|
<div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
@@ -413,16 +512,12 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
413
512
|
</div>
|
|
414
513
|
) : (
|
|
415
514
|
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
515
|
<ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
|
|
420
516
|
))
|
|
421
517
|
)}
|
|
422
518
|
</>
|
|
423
519
|
)}
|
|
424
520
|
|
|
425
|
-
{/* Command groups, each under its real category header. */}
|
|
426
521
|
{commandGroups.map((group) => (
|
|
427
522
|
<div key={group.category}>
|
|
428
523
|
<div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
|
|
@@ -432,9 +527,25 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
|
432
527
|
})}
|
|
433
528
|
</div>
|
|
434
529
|
))}
|
|
530
|
+
|
|
531
|
+
{viewAllRow && (
|
|
532
|
+
<button
|
|
533
|
+
type="button"
|
|
534
|
+
data-selected={viewAllRow.id === selectedId}
|
|
535
|
+
onMouseEnter={() => selectRow(viewAllRow.id)}
|
|
536
|
+
onMouseDown={(e) => { e.preventDefault(); execute(viewAllRow) }}
|
|
537
|
+
className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 mt-1 text-left border-t border-theme-border transition-colors', viewAllRow.id === selectedId ? 'selection' : 'hover:bg-theme-elevated/40')}
|
|
538
|
+
>
|
|
539
|
+
<Search className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
|
|
540
|
+
<span className="text-sm text-[var(--color-brand)]">See all {viewAllRow.count} result{viewAllRow.count === 1 ? '' : 's'}</span>
|
|
541
|
+
<CornerDownLeft className="w-3 h-3 ml-auto shrink-0 text-theme-text-tertiary" />
|
|
542
|
+
</button>
|
|
543
|
+
)}
|
|
435
544
|
</div>
|
|
436
545
|
<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"
|
|
546
|
+
<span className="flex items-center gap-1">
|
|
547
|
+
<CornerDownLeft className="w-3 h-3" /> {submitToSearch && searchActive && selectedIndex < 0 ? 'search all' : 'open'}
|
|
548
|
+
</span>
|
|
438
549
|
<span>↑↓ navigate</span>
|
|
439
550
|
<span>⇞⇟ page</span>
|
|
440
551
|
<span>esc close</span>
|
|
@@ -452,8 +563,6 @@ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: Sear
|
|
|
452
563
|
const Icon = getResourceIcon(hit.kind)
|
|
453
564
|
const dot = healthDot(hit.summaryContext?.health)
|
|
454
565
|
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
566
|
const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
|
|
458
567
|
return (
|
|
459
568
|
<button
|
|
@@ -468,6 +577,7 @@ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: Sear
|
|
|
468
577
|
<span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
|
|
469
578
|
{highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
|
|
470
579
|
{hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
|
|
580
|
+
{hit.clusterName ? <> · <span className="text-theme-text-secondary">{hit.clusterName}</span></> : ''}
|
|
471
581
|
</span>
|
|
472
582
|
{contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
|
|
473
583
|
{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>}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { forwardRef, useMemo, useState } from 'react'
|
|
2
|
+
import { Omnibar, type OmnibarHandle } from './Omnibar'
|
|
3
|
+
import { useSearch, useNamespaceScope, useContexts, type SearchHit } from '../../api/client'
|
|
4
|
+
import { useAPIResources } from '../../api/apiResources'
|
|
5
|
+
import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
|
|
6
|
+
import { useCommandItems, type CommandItemCallbacks } from './command-items'
|
|
7
|
+
|
|
8
|
+
interface RadarOmnibarProps extends CommandItemCallbacks {
|
|
9
|
+
onOpenResource: (hit: SearchHit) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Radar standalone's omnibar: wires the injectable Omnibar to Radar's own hooks
|
|
13
|
+
// (cluster /api/search, kubeconfig contexts, API discovery, recents). Radar Hub
|
|
14
|
+
// provides a parallel wrapper over fleet search.
|
|
15
|
+
export const RadarOmnibar = forwardRef<OmnibarHandle, RadarOmnibarProps>(function RadarOmnibar(
|
|
16
|
+
{ onOpenResource, ...callbacks },
|
|
17
|
+
ref,
|
|
18
|
+
) {
|
|
19
|
+
// The omnibar debounces internally and emits the query to search here.
|
|
20
|
+
const [query, setQuery] = useState('')
|
|
21
|
+
const [open, setOpen] = useState(false)
|
|
22
|
+
|
|
23
|
+
const { data: searchData, isFetching, isPlaceholderData, isError } = useSearch(query, { enabled: open, globalNs: true })
|
|
24
|
+
const { data: nsScope } = useNamespaceScope()
|
|
25
|
+
const { data: apiResources } = useAPIResources()
|
|
26
|
+
const { data: contexts } = useContexts()
|
|
27
|
+
const contextKey = useMemo(() => contexts?.find((c) => c.isCurrent)?.name ?? '', [contexts])
|
|
28
|
+
|
|
29
|
+
const modifierOptions = useMemo(() => ({
|
|
30
|
+
ns: nsScope?.accessibleNamespaces ?? [],
|
|
31
|
+
kind: apiResources ? [...new Set(apiResources.filter((r) => r.verbs?.includes('list')).map((r) => r.kind))].sort() : [],
|
|
32
|
+
}), [nsScope, apiResources])
|
|
33
|
+
|
|
34
|
+
const commandItems = useCommandItems(callbacks)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Omnibar
|
|
38
|
+
ref={ref}
|
|
39
|
+
onOpenResource={onOpenResource}
|
|
40
|
+
commandItems={commandItems}
|
|
41
|
+
onQueryChange={(q, o) => { setQuery(q); setOpen(o) }}
|
|
42
|
+
searchData={searchData}
|
|
43
|
+
isFetching={isFetching}
|
|
44
|
+
isError={isError}
|
|
45
|
+
isPlaceholderData={isPlaceholderData}
|
|
46
|
+
modifierOptions={modifierOptions}
|
|
47
|
+
seedNamespaces={nsScope?.actives}
|
|
48
|
+
loadRecents={() => loadRecentResources(contextKey)}
|
|
49
|
+
recordRecent={(r) => recordRecentResource(r, contextKey)}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
})
|