@skyhook-io/radar-app 1.5.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 +4 -4
- package/src/App.tsx +178 -44
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +75 -5
- package/src/components/UserMenu.tsx +56 -10
- package/src/components/applications/ApplicationsView.tsx +27 -19
- package/src/components/audit/AuditSettingsDialog.tsx +1 -1
- package/src/components/audit/AuditView.tsx +23 -35
- package/src/components/gitops/GitOpsView.tsx +24 -2
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +34 -0
- package/src/components/issues/IssuesPane.tsx +82 -28
- package/src/components/nav/PrimaryNavRail.tsx +282 -0
- package/src/components/resource/HPACharts.tsx +7 -2
- package/src/components/resource/RestartChart.tsx +8 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +18 -1
- package/src/components/timeline/TimelineList.tsx +6 -1
- package/src/components/timeline/TimelineView.tsx +9 -0
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +603 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.ts +15 -0
- package/src/utils/navigation.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
"@playwright/test": "^1.59.1",
|
|
55
55
|
"@skyhook-io/k8s-ui": "*",
|
|
56
56
|
"@tailwindcss/typography": "^0.5.20",
|
|
57
|
-
"@tailwindcss/vite": "^4.3.
|
|
57
|
+
"@tailwindcss/vite": "^4.3.1",
|
|
58
58
|
"@tanstack/react-query": "^5.100.14",
|
|
59
|
-
"@types/node": "^25.
|
|
59
|
+
"@types/node": "^25.9.3",
|
|
60
60
|
"@types/react": "^19.2.17",
|
|
61
61
|
"@types/react-dom": "^19.2.3",
|
|
62
62
|
"@vitejs/plugin-react": "^6.0.2",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"react-dom": "^19.2.7",
|
|
71
71
|
"react-router-dom": "^7.17.0",
|
|
72
72
|
"tailwind-merge": "^3.6.0",
|
|
73
|
-
"tailwindcss": "^4.
|
|
73
|
+
"tailwindcss": "^4.3.1",
|
|
74
74
|
"typescript": "^6.0.2",
|
|
75
75
|
"vite": "^8.0.12"
|
|
76
76
|
},
|
package/src/App.tsx
CHANGED
|
@@ -7,6 +7,8 @@ import { useNavigate, useLocation, useSearchParams, useNavigationType, Navigatio
|
|
|
7
7
|
import { HomeView } from './components/home/HomeView'
|
|
8
8
|
import { DebugOverlay } from './components/DebugOverlay'
|
|
9
9
|
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind, gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
|
|
10
|
+
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
11
|
+
import { useAPIResources } from './api/apiResources'
|
|
10
12
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
11
13
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
12
14
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -27,6 +29,9 @@ import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
|
27
29
|
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
28
30
|
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
29
31
|
import { useNavCustomization } from './context/NavCustomization'
|
|
32
|
+
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
33
|
+
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
34
|
+
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
30
35
|
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
31
36
|
import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
32
37
|
import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
@@ -43,14 +48,16 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
|
|
|
43
48
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
44
49
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
45
50
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
46
|
-
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug,
|
|
51
|
+
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
|
|
47
52
|
import { useTheme } from './context/ThemeContext'
|
|
48
53
|
import { Tooltip } from './components/ui/Tooltip'
|
|
49
54
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
50
55
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
51
56
|
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
52
57
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
53
|
-
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath } from './utils/navigation'
|
|
58
|
+
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
59
|
+
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
60
|
+
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
54
61
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
55
62
|
|
|
56
63
|
// All possible node kinds (core + GitOps)
|
|
@@ -94,7 +101,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
94
101
|
|
|
95
102
|
// Convert API resource name back to topology node ID prefix
|
|
96
103
|
// Extended MainView type that includes traffic and cost
|
|
97
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | '
|
|
104
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
|
|
98
105
|
|
|
99
106
|
// Extract view from URL path
|
|
100
107
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -107,7 +114,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
107
114
|
if (path === 'traffic') return 'traffic'
|
|
108
115
|
if (path === 'cost') return 'cost'
|
|
109
116
|
if (path === 'workload') return 'workload'
|
|
110
|
-
if (path === 'audit') return 'audit
|
|
117
|
+
if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
|
|
111
118
|
if (path === 'gitops') return 'gitops'
|
|
112
119
|
if (path === 'applications') return 'applications'
|
|
113
120
|
if (path === 'compare') return 'compare'
|
|
@@ -171,6 +178,24 @@ function AppInner() {
|
|
|
171
178
|
const capabilities = useCapabilitiesContext()
|
|
172
179
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
173
180
|
const navCustomization = useNavCustomization()
|
|
181
|
+
const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
|
|
182
|
+
// Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
|
|
183
|
+
// the left chrome via their own fleet rail and keep Radar's top-bar pills.
|
|
184
|
+
const showNavRail = !navCustomization.embedded
|
|
185
|
+
// Chromeless embed: the host (Radar Hub) owns ALL chrome and drives view
|
|
186
|
+
// navigation + scope from its own UI, so Radar renders just the active view's
|
|
187
|
+
// content — no top bar, no view-switcher. Used for per-cluster views surfaced
|
|
188
|
+
// as native cloud destinations behind a cluster picker.
|
|
189
|
+
const chromeless = navCustomization.embedded === true && navCustomization.chrome === 'none'
|
|
190
|
+
// Force the slim rail on narrow windows: a pinned 176px rail needs viewport
|
|
191
|
+
// ≥976 to keep content above its ~800px floor (collapsed needs only ≥856).
|
|
192
|
+
// Below 976 we render collapsed regardless of the pin preference — a
|
|
193
|
+
// temporary responsive override that does NOT touch the persisted value, so
|
|
194
|
+
// the user's pinned state returns when they widen again. Fly-out labels cover
|
|
195
|
+
// the collapsed state, so the manual toggle is hidden here rather than left
|
|
196
|
+
// inert (expanding would just re-breach the floor).
|
|
197
|
+
const railForcedSlim = useMediaQuery('(max-width: 975px)')
|
|
198
|
+
const navRailEffectivePinned = navRailPinned && !railForcedSlim
|
|
174
199
|
|
|
175
200
|
// Auth check — detect if auth is enabled but user is not authenticated
|
|
176
201
|
const { data: authMe, isPending: authMePending } = useAuthMe()
|
|
@@ -262,7 +287,7 @@ function AppInner() {
|
|
|
262
287
|
// unaffected and renders the in-app audit view as before.
|
|
263
288
|
const clusterChecksHref = navCustomization.clusterChecksHref
|
|
264
289
|
useEffect(() => {
|
|
265
|
-
if (clusterChecksHref && mainView === '
|
|
290
|
+
if (clusterChecksHref && mainView === 'checks') {
|
|
266
291
|
window.location.replace(clusterChecksHref())
|
|
267
292
|
}
|
|
268
293
|
}, [clusterChecksHref, mainView])
|
|
@@ -450,14 +475,38 @@ function AppInner() {
|
|
|
450
475
|
|
|
451
476
|
// Refs for dropdown components to trigger them via shortcuts
|
|
452
477
|
const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
|
|
478
|
+
const omnibarRef = useRef<OmnibarHandle>(null)
|
|
479
|
+
|
|
480
|
+
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
481
|
+
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
482
|
+
// view — kindToPlural would otherwise English-guess the route before a
|
|
483
|
+
// resources view has run n().
|
|
484
|
+
const { data: navApiResources } = useAPIResources()
|
|
485
|
+
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
453
486
|
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
454
487
|
|
|
455
488
|
// View switching keyboard shortcuts
|
|
456
|
-
|
|
489
|
+
// `g`+mnemonic sequences cover every view. Numeric 1–N can't: there are 11
|
|
490
|
+
// views and only 9 single digits, so `10`/`11` never match a keypress (a
|
|
491
|
+
// KeyboardEvent.key is one character). `g`-prefixed mnemonics scale, are the
|
|
492
|
+
// GitHub/Linear convention, and their second keys are all distinct (no clash
|
|
493
|
+
// with the scoped `g g` table shortcut). The letters are fixed regardless of
|
|
494
|
+
// position, so reordering the rail never changes a shortcut.
|
|
495
|
+
const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
|
|
496
|
+
home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
|
|
497
|
+
applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
|
|
498
|
+
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
499
|
+
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
500
|
+
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
501
|
+
workload: '', compare: '',
|
|
502
|
+
}
|
|
503
|
+
const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
|
|
504
|
+
(v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
|
|
505
|
+
)
|
|
457
506
|
useRegisterShortcuts([
|
|
458
|
-
...views.map((view
|
|
507
|
+
...views.map((view) => ({
|
|
459
508
|
id: `view-${view}`,
|
|
460
|
-
keys:
|
|
509
|
+
keys: VIEW_SHORTCUT_KEYS[view],
|
|
461
510
|
description: `Go to ${view.charAt(0).toUpperCase() + view.slice(1)}`,
|
|
462
511
|
category: 'Navigation' as const,
|
|
463
512
|
scope: 'global' as const,
|
|
@@ -493,16 +542,21 @@ function AppInner() {
|
|
|
493
542
|
description: 'Show keyboard shortcuts',
|
|
494
543
|
category: 'General' as const,
|
|
495
544
|
scope: 'global' as const,
|
|
496
|
-
|
|
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) },
|
|
497
548
|
},
|
|
498
549
|
{
|
|
499
550
|
id: 'command-palette',
|
|
500
551
|
keys: 'Cmd+k',
|
|
501
|
-
description: '
|
|
552
|
+
description: 'Search resources & commands',
|
|
502
553
|
category: 'General' as const,
|
|
503
554
|
scope: 'global' as const,
|
|
504
555
|
allowInInputs: true,
|
|
505
|
-
|
|
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) },
|
|
506
560
|
},
|
|
507
561
|
{
|
|
508
562
|
id: 'diagnostics',
|
|
@@ -513,6 +567,20 @@ function AppInner() {
|
|
|
513
567
|
allowInInputs: true,
|
|
514
568
|
handler: () => setShowDiagnostics(prev => !prev),
|
|
515
569
|
},
|
|
570
|
+
// Settings exposes local-binary controls that don't apply to embedded hosts.
|
|
571
|
+
// Register the shortcut only when standalone (matching the gear button) —
|
|
572
|
+
// `enabled: false` would still list it in the `?` help overlay, which shows
|
|
573
|
+
// all registered shortcuts regardless of enabled state.
|
|
574
|
+
...(showNavRail
|
|
575
|
+
? [{
|
|
576
|
+
id: 'open-settings',
|
|
577
|
+
keys: 'g s',
|
|
578
|
+
description: 'Open settings',
|
|
579
|
+
category: 'General' as const,
|
|
580
|
+
scope: 'global' as const,
|
|
581
|
+
handler: () => setShowSettings(true),
|
|
582
|
+
}]
|
|
583
|
+
: []),
|
|
516
584
|
])
|
|
517
585
|
|
|
518
586
|
// Separate registration for help-close — its `enabled` changes with showHelp,
|
|
@@ -1067,7 +1135,7 @@ function AppInner() {
|
|
|
1067
1135
|
// Fleet mode overrides visible kinds to show only CAPI resources + Node
|
|
1068
1136
|
const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
|
|
1069
1137
|
|
|
1070
|
-
// Filter by namespace (
|
|
1138
|
+
// Filter by namespace (client-side) and by visible kinds
|
|
1071
1139
|
const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
|
|
1072
1140
|
const filteredNodes = displayedTopology.nodes.filter(node =>
|
|
1073
1141
|
effectiveKinds.has(node.kind) &&
|
|
@@ -1141,12 +1209,38 @@ function AppInner() {
|
|
|
1141
1209
|
|
|
1142
1210
|
return (
|
|
1143
1211
|
<PortForwardProvider>
|
|
1144
|
-
|
|
1145
|
-
|
|
1212
|
+
{/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
|
|
1213
|
+
the outer minimum must include it (176px pinned / 56px collapsed) or the
|
|
1214
|
+
content column (min-w-0, shrinkable) would fall below the old desktop
|
|
1215
|
+
floor at small windows. Embedded mode has no rail → plain 800. */}
|
|
1216
|
+
<div
|
|
1217
|
+
className="relative flex h-screen bg-theme-base"
|
|
1218
|
+
style={{ minWidth: 800 + (showNavRail ? (navRailEffectivePinned ? 176 : 56) : 0) }}
|
|
1219
|
+
>
|
|
1220
|
+
{showNavRail && (
|
|
1221
|
+
<PrimaryNavRail
|
|
1222
|
+
activeView={mainView}
|
|
1223
|
+
onNavigate={setMainView}
|
|
1224
|
+
pinned={navRailEffectivePinned}
|
|
1225
|
+
onTogglePinned={toggleNavRailPinned}
|
|
1226
|
+
showPinToggle={!railForcedSlim}
|
|
1227
|
+
onOpenSettings={() => setShowSettings(true)}
|
|
1228
|
+
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1229
|
+
/>
|
|
1230
|
+
)}
|
|
1231
|
+
{/* `relative` makes this column the containing block for the absolute
|
|
1232
|
+
overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
|
|
1233
|
+
span the content area AFTER the rail rather than the full viewport
|
|
1234
|
+
under it. `fixed` splashes (connecting/switching) are unaffected. */}
|
|
1235
|
+
<div className="relative flex flex-col flex-1 min-w-0 h-full">
|
|
1236
|
+
{/* Header — suppressed in chromeless embed; the host owns the chrome. */}
|
|
1237
|
+
{!chromeless && (
|
|
1146
1238
|
<header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
|
|
1147
1239
|
{/* Left: Logo + Cluster info */}
|
|
1148
1240
|
<div className="flex items-center gap-4 shrink-0">
|
|
1149
|
-
{
|
|
1241
|
+
{/* Standalone rail owns the brand; only the embedded/pill layout
|
|
1242
|
+
shows it in the header (host may override via brandSlot). */}
|
|
1243
|
+
{navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
|
|
1150
1244
|
|
|
1151
1245
|
<div className="flex items-center gap-2">
|
|
1152
1246
|
{navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
|
|
@@ -1200,7 +1294,10 @@ function AppInner() {
|
|
|
1200
1294
|
</div>
|
|
1201
1295
|
</div>
|
|
1202
1296
|
|
|
1203
|
-
{/* Center: View tabs —
|
|
1297
|
+
{/* Center: View tabs — embedded/pill layout only. Standalone Radar
|
|
1298
|
+
navigates via the left rail (showNavRail), so the pill bar is
|
|
1299
|
+
suppressed there to avoid a duplicate primary nav. */}
|
|
1300
|
+
{!showNavRail && (
|
|
1204
1301
|
<div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
|
|
1205
1302
|
{([
|
|
1206
1303
|
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
@@ -1217,7 +1314,7 @@ function AppInner() {
|
|
|
1217
1314
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
1218
1315
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
1219
1316
|
// command palette (⌘K). Remove this comment to restore it.
|
|
1220
|
-
{ view: '
|
|
1317
|
+
{ view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
|
|
1221
1318
|
] as const)
|
|
1222
1319
|
// In Cloud, Checks is a fleet-scoped feature owned by the host's
|
|
1223
1320
|
// left rail; the per-cluster view is just that fleet queue filtered
|
|
@@ -1227,7 +1324,7 @@ function AppInner() {
|
|
|
1227
1324
|
// via the Home "Cluster Audit" card (→ /audit, redirected to the
|
|
1228
1325
|
// scoped fleet Checks by the clusterChecksHref effect above), ⌘K,
|
|
1229
1326
|
// and bookmarks. Standalone OSS keeps the Audit tab.
|
|
1230
|
-
.filter(({ view }) => !(view === '
|
|
1327
|
+
.filter(({ view }) => !(view === 'checks' && clusterChecksHref))
|
|
1231
1328
|
.map(({ view, icon: Icon, label }) => (
|
|
1232
1329
|
<Tooltip key={view} content={label} delay={100} position="bottom">
|
|
1233
1330
|
<button
|
|
@@ -1254,6 +1351,30 @@ function AppInner() {
|
|
|
1254
1351
|
</Tooltip>
|
|
1255
1352
|
))}
|
|
1256
1353
|
</div>
|
|
1354
|
+
)}
|
|
1355
|
+
|
|
1356
|
+
{/* Center: omnibar — standalone search + command surface (the ⌘K entry).
|
|
1357
|
+
Fills the space the pill bar left; embedded keeps the pills + modal. */}
|
|
1358
|
+
{showNavRail && (
|
|
1359
|
+
<div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
|
|
1360
|
+
<RadarOmnibar
|
|
1361
|
+
ref={omnibarRef}
|
|
1362
|
+
onNavigateView={(view) => setMainView(view)}
|
|
1363
|
+
onNavigateKind={(kind, group) => {
|
|
1364
|
+
const params = new URLSearchParams(searchParams)
|
|
1365
|
+
params.delete('kind')
|
|
1366
|
+
if (group) params.set('apiGroup', group); else params.delete('apiGroup')
|
|
1367
|
+
params.delete('resource')
|
|
1368
|
+
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1369
|
+
}}
|
|
1370
|
+
onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
|
|
1371
|
+
onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
|
|
1372
|
+
onToggleTheme={toggleTheme}
|
|
1373
|
+
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1374
|
+
onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
|
|
1375
|
+
/>
|
|
1376
|
+
</div>
|
|
1377
|
+
)}
|
|
1257
1378
|
|
|
1258
1379
|
{/* Right: Controls */}
|
|
1259
1380
|
<div className="flex items-center gap-3 shrink-0">
|
|
@@ -1264,7 +1385,9 @@ function AppInner() {
|
|
|
1264
1385
|
/>
|
|
1265
1386
|
|
|
1266
1387
|
|
|
1267
|
-
{/* Command palette trigger
|
|
1388
|
+
{/* Command palette trigger — embedded only; standalone has the
|
|
1389
|
+
top-center omnibar (which is the ⌘K surface). */}
|
|
1390
|
+
{!showNavRail && (
|
|
1268
1391
|
<button
|
|
1269
1392
|
onClick={() => setShowCommandPalette(true)}
|
|
1270
1393
|
className="hidden lg:flex items-center gap-2 h-7 px-2.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
@@ -1274,6 +1397,7 @@ function AppInner() {
|
|
|
1274
1397
|
{typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}K
|
|
1275
1398
|
</kbd>
|
|
1276
1399
|
</button>
|
|
1400
|
+
)}
|
|
1277
1401
|
|
|
1278
1402
|
{/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
|
|
1279
1403
|
{!navCustomization.embedded && (
|
|
@@ -1305,33 +1429,37 @@ function AppInner() {
|
|
|
1305
1429
|
</div>
|
|
1306
1430
|
)}
|
|
1307
1431
|
|
|
1308
|
-
{/*
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1432
|
+
{/* Help + Report-a-bug — standalone only (the left rail owns chrome;
|
|
1433
|
+
embedded hosts provide their own help/support). These replace the
|
|
1434
|
+
old floating bottom-right pair. Settings moved to the rail bottom. */}
|
|
1435
|
+
{showNavRail && (
|
|
1436
|
+
<>
|
|
1437
|
+
<button
|
|
1438
|
+
onClick={() => setShowHelp(true)}
|
|
1439
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1440
|
+
title="Keyboard shortcuts (?)"
|
|
1441
|
+
>
|
|
1442
|
+
<HelpCircle className="w-4 h-4" />
|
|
1443
|
+
</button>
|
|
1444
|
+
<button
|
|
1445
|
+
onClick={() => setShowDiagnostics(true)}
|
|
1446
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1447
|
+
title="Report a bug / Diagnostics"
|
|
1448
|
+
>
|
|
1449
|
+
<Bug className="w-4 h-4" />
|
|
1450
|
+
</button>
|
|
1451
|
+
</>
|
|
1324
1452
|
)}
|
|
1325
1453
|
|
|
1326
|
-
{/*
|
|
1327
|
-
|
|
1328
|
-
{!navCustomization.embedded && <UserMenu />}
|
|
1454
|
+
{/* Account moved to the rail bottom (standalone). Embedded never showed
|
|
1455
|
+
Radar's UserMenu — the host provides its own via rightExtras. */}
|
|
1329
1456
|
|
|
1330
1457
|
{/* Consumer-provided extras (e.g. Radar Hub's Install button +
|
|
1331
1458
|
avatar menu) appended to the right of the action bar. */}
|
|
1332
1459
|
{navCustomization.rightExtras}
|
|
1333
1460
|
</div>
|
|
1334
1461
|
</header>
|
|
1462
|
+
)}
|
|
1335
1463
|
|
|
1336
1464
|
{/* Auth barrier - show when auth is enabled but user is not authenticated */}
|
|
1337
1465
|
{authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
|
|
@@ -1684,16 +1812,15 @@ function AppInner() {
|
|
|
1684
1812
|
fleet Checks queue (clusterChecksHref effect above) — render a brief
|
|
1685
1813
|
splash instead of the single-cluster view while the cross-document
|
|
1686
1814
|
nav lands. */}
|
|
1687
|
-
{mainView === '
|
|
1815
|
+
{mainView === 'checks' && clusterChecksHref && (
|
|
1688
1816
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
|
|
1689
1817
|
<img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
|
|
1690
1818
|
<p className="text-sm text-theme-text-secondary">Opening Checks…</p>
|
|
1691
1819
|
</div>
|
|
1692
1820
|
)}
|
|
1693
|
-
{mainView === '
|
|
1821
|
+
{mainView === 'checks' && !clusterChecksHref && (
|
|
1694
1822
|
<AuditView
|
|
1695
1823
|
namespaces={namespaces}
|
|
1696
|
-
onBack={() => setMainView('home')}
|
|
1697
1824
|
onNavigateToResource={navigateToResourceList}
|
|
1698
1825
|
/>
|
|
1699
1826
|
)}
|
|
@@ -1705,7 +1832,6 @@ function AppInner() {
|
|
|
1705
1832
|
{mainView === 'issues' && (
|
|
1706
1833
|
<IssuesPane
|
|
1707
1834
|
namespaces={namespaces}
|
|
1708
|
-
onBack={() => setMainView('home')}
|
|
1709
1835
|
onNavigateToResource={navigateFromIssue}
|
|
1710
1836
|
/>
|
|
1711
1837
|
)}
|
|
@@ -1730,6 +1856,9 @@ function AppInner() {
|
|
|
1730
1856
|
<ResourceDetailDrawer
|
|
1731
1857
|
resource={drawerResource}
|
|
1732
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}
|
|
1733
1862
|
isOpen={resourceDrawer.isOpen}
|
|
1734
1863
|
expanded={drawerExpanded}
|
|
1735
1864
|
onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
|
|
@@ -1783,8 +1912,12 @@ function AppInner() {
|
|
|
1783
1912
|
{/* Spacer for dock */}
|
|
1784
1913
|
<DockSpacer />
|
|
1785
1914
|
|
|
1786
|
-
{/* Floating action buttons —
|
|
1787
|
-
|
|
1915
|
+
{/* Floating action buttons — embedded only, and not in chromeless (the
|
|
1916
|
+
host owns help/diagnostics chrome). Standalone moved help + bug to
|
|
1917
|
+
visible top-bar icons (the rail owns chrome). */}
|
|
1918
|
+
{!showNavRail && !chromeless && (
|
|
1919
|
+
<FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
|
|
1920
|
+
)}
|
|
1788
1921
|
|
|
1789
1922
|
{/* Keyboard shortcut help overlay */}
|
|
1790
1923
|
{helpOverlay.shouldRender && <ShortcutHelpOverlay isOpen={helpOverlay.isOpen} onClose={() => setShowHelp(false)} currentView={mainView} />}
|
|
@@ -1841,6 +1974,7 @@ function AppInner() {
|
|
|
1841
1974
|
|
|
1842
1975
|
{/* Debug overlay - only in dev mode */}
|
|
1843
1976
|
{import.meta.env.DEV && <DebugOverlay />}
|
|
1977
|
+
</div>
|
|
1844
1978
|
</div>
|
|
1845
1979
|
</PortForwardProvider>
|
|
1846
1980
|
)
|
package/src/RadarApp.tsx
CHANGED
|
@@ -70,6 +70,13 @@ export interface RadarAppProps {
|
|
|
70
70
|
* See ./context/NavCustomization for the slot shape.
|
|
71
71
|
*/
|
|
72
72
|
navSlots?: NavCustomization;
|
|
73
|
+
/**
|
|
74
|
+
* Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
|
|
75
|
+
* deep-link a specific view (e.g. '/topology') without owning the URL bar —
|
|
76
|
+
* used with `navSlots.chrome: 'none'` to render a single per-cluster view
|
|
77
|
+
* chromeless under the host's own chrome (Radar Hub's per-cluster destinations).
|
|
78
|
+
*/
|
|
79
|
+
initialPath?: string;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
// Default QueryClient with the same shape Radar's standalone binary uses.
|
|
@@ -109,6 +116,7 @@ export function RadarApp({
|
|
|
109
116
|
router = 'browser',
|
|
110
117
|
queryClient,
|
|
111
118
|
navSlots,
|
|
119
|
+
initialPath,
|
|
112
120
|
}: RadarAppProps): React.ReactElement {
|
|
113
121
|
// Apply runtime config during render so module-level singletons are set
|
|
114
122
|
// before children construct URLs. getApiBase() / getAuthHeaders() /
|
|
@@ -136,7 +144,7 @@ export function RadarApp({
|
|
|
136
144
|
);
|
|
137
145
|
|
|
138
146
|
if (router === 'memory') {
|
|
139
|
-
return <MemoryRouter initialEntries={['/']}>{inner}</MemoryRouter>;
|
|
147
|
+
return <MemoryRouter initialEntries={[initialPath || '/']}>{inner}</MemoryRouter>;
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
return <BrowserRouter basename={basename || undefined}>{inner}</BrowserRouter>;
|
package/src/api/client.ts
CHANGED
|
@@ -90,8 +90,8 @@ export function isForbiddenError(error: unknown): boolean {
|
|
|
90
90
|
return error instanceof ApiError && error.status === 403
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export async function fetchJSON<T>(path: string): Promise<T> {
|
|
94
|
-
const response = await apiFetch(`${getApiBase()}${path}
|
|
93
|
+
export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
94
|
+
const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
|
|
95
95
|
if (!response.ok) {
|
|
96
96
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
97
97
|
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
@@ -691,6 +691,73 @@ export interface RuntimeStats {
|
|
|
691
691
|
dynamicInformers?: number
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
+
// ============================================================================
|
|
695
|
+
// Resource search (GET /api/search) — the existing search engine, RBAC-filtered
|
|
696
|
+
// and ranked server-side. Mirrors internal/search.Hit / .Result.
|
|
697
|
+
// ============================================================================
|
|
698
|
+
|
|
699
|
+
export interface SearchMatchedField {
|
|
700
|
+
token: string
|
|
701
|
+
/** "name" | "namespace" | "label:k" | "annotation:k" | "image" | "kind" | "content:path" */
|
|
702
|
+
site: string
|
|
703
|
+
score: number
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export interface SearchSummaryContext {
|
|
707
|
+
health?: string
|
|
708
|
+
issueCount?: number
|
|
709
|
+
managedBy?: { kind?: string; name?: string } | null
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export interface SearchHit {
|
|
713
|
+
score: number
|
|
714
|
+
kind: string
|
|
715
|
+
group?: string
|
|
716
|
+
namespace?: string
|
|
717
|
+
name: string
|
|
718
|
+
matched?: SearchMatchedField[]
|
|
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
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export interface SearchResult {
|
|
728
|
+
hits: SearchHit[]
|
|
729
|
+
total: number
|
|
730
|
+
searched: number
|
|
731
|
+
total_matched: number
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const SEARCH_MIN_QUERY = 2
|
|
735
|
+
|
|
736
|
+
// useSearch hits the resource-search engine. The caller supplies the (already
|
|
737
|
+
// debounced) query; the hook is enabled only past the min length. include=none
|
|
738
|
+
// keeps the per-hit payload identity-only; context=summary attaches
|
|
739
|
+
// health/issueCount per hit (rich rows). React Query's AbortSignal cancels
|
|
740
|
+
// overlapping scans on a new query. keepPreviousData avoids flicker while the
|
|
741
|
+
// next query resolves.
|
|
742
|
+
export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
|
|
743
|
+
const trimmed = query.trim()
|
|
744
|
+
const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
|
|
745
|
+
const limit = opts?.limit ?? 20
|
|
746
|
+
const context = opts?.context ?? 'summary'
|
|
747
|
+
// globalNs makes search ignore the per-user namespace-switcher pick and scan
|
|
748
|
+
// the user's full RBAC ceiling (scope then comes only from the query's `ns:`
|
|
749
|
+
// tokens). The omnibar opts in so ⌘K is a genuinely global lookup.
|
|
750
|
+
const globalNs = opts?.globalNs ?? false
|
|
751
|
+
return useQuery<SearchResult>({
|
|
752
|
+
queryKey: ['search', trimmed, limit, context, globalNs],
|
|
753
|
+
queryFn: ({ signal }) =>
|
|
754
|
+
fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
|
|
755
|
+
enabled,
|
|
756
|
+
staleTime: 2000,
|
|
757
|
+
placeholderData: (prev) => prev, // keepPreviousData
|
|
758
|
+
})
|
|
759
|
+
}
|
|
760
|
+
|
|
694
761
|
export interface HealthResponse {
|
|
695
762
|
status: string
|
|
696
763
|
resourceCount: number
|
|
@@ -755,7 +822,7 @@ export function useAuthMe() {
|
|
|
755
822
|
}
|
|
756
823
|
|
|
757
824
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
758
|
-
// CloudRole.AtLeast — the
|
|
825
|
+
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
759
826
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
760
827
|
// backend would happily honor (or vice versa).
|
|
761
828
|
const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
|
|
@@ -927,6 +994,7 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
|
|
|
927
994
|
data: query.data?.resource,
|
|
928
995
|
relationships: query.data?.relationships,
|
|
929
996
|
certificateInfo: query.data?.certificateInfo,
|
|
997
|
+
hpaDiagnosis: query.data?.hpaDiagnosis,
|
|
930
998
|
}
|
|
931
999
|
}
|
|
932
1000
|
|
|
@@ -973,6 +1041,7 @@ export interface UseChangesOptions {
|
|
|
973
1041
|
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
974
1042
|
includeK8sEvents?: boolean
|
|
975
1043
|
includeManaged?: boolean
|
|
1044
|
+
includeDeleted?: boolean
|
|
976
1045
|
limit?: number
|
|
977
1046
|
enabled?: boolean
|
|
978
1047
|
}
|
|
@@ -997,7 +1066,7 @@ function getTimeRangeDate(range: TimeRange): Date | null {
|
|
|
997
1066
|
}
|
|
998
1067
|
|
|
999
1068
|
export function useChanges(options: UseChangesOptions = {}) {
|
|
1000
|
-
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
|
|
1001
1070
|
|
|
1002
1071
|
const params = new URLSearchParams()
|
|
1003
1072
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
@@ -1005,6 +1074,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1005
1074
|
if (filter) params.set('filter', filter)
|
|
1006
1075
|
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
1007
1076
|
if (includeManaged) params.set('include_managed', 'true')
|
|
1077
|
+
if (!includeDeleted) params.set('include_deleted', 'false')
|
|
1008
1078
|
params.set('limit', String(limit))
|
|
1009
1079
|
|
|
1010
1080
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
@@ -1015,7 +1085,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1015
1085
|
const queryString = params.toString()
|
|
1016
1086
|
|
|
1017
1087
|
return useQuery<TimelineEvent[]>({
|
|
1018
|
-
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
|
|
1088
|
+
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
|
|
1019
1089
|
queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
|
|
1020
1090
|
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1021
1091
|
refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
|