@skyhook-io/radar-app 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +14 -6
- package/src/api/client.ts +10 -3
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
- 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/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -56,7 +56,8 @@ import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
|
56
56
|
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
57
57
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
58
58
|
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
59
|
-
import {
|
|
59
|
+
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
60
|
+
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
60
61
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
61
62
|
|
|
62
63
|
// All possible node kinds (core + GitOps)
|
|
@@ -541,7 +542,9 @@ function AppInner() {
|
|
|
541
542
|
description: 'Show keyboard shortcuts',
|
|
542
543
|
category: 'General' as const,
|
|
543
544
|
scope: 'global' as const,
|
|
544
|
-
|
|
545
|
+
// Chromeless embeds (Radar Hub) own their own help surface — don't open a
|
|
546
|
+
// competing Radar overlay.
|
|
547
|
+
handler: () => { if (!chromeless) setShowHelp(prev => !prev) },
|
|
545
548
|
},
|
|
546
549
|
{
|
|
547
550
|
id: 'command-palette',
|
|
@@ -550,8 +553,10 @@ function AppInner() {
|
|
|
550
553
|
category: 'General' as const,
|
|
551
554
|
scope: 'global' as const,
|
|
552
555
|
allowInInputs: true,
|
|
553
|
-
// Standalone focuses the top-center omnibar; embedded opens the modal.
|
|
554
|
-
|
|
556
|
+
// Standalone focuses the top-center omnibar; embedded opens the modal. In
|
|
557
|
+
// a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
|
|
558
|
+
// otherwise both the host omnibar and Radar's palette fire on one ⌘K.
|
|
559
|
+
handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
|
|
555
560
|
},
|
|
556
561
|
{
|
|
557
562
|
id: 'diagnostics',
|
|
@@ -1130,7 +1135,7 @@ function AppInner() {
|
|
|
1130
1135
|
// Fleet mode overrides visible kinds to show only CAPI resources + Node
|
|
1131
1136
|
const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
|
|
1132
1137
|
|
|
1133
|
-
// Filter by namespace (
|
|
1138
|
+
// Filter by namespace (client-side) and by visible kinds
|
|
1134
1139
|
const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
|
|
1135
1140
|
const filteredNodes = displayedTopology.nodes.filter(node =>
|
|
1136
1141
|
effectiveKinds.has(node.kind) &&
|
|
@@ -1352,7 +1357,7 @@ function AppInner() {
|
|
|
1352
1357
|
Fills the space the pill bar left; embedded keeps the pills + modal. */}
|
|
1353
1358
|
{showNavRail && (
|
|
1354
1359
|
<div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
|
|
1355
|
-
<
|
|
1360
|
+
<RadarOmnibar
|
|
1356
1361
|
ref={omnibarRef}
|
|
1357
1362
|
onNavigateView={(view) => setMainView(view)}
|
|
1358
1363
|
onNavigateKind={(kind, group) => {
|
|
@@ -1851,6 +1856,9 @@ function AppInner() {
|
|
|
1851
1856
|
<ResourceDetailDrawer
|
|
1852
1857
|
resource={drawerResource}
|
|
1853
1858
|
initialTab={drawerInitialTab}
|
|
1859
|
+
// No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
|
|
1860
|
+
// to the top of the content area instead of leaving a 49px gap.
|
|
1861
|
+
headerHeight={chromeless ? 0 : undefined}
|
|
1854
1862
|
isOpen={resourceDrawer.isOpen}
|
|
1855
1863
|
expanded={drawerExpanded}
|
|
1856
1864
|
onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
|
package/src/api/client.ts
CHANGED
|
@@ -717,6 +717,11 @@ export interface SearchHit {
|
|
|
717
717
|
name: string
|
|
718
718
|
matched?: SearchMatchedField[]
|
|
719
719
|
summaryContext?: SearchSummaryContext
|
|
720
|
+
/** Embedder (Radar Hub) only: the cluster this hit belongs to, for
|
|
721
|
+
* cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
|
|
722
|
+
* unset — the omnibar keys + displays the cluster only when present. */
|
|
723
|
+
cluster?: string
|
|
724
|
+
clusterName?: string
|
|
720
725
|
}
|
|
721
726
|
|
|
722
727
|
export interface SearchResult {
|
|
@@ -817,7 +822,7 @@ export function useAuthMe() {
|
|
|
817
822
|
}
|
|
818
823
|
|
|
819
824
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
820
|
-
// CloudRole.AtLeast — the
|
|
825
|
+
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
821
826
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
822
827
|
// backend would happily honor (or vice versa).
|
|
823
828
|
const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
|
|
@@ -1036,6 +1041,7 @@ export interface UseChangesOptions {
|
|
|
1036
1041
|
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
1037
1042
|
includeK8sEvents?: boolean
|
|
1038
1043
|
includeManaged?: boolean
|
|
1044
|
+
includeDeleted?: boolean
|
|
1039
1045
|
limit?: number
|
|
1040
1046
|
enabled?: boolean
|
|
1041
1047
|
}
|
|
@@ -1060,7 +1066,7 @@ function getTimeRangeDate(range: TimeRange): Date | null {
|
|
|
1060
1066
|
}
|
|
1061
1067
|
|
|
1062
1068
|
export function useChanges(options: UseChangesOptions = {}) {
|
|
1063
|
-
const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
|
|
1069
|
+
const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
|
|
1064
1070
|
|
|
1065
1071
|
const params = new URLSearchParams()
|
|
1066
1072
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
@@ -1068,6 +1074,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1068
1074
|
if (filter) params.set('filter', filter)
|
|
1069
1075
|
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
1070
1076
|
if (includeManaged) params.set('include_managed', 'true')
|
|
1077
|
+
if (!includeDeleted) params.set('include_deleted', 'false')
|
|
1071
1078
|
params.set('limit', String(limit))
|
|
1072
1079
|
|
|
1073
1080
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
@@ -1078,7 +1085,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1078
1085
|
const queryString = params.toString()
|
|
1079
1086
|
|
|
1080
1087
|
return useQuery<TimelineEvent[]>({
|
|
1081
|
-
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
|
|
1088
|
+
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
|
|
1082
1089
|
queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
|
|
1083
1090
|
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1084
1091
|
refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
|
|
@@ -27,7 +27,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
27
27
|
|
|
28
28
|
// Repo refresh is gated only by `requireHelmWrite` on the backend
|
|
29
29
|
// (handleUpdateRepository deliberately skips requireCloudRole — it
|
|
30
|
-
// mutates pod-local chart cache, not cluster state). So the
|
|
30
|
+
// mutates pod-local chart cache, not cluster state). So the frontend gate
|
|
31
31
|
// here must NOT include the Cloud role check, or Cloud viewers with
|
|
32
32
|
// rbac.helm=true would be blocked from a refresh the backend allows.
|
|
33
33
|
const canHelmWrite = useCanHelmWrite()
|
|
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
|
|
|
22
22
|
*
|
|
23
23
|
* Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
|
|
24
24
|
* true when no Cloud role is present. The backend gate has the same
|
|
25
|
-
* shape, so the
|
|
25
|
+
* shape, so the frontend stays in lockstep.
|
|
26
26
|
*/
|
|
27
27
|
export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
|
|
28
28
|
const { role, canAtLeast } = useCloudRole()
|
|
@@ -18,6 +18,9 @@ interface ResourceDetailDrawerProps {
|
|
|
18
18
|
onExpand?: (resource: SelectedResource) => void
|
|
19
19
|
/** Navigate to another resource within expanded WorkloadView */
|
|
20
20
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
21
|
+
/** Top offset for the drawer (px). Defaults to Radar's 49px header height;
|
|
22
|
+
* pass 0 in chromeless embeds where there's no Radar header above it. */
|
|
23
|
+
headerHeight?: number
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
|
|
@@ -15,9 +15,11 @@ interface TimelineListProps {
|
|
|
15
15
|
onResourceClick?: NavigateToResource
|
|
16
16
|
initialFilter?: ActivityTypeFilter
|
|
17
17
|
initialTimeRange?: TimeRange
|
|
18
|
+
showDeleted: boolean
|
|
19
|
+
onShowDeletedChange: (showDeleted: boolean) => void
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
|
|
22
|
+
export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange }: TimelineListProps) {
|
|
21
23
|
const hasLimitedAccess = useHasLimitedAccess()
|
|
22
24
|
const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kind?: string }>({
|
|
23
25
|
timeRange: initialTimeRange ?? '1h',
|
|
@@ -32,6 +34,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
32
34
|
kind: queryParams.kind,
|
|
33
35
|
timeRange: queryParams.timeRange,
|
|
34
36
|
includeK8sEvents: true,
|
|
37
|
+
includeDeleted: showDeleted,
|
|
35
38
|
limit: 500,
|
|
36
39
|
})
|
|
37
40
|
|
|
@@ -64,6 +67,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
64
67
|
onResourceClick={onResourceClick}
|
|
65
68
|
initialFilter={initialFilter}
|
|
66
69
|
initialTimeRange={initialTimeRange}
|
|
70
|
+
showDeleted={showDeleted}
|
|
71
|
+
onShowDeletedChange={onShowDeletedChange}
|
|
67
72
|
/>
|
|
68
73
|
)
|
|
69
74
|
}
|
|
@@ -42,6 +42,10 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
42
42
|
// Force list view on large clusters without namespace filter
|
|
43
43
|
const effectiveInitialMode = requiresNamespaceFilter ? 'list' : (initialViewMode ?? 'swimlane')
|
|
44
44
|
const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
|
|
45
|
+
// Shared across list + swimlane so the toggle carries across the view switch,
|
|
46
|
+
// and so the swimlane fetch can exclude deletes server-side (before LIMIT)
|
|
47
|
+
// rather than only hiding them client-side after the 10k cap.
|
|
48
|
+
const [showDeleted, setShowDeleted] = useState(true)
|
|
45
49
|
|
|
46
50
|
// Only fetch heavy swimlane data when actually showing swimlanes
|
|
47
51
|
const showSwimlanes = viewMode === 'swimlane' && !requiresNamespaceFilter
|
|
@@ -53,6 +57,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
53
57
|
timeRange: 'all',
|
|
54
58
|
includeK8sEvents: true,
|
|
55
59
|
includeManaged: true,
|
|
60
|
+
includeDeleted: showDeleted,
|
|
56
61
|
limit: 10000,
|
|
57
62
|
enabled: showSwimlanes,
|
|
58
63
|
})
|
|
@@ -119,6 +124,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
119
124
|
onViewModeChange={setViewMode}
|
|
120
125
|
topology={stableTopology}
|
|
121
126
|
namespaces={namespaces}
|
|
127
|
+
showDeleted={showDeleted}
|
|
128
|
+
onShowDeletedChange={setShowDeleted}
|
|
122
129
|
/>
|
|
123
130
|
)
|
|
124
131
|
}
|
|
@@ -131,6 +138,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
131
138
|
onResourceClick={onResourceClick}
|
|
132
139
|
initialFilter={initialFilter}
|
|
133
140
|
initialTimeRange={initialTimeRange}
|
|
141
|
+
showDeleted={showDeleted}
|
|
142
|
+
onShowDeletedChange={setShowDeleted}
|
|
134
143
|
/>
|
|
135
144
|
)
|
|
136
145
|
}
|
|
@@ -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
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -31,3 +31,18 @@ export type { ClusterSwitcherProps, ClusterSwitcherItem } from '@skyhook-io/k8s-
|
|
|
31
31
|
// cluster prefix (e.g. /c/:id).
|
|
32
32
|
export { resourcePath, buildWorkloadPath } from './utils/navigation';
|
|
33
33
|
export type { SelectedResource } from '@skyhook-io/k8s-ui/types/core';
|
|
34
|
+
|
|
35
|
+
// Injectable omnibar — the standalone search/command surface, decoupled from
|
|
36
|
+
// Radar's own data hooks so embedders (Radar Hub) can drive it with fleet
|
|
37
|
+
// search + their own command items while sharing the exact UX (pills, modifier
|
|
38
|
+
// autocomplete, kind-first ranking, match highlighting, keyboard nav, recents).
|
|
39
|
+
export { Omnibar } from './components/ui/Omnibar';
|
|
40
|
+
export type {
|
|
41
|
+
OmnibarProps,
|
|
42
|
+
OmnibarHandle,
|
|
43
|
+
OmnibarRecent,
|
|
44
|
+
OmnibarSearchResult,
|
|
45
|
+
} from './components/ui/Omnibar';
|
|
46
|
+
export { bestScore } from './components/ui/command-items';
|
|
47
|
+
export type { CommandItem } from './components/ui/command-items';
|
|
48
|
+
export type { SearchHit, SearchMatchedField } from './api/client';
|