@skyhook-io/radar-app 0.2.2 → 0.3.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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,602 @@
|
|
|
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 type { SearchHit, SearchMatchedField } from '../../api/client'
|
|
8
|
+
import { bestScore, type CommandItem } from './command-items'
|
|
9
|
+
import { SearchSyntaxHelp } from './SearchSyntaxHelp'
|
|
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
|
+
|
|
30
|
+
// Health → dot color (summaryContext.health is the same vocabulary as the rest
|
|
31
|
+
// of Radar). Kept local + tiny to avoid pulling the full status-tone system.
|
|
32
|
+
function healthDot(health?: string): string | null {
|
|
33
|
+
switch (health) {
|
|
34
|
+
case 'healthy': return 'bg-emerald-500'
|
|
35
|
+
case 'degraded': return 'bg-amber-500'
|
|
36
|
+
case 'unhealthy': return 'bg-red-500'
|
|
37
|
+
case 'unknown': return 'bg-theme-text-tertiary'
|
|
38
|
+
default: return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeRe(s: string): string {
|
|
43
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Wrap matched substrings in a brand-tinted, bold run so the user can see WHY a
|
|
47
|
+
// result matched — including when the match is on the namespace/kind, not the
|
|
48
|
+
// name. Longest tokens first so "staging" wins over a stray "s".
|
|
49
|
+
function highlight(text: string, tokens: string[]): React.ReactNode {
|
|
50
|
+
const toks = [...new Set(tokens.filter(Boolean))].sort((a, b) => b.length - a.length)
|
|
51
|
+
if (!toks.length || !text) return text
|
|
52
|
+
const re = new RegExp(`(${toks.map(escapeRe).join('|')})`, 'ig')
|
|
53
|
+
const parts: React.ReactNode[] = []
|
|
54
|
+
let last = 0
|
|
55
|
+
for (const m of text.matchAll(re)) {
|
|
56
|
+
const i = m.index ?? 0
|
|
57
|
+
if (i > last) parts.push(text.slice(last, i))
|
|
58
|
+
parts.push(<mark key={i} className="bg-transparent font-semibold text-[var(--color-brand)]">{m[0]}</mark>)
|
|
59
|
+
last = i + m[0].length
|
|
60
|
+
}
|
|
61
|
+
if (!parts.length) return text
|
|
62
|
+
if (last < text.length) parts.push(text.slice(last))
|
|
63
|
+
return parts
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The query tokens that the search engine recorded as landing on a given field
|
|
67
|
+
// (site), so each displayed field highlights only what actually matched it.
|
|
68
|
+
function tokensForSite(matched: SearchMatchedField[] | undefined, ...sites: string[]): string[] {
|
|
69
|
+
if (!matched) return []
|
|
70
|
+
return matched.filter((m) => sites.includes(m.site)).map((m) => m.token)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function useDebounced<T>(value: T, ms: number): T {
|
|
74
|
+
const [v, setV] = useState(value)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const t = setTimeout(() => setV(value), ms)
|
|
77
|
+
return () => clearTimeout(t)
|
|
78
|
+
}, [value, ms])
|
|
79
|
+
return v
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OmnibarHandle {
|
|
83
|
+
focus: () => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OmnibarProps {
|
|
87
|
+
/** Open a resource hit (route-based — sets the URL + opens the drawer/page). */
|
|
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
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Row =
|
|
127
|
+
| { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
|
|
128
|
+
| { id: string; kind: 'command'; command: CommandItem }
|
|
129
|
+
| { id: string; kind: 'viewAll'; query: string; count: number }
|
|
130
|
+
|
|
131
|
+
const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
|
|
132
|
+
const PAGE = 8
|
|
133
|
+
const STRONG_KIND = 100 // exact (150) or prefix (100) kind-name match
|
|
134
|
+
|
|
135
|
+
function pillsToQuery(pills: SearchModifier[]): string {
|
|
136
|
+
return pills.map((p) => `${p.key}:${p.value}`).join(' ')
|
|
137
|
+
}
|
|
138
|
+
|
|
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.
|
|
146
|
+
export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
|
|
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
|
+
},
|
|
165
|
+
ref,
|
|
166
|
+
) {
|
|
167
|
+
const [text, setText] = useState('')
|
|
168
|
+
const [pills, setPills] = useState<SearchModifier[]>([])
|
|
169
|
+
const [open, setOpen] = useState(false)
|
|
170
|
+
const [suggesting, setSuggesting] = useState(false)
|
|
171
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
172
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
173
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
174
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
175
|
+
// The dropdown is portaled to <body> (so the header's stacking context can't
|
|
176
|
+
// trap the dim overlay). `centerX` aligns the panel under the input; `top` is
|
|
177
|
+
// the HEADER's bottom (not the input's) so the dim starts cleanly below the
|
|
178
|
+
// whole top bar.
|
|
179
|
+
const [anchor, setAnchor] = useState<{ centerX: number; top: number; width: number } | null>(null)
|
|
180
|
+
|
|
181
|
+
useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
|
|
182
|
+
|
|
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])
|
|
189
|
+
|
|
190
|
+
// Reflect the current view scope as an editable `ns:` pill on open, so a
|
|
191
|
+
// deliberately broad ⌘K search shows (and lets you remove) the namespace it's
|
|
192
|
+
// narrowed to. Seeded once per open, only from a truly empty launcher state.
|
|
193
|
+
const seededRef = useRef(false)
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!open) { seededRef.current = false; return }
|
|
196
|
+
if (seededRef.current || seedNamespaces === undefined) return
|
|
197
|
+
seededRef.current = true
|
|
198
|
+
if (pills.length === 0 && text === '' && seedNamespaces.length > 0) {
|
|
199
|
+
setPills(seedNamespaces.map((ns) => ({ key: 'ns', value: ns })))
|
|
200
|
+
}
|
|
201
|
+
}, [open, seedNamespaces, pills.length, text])
|
|
202
|
+
|
|
203
|
+
const freeText = text.trim()
|
|
204
|
+
const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
|
|
205
|
+
const searchActive = queryString.length >= 2
|
|
206
|
+
// Small debounce: coalesce fast keystrokes (less list reshuffle). The host's
|
|
207
|
+
// search hook handles smoothness (keepPreviousData + AbortSignal).
|
|
208
|
+
const debounced = useDebounced(queryString, 120)
|
|
209
|
+
|
|
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])
|
|
213
|
+
|
|
214
|
+
// Commands score against the FREE text only — modifiers live in pills, so the
|
|
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.
|
|
220
|
+
const scoredCommands = useMemo(() => {
|
|
221
|
+
if (!freeText) {
|
|
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 }))
|
|
225
|
+
}
|
|
226
|
+
return commandItems.map((item) => ({ item, score: bestScore(item, freeText) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
|
|
227
|
+
}, [commandItems, freeText, pills.length, launcherShowsViews])
|
|
228
|
+
|
|
229
|
+
// Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
|
|
230
|
+
// resource instances.
|
|
231
|
+
const leadingKinds = useMemo<CommandItem[]>(
|
|
232
|
+
() => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
|
|
233
|
+
[scoredCommands, freeText],
|
|
234
|
+
)
|
|
235
|
+
const leadingIds = useMemo(() => new Set(leadingKinds.map((i) => i.id)), [leadingKinds])
|
|
236
|
+
|
|
237
|
+
const resourceRows = useMemo<Row[]>(() => {
|
|
238
|
+
const hits = searchData?.hits ?? []
|
|
239
|
+
return hits.map((hit) => ({ id: `res:${hit.cluster || ''}:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
|
|
240
|
+
}, [searchData])
|
|
241
|
+
|
|
242
|
+
// Launcher recents: only in the truly-empty state (no text, no pills).
|
|
243
|
+
const recentRows = useMemo<Row[]>(() => {
|
|
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}`,
|
|
247
|
+
kind: 'resource' as const,
|
|
248
|
+
recent: true,
|
|
249
|
+
hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name, cluster: r.cluster, clusterName: r.clusterName } as SearchHit,
|
|
250
|
+
}))
|
|
251
|
+
}, [open, freeText, pills.length, loadRecents])
|
|
252
|
+
|
|
253
|
+
// Remaining matched commands (leading kinds removed so they don't repeat),
|
|
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.
|
|
257
|
+
const commandGroups = useMemo(() => {
|
|
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)
|
|
261
|
+
const byCat = new Map<string, CommandItem[]>()
|
|
262
|
+
for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
|
|
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])
|
|
270
|
+
|
|
271
|
+
const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
|
|
272
|
+
|
|
273
|
+
const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
|
|
274
|
+
|
|
275
|
+
// Ordered, id-stable list (render order == keyboard model).
|
|
276
|
+
const rows = useMemo<Row[]>(() => {
|
|
277
|
+
const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
|
|
278
|
+
if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
|
|
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
|
|
286
|
+
}, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive, onViewAllResults, queryString, searchData])
|
|
287
|
+
const viewAllRow = rows.find((r): r is Extract<Row, { kind: 'viewAll' }> => r.kind === 'viewAll')
|
|
288
|
+
|
|
289
|
+
// Selection tracked by stable id (not array index) so Enter can never fire a
|
|
290
|
+
// stale row when the set shifts. Auto-follows the TOP result until the user
|
|
291
|
+
// arrow-keys; a new query re-enables auto-follow.
|
|
292
|
+
// When the host wires a search page (onViewAllResults), Enter on an
|
|
293
|
+
// un-touched query goes THERE with the query rather than firing the top hit —
|
|
294
|
+
// so we never pre-select a row (the user opts into a specific result by
|
|
295
|
+
// arrowing/hovering). Without a search page (OSS), keep auto-follow-top so
|
|
296
|
+
// Enter still opens the best match.
|
|
297
|
+
const submitToSearch = !!onViewAllResults
|
|
298
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
299
|
+
const userMovedRef = useRef(false)
|
|
300
|
+
useEffect(() => { userMovedRef.current = false }, [queryString])
|
|
301
|
+
const rowsKey = rows.map((r) => r.id).join('|')
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
// Only suppress the pre-selection while actively SEARCHING (so Enter goes to
|
|
304
|
+
// the search page, not a maybe-wrong top hit). In the empty launcher there's
|
|
305
|
+
// no search to defer to, so auto-select the first row — otherwise Enter is a
|
|
306
|
+
// no-op while the footer still reads "open".
|
|
307
|
+
const dflt = submitToSearch && searchActive ? null : (rows[0]?.id ?? null)
|
|
308
|
+
setSelectedId((cur) => {
|
|
309
|
+
if (!userMovedRef.current) return dflt
|
|
310
|
+
return cur && rows.some((r) => r.id === cur) ? cur : dflt
|
|
311
|
+
})
|
|
312
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
313
|
+
}, [rowsKey])
|
|
314
|
+
const selectedIndex = rows.findIndex((r) => r.id === selectedId)
|
|
315
|
+
const moveSelection = (delta: number) => {
|
|
316
|
+
userMovedRef.current = true
|
|
317
|
+
setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
|
|
318
|
+
}
|
|
319
|
+
const selectRow = (id: string) => { userMovedRef.current = true; setSelectedId(id) }
|
|
320
|
+
// Page by a full screenful of visible rows (minus one for context overlap).
|
|
321
|
+
const pageStep = () => {
|
|
322
|
+
const list = listRef.current
|
|
323
|
+
const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
|
|
324
|
+
if (!list || !rowH) return PAGE
|
|
325
|
+
return Math.max(1, Math.floor(list.clientHeight / rowH) - 1)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const execute = useCallback((row: Row) => {
|
|
329
|
+
if (row.kind === 'command') {
|
|
330
|
+
row.command.action()
|
|
331
|
+
} else if (row.kind === 'viewAll') {
|
|
332
|
+
onViewAllResults?.(row.query)
|
|
333
|
+
} else {
|
|
334
|
+
const h = row.hit
|
|
335
|
+
recordRecent?.({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name, cluster: h.cluster, clusterName: h.clusterName })
|
|
336
|
+
onOpenResource(h)
|
|
337
|
+
}
|
|
338
|
+
setOpen(false)
|
|
339
|
+
setText('')
|
|
340
|
+
setPills([])
|
|
341
|
+
inputRef.current?.blur()
|
|
342
|
+
}, [onOpenResource, recordRecent, onViewAllResults])
|
|
343
|
+
|
|
344
|
+
// The resources shown don't (yet) belong to the current query: the debounce
|
|
345
|
+
// hasn't fired, the data is React Query placeholder, or results haven't landed.
|
|
346
|
+
const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
|
|
347
|
+
|
|
348
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
349
|
+
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
|
|
350
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
|
|
351
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
|
|
352
|
+
else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
|
|
353
|
+
else if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-pageStep()) }
|
|
354
|
+
else if (e.key === 'Enter') {
|
|
355
|
+
e.preventDefault()
|
|
356
|
+
const row = rows[selectedIndex]
|
|
357
|
+
if (row) {
|
|
358
|
+
if (row.kind === 'resource' && resourcesStale) return
|
|
359
|
+
execute(row)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
// No row chosen: submit the query to the full search page (the default
|
|
363
|
+
// for a host that wired one). viewAllRow carries the count when results
|
|
364
|
+
// are in; fall back to the raw query while they're still loading.
|
|
365
|
+
if (submitToSearch && searchActive) {
|
|
366
|
+
if (viewAllRow) execute(viewAllRow)
|
|
367
|
+
else { onViewAllResults?.(queryString); setOpen(false); setText(''); setPills([]); inputRef.current?.blur() }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
371
|
+
}, [rows, selectedIndex, execute, resourcesStale, submitToSearch, searchActive, viewAllRow, onViewAllResults, queryString])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
|
375
|
+
}, [selectedId])
|
|
376
|
+
|
|
377
|
+
// Close on outside click — the panel is portaled out of the container.
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (!open) return
|
|
380
|
+
const onDown = (e: MouseEvent) => {
|
|
381
|
+
const t = e.target as Node
|
|
382
|
+
if (!containerRef.current?.contains(t) && !panelRef.current?.contains(t)) setOpen(false)
|
|
383
|
+
}
|
|
384
|
+
document.addEventListener('mousedown', onDown)
|
|
385
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
386
|
+
}, [open])
|
|
387
|
+
|
|
388
|
+
// Track the input's position so the portaled panel stays anchored under it.
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (!open) { setAnchor(null); return }
|
|
391
|
+
const update = () => {
|
|
392
|
+
const el = containerRef.current
|
|
393
|
+
if (!el) return
|
|
394
|
+
const r = el.getBoundingClientRect()
|
|
395
|
+
const header = el.closest('header')
|
|
396
|
+
setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom, width: r.width })
|
|
397
|
+
}
|
|
398
|
+
update()
|
|
399
|
+
window.addEventListener('resize', update)
|
|
400
|
+
window.addEventListener('scroll', update, true)
|
|
401
|
+
return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true) }
|
|
402
|
+
}, [open])
|
|
403
|
+
|
|
404
|
+
const mac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac')
|
|
405
|
+
const total = searchData?.total ?? 0
|
|
406
|
+
const totalMatched = searchData?.total_matched ?? 0
|
|
407
|
+
const hasNsPill = pills.some((p) => p.key === 'ns')
|
|
408
|
+
const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
|
|
409
|
+
|
|
410
|
+
const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
|
|
411
|
+
|
|
412
|
+
const hero = size === 'hero'
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div
|
|
416
|
+
ref={containerRef}
|
|
417
|
+
className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', open && hero && 'z-[16]')}
|
|
418
|
+
// Open on click even when the field is already focused — onFocus alone
|
|
419
|
+
// never fires again, so an autofocused hero (Home) wouldn't reveal the
|
|
420
|
+
// launcher on a click.
|
|
421
|
+
onMouseDown={() => setOpen(true)}
|
|
422
|
+
>
|
|
423
|
+
<SearchPillInput
|
|
424
|
+
className={hero
|
|
425
|
+
? '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)]'
|
|
426
|
+
: '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'}
|
|
427
|
+
inputClassName={hero ? 'text-lg py-4' : undefined}
|
|
428
|
+
text={text}
|
|
429
|
+
pills={pills}
|
|
430
|
+
onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
|
|
431
|
+
onKeyDown={handleKeyDown}
|
|
432
|
+
onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
|
|
433
|
+
onSuggestingChange={setSuggesting}
|
|
434
|
+
modifierOptions={modifierOptions}
|
|
435
|
+
placeholder={placeholder}
|
|
436
|
+
aria-label="Search resources and commands"
|
|
437
|
+
inputRef={inputRef}
|
|
438
|
+
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'} />}
|
|
439
|
+
rightSlot={
|
|
440
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
441
|
+
<SearchSyntaxHelp />
|
|
442
|
+
{!hero && !text && pills.length === 0 && (
|
|
443
|
+
<kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
|
|
444
|
+
{mac ? '⌘' : 'Ctrl+'}K
|
|
445
|
+
</kbd>
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
}
|
|
449
|
+
/>
|
|
450
|
+
|
|
451
|
+
{open && anchor && (dropdownOpen || suggesting) && createPortal(
|
|
452
|
+
<>
|
|
453
|
+
{/* Scrim — separates the dropdown from the page, consistently in both
|
|
454
|
+
modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
|
|
455
|
+
nav chrome stays lit while the content behind the panel dims+blurs:
|
|
456
|
+
a "spotlight on search", not a full-screen modal dim (which fits a
|
|
457
|
+
centered command palette, not an anchored omnibar). The hero covers
|
|
458
|
+
from the top (its box is in the content, lifted to z-[16]); the
|
|
459
|
+
top-bar launcher covers from below the field (its box is already in
|
|
460
|
+
the z-20 chrome). Click closes. */}
|
|
461
|
+
<div
|
|
462
|
+
className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
|
|
463
|
+
style={{ top: hero ? 0 : anchor.top }}
|
|
464
|
+
onClick={() => { setOpen(false); inputRef.current?.blur() }}
|
|
465
|
+
/>
|
|
466
|
+
{dropdownOpen && (
|
|
467
|
+
<div
|
|
468
|
+
ref={panelRef}
|
|
469
|
+
style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
|
|
470
|
+
className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
|
|
471
|
+
>
|
|
472
|
+
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
|
|
473
|
+
{recentRows.length > 0 && (
|
|
474
|
+
<div>
|
|
475
|
+
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
|
|
476
|
+
{recentRows.map((row) => row.kind === 'resource' && (
|
|
477
|
+
<ResourceRow key={row.id} hit={row.hit} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => execute(row)} />
|
|
478
|
+
))}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
{leadingKinds.length > 0 && (
|
|
483
|
+
<div>
|
|
484
|
+
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
|
|
485
|
+
{leadingKinds.map((item) => {
|
|
486
|
+
const id = `cmd:${item.id}`
|
|
487
|
+
return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute(toCmdRow(item))} />
|
|
488
|
+
})}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
|
|
492
|
+
{searchActive && (
|
|
493
|
+
<>
|
|
494
|
+
<div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
|
|
495
|
+
<span>Resources</span>
|
|
496
|
+
{isFetching && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
497
|
+
{!isFetching && !isError && totalMatched > total && <span className="normal-case font-normal">showing {total} of {totalMatched}</span>}
|
|
498
|
+
</div>
|
|
499
|
+
{isError ? (
|
|
500
|
+
<div className="flex items-center gap-2 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
|
501
|
+
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> Search is unavailable right now.
|
|
502
|
+
</div>
|
|
503
|
+
) : resourceRows.length === 0 && !isFetching ? (
|
|
504
|
+
<div className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
505
|
+
No resources match{freeText ? <> “{freeText}”</> : ''}.
|
|
506
|
+
{hasNsPill && (
|
|
507
|
+
<button onMouseDown={(e) => { e.preventDefault(); clearNsPills() }} className="ml-1.5 text-[var(--color-brand)] hover:underline">
|
|
508
|
+
Search all namespaces
|
|
509
|
+
</button>
|
|
510
|
+
)}
|
|
511
|
+
</div>
|
|
512
|
+
) : (
|
|
513
|
+
resourceRows.map((row) => row.kind === 'resource' && (
|
|
514
|
+
<ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
|
|
515
|
+
))
|
|
516
|
+
)}
|
|
517
|
+
</>
|
|
518
|
+
)}
|
|
519
|
+
|
|
520
|
+
{commandGroups.map((group) => (
|
|
521
|
+
<div key={group.category}>
|
|
522
|
+
<div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
|
|
523
|
+
{group.items.map((item) => {
|
|
524
|
+
const id = `cmd:${item.id}`
|
|
525
|
+
return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute({ id, kind: 'command', command: item })} />
|
|
526
|
+
})}
|
|
527
|
+
</div>
|
|
528
|
+
))}
|
|
529
|
+
|
|
530
|
+
{viewAllRow && (
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
data-selected={viewAllRow.id === selectedId}
|
|
534
|
+
onMouseEnter={() => selectRow(viewAllRow.id)}
|
|
535
|
+
onMouseDown={(e) => { e.preventDefault(); execute(viewAllRow) }}
|
|
536
|
+
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')}
|
|
537
|
+
>
|
|
538
|
+
<Search className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
|
|
539
|
+
<span className="text-sm text-[var(--color-brand)]">See all {viewAllRow.count} result{viewAllRow.count === 1 ? '' : 's'}</span>
|
|
540
|
+
<CornerDownLeft className="w-3 h-3 ml-auto shrink-0 text-theme-text-tertiary" />
|
|
541
|
+
</button>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
<div className="flex items-center gap-3 px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
545
|
+
<span className="flex items-center gap-1">
|
|
546
|
+
<CornerDownLeft className="w-3 h-3" /> {submitToSearch && searchActive && selectedIndex < 0 ? 'search all' : 'open'}
|
|
547
|
+
</span>
|
|
548
|
+
<span>↑↓ navigate</span>
|
|
549
|
+
<span>⇞⇟ page</span>
|
|
550
|
+
<span>esc close</span>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
</>,
|
|
555
|
+
document.body,
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: SearchHit; selected: boolean; stale?: boolean; onSelect: () => void; onActivate: () => void }) {
|
|
562
|
+
const Icon = getResourceIcon(hit.kind)
|
|
563
|
+
const dot = healthDot(hit.summaryContext?.health)
|
|
564
|
+
const issues = hit.summaryContext?.issueCount ?? 0
|
|
565
|
+
const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
|
|
566
|
+
return (
|
|
567
|
+
<button
|
|
568
|
+
data-selected={selected}
|
|
569
|
+
onClick={onActivate}
|
|
570
|
+
onMouseMove={onSelect}
|
|
571
|
+
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')}
|
|
572
|
+
>
|
|
573
|
+
<Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
|
|
574
|
+
<span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(hit.name, tokensForSite(hit.matched, 'name'))}</span>
|
|
575
|
+
{dot && <span className={clsx('h-1.5 w-1.5 rounded-full shrink-0', dot)} />}
|
|
576
|
+
<span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
|
|
577
|
+
{highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
|
|
578
|
+
{hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
|
|
579
|
+
{hit.clusterName ? <> · <span className="text-theme-text-secondary">{hit.clusterName}</span></> : ''}
|
|
580
|
+
</span>
|
|
581
|
+
{contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
|
|
582
|
+
{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>}
|
|
583
|
+
</button>
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function CommandRow({ item, tokens, selected, onSelect, onActivate }: { item: CommandItem; tokens: string[]; selected: boolean; onSelect: () => void; onActivate: () => void }) {
|
|
588
|
+
const Icon = item.icon
|
|
589
|
+
return (
|
|
590
|
+
<button
|
|
591
|
+
data-selected={selected}
|
|
592
|
+
onClick={onActivate}
|
|
593
|
+
onMouseMove={onSelect}
|
|
594
|
+
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')}
|
|
595
|
+
>
|
|
596
|
+
{Icon ? <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" /> : <span className="w-4 shrink-0" />}
|
|
597
|
+
<span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(item.label, tokens)}</span>
|
|
598
|
+
{item.sublabel && <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">{highlight(item.sublabel, tokens)}</span>}
|
|
599
|
+
{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>}
|
|
600
|
+
</button>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
@@ -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
|
+
})
|