@skyhook-io/radar-app 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
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.0",
57
+ "@tailwindcss/vite": "^4.3.1",
58
58
  "@tanstack/react-query": "^5.100.14",
59
- "@types/node": "^25.7.0",
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.2.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,15 @@ 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, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
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 { Omnibar, type OmnibarHandle } from './components/ui/Omnibar'
54
60
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
55
61
 
56
62
  // All possible node kinds (core + GitOps)
@@ -94,7 +100,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
94
100
 
95
101
  // Convert API resource name back to topology node ID prefix
96
102
  // Extended MainView type that includes traffic and cost
97
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare' | 'issues' | 'applications'
103
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
98
104
 
99
105
  // Extract view from URL path
100
106
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -107,7 +113,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
107
113
  if (path === 'traffic') return 'traffic'
108
114
  if (path === 'cost') return 'cost'
109
115
  if (path === 'workload') return 'workload'
110
- if (path === 'audit') return 'audit'
116
+ if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
111
117
  if (path === 'gitops') return 'gitops'
112
118
  if (path === 'applications') return 'applications'
113
119
  if (path === 'compare') return 'compare'
@@ -171,6 +177,24 @@ function AppInner() {
171
177
  const capabilities = useCapabilitiesContext()
172
178
  const openLocalTerminal = useOpenLocalTerminal()
173
179
  const navCustomization = useNavCustomization()
180
+ const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
181
+ // Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
182
+ // the left chrome via their own fleet rail and keep Radar's top-bar pills.
183
+ const showNavRail = !navCustomization.embedded
184
+ // Chromeless embed: the host (Radar Hub) owns ALL chrome and drives view
185
+ // navigation + scope from its own UI, so Radar renders just the active view's
186
+ // content — no top bar, no view-switcher. Used for per-cluster views surfaced
187
+ // as native cloud destinations behind a cluster picker.
188
+ const chromeless = navCustomization.embedded === true && navCustomization.chrome === 'none'
189
+ // Force the slim rail on narrow windows: a pinned 176px rail needs viewport
190
+ // ≥976 to keep content above its ~800px floor (collapsed needs only ≥856).
191
+ // Below 976 we render collapsed regardless of the pin preference — a
192
+ // temporary responsive override that does NOT touch the persisted value, so
193
+ // the user's pinned state returns when they widen again. Fly-out labels cover
194
+ // the collapsed state, so the manual toggle is hidden here rather than left
195
+ // inert (expanding would just re-breach the floor).
196
+ const railForcedSlim = useMediaQuery('(max-width: 975px)')
197
+ const navRailEffectivePinned = navRailPinned && !railForcedSlim
174
198
 
175
199
  // Auth check — detect if auth is enabled but user is not authenticated
176
200
  const { data: authMe, isPending: authMePending } = useAuthMe()
@@ -262,7 +286,7 @@ function AppInner() {
262
286
  // unaffected and renders the in-app audit view as before.
263
287
  const clusterChecksHref = navCustomization.clusterChecksHref
264
288
  useEffect(() => {
265
- if (clusterChecksHref && mainView === 'audit') {
289
+ if (clusterChecksHref && mainView === 'checks') {
266
290
  window.location.replace(clusterChecksHref())
267
291
  }
268
292
  }, [clusterChecksHref, mainView])
@@ -450,14 +474,38 @@ function AppInner() {
450
474
 
451
475
  // Refs for dropdown components to trigger them via shortcuts
452
476
  const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
477
+ const omnibarRef = useRef<OmnibarHandle>(null)
478
+
479
+ // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
480
+ // mount) so the omnibar can open a CRD hit with an irregular plural from any
481
+ // view — kindToPlural would otherwise English-guess the route before a
482
+ // resources view has run n().
483
+ const { data: navApiResources } = useAPIResources()
484
+ useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
453
485
  const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
454
486
 
455
487
  // View switching keyboard shortcuts
456
- const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'applications', 'traffic', 'cost', 'audit']
488
+ // `g`+mnemonic sequences cover every view. Numeric 1–N can't: there are 11
489
+ // views and only 9 single digits, so `10`/`11` never match a keypress (a
490
+ // KeyboardEvent.key is one character). `g`-prefixed mnemonics scale, are the
491
+ // GitHub/Linear convention, and their second keys are all distinct (no clash
492
+ // with the scoped `g g` table shortcut). The letters are fixed regardless of
493
+ // position, so reordering the rail never changes a shortcut.
494
+ const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
495
+ home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
496
+ applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
497
+ gitops: 'g o', checks: 'g u', cost: 'g c',
498
+ // Non-rail views (reachable via deep links / actions, not the rail) get no
499
+ // dedicated mnemonic — listed for exhaustiveness so the type stays total.
500
+ workload: '', compare: '',
501
+ }
502
+ const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
503
+ (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
504
+ )
457
505
  useRegisterShortcuts([
458
- ...views.map((view, i) => ({
506
+ ...views.map((view) => ({
459
507
  id: `view-${view}`,
460
- keys: String(i + 1),
508
+ keys: VIEW_SHORTCUT_KEYS[view],
461
509
  description: `Go to ${view.charAt(0).toUpperCase() + view.slice(1)}`,
462
510
  category: 'Navigation' as const,
463
511
  scope: 'global' as const,
@@ -498,11 +546,12 @@ function AppInner() {
498
546
  {
499
547
  id: 'command-palette',
500
548
  keys: 'Cmd+k',
501
- description: 'Open command palette',
549
+ description: 'Search resources & commands',
502
550
  category: 'General' as const,
503
551
  scope: 'global' as const,
504
552
  allowInInputs: true,
505
- handler: () => setShowCommandPalette(true),
553
+ // Standalone focuses the top-center omnibar; embedded opens the modal.
554
+ handler: () => { if (showNavRail) omnibarRef.current?.focus(); else setShowCommandPalette(true) },
506
555
  },
507
556
  {
508
557
  id: 'diagnostics',
@@ -513,6 +562,20 @@ function AppInner() {
513
562
  allowInInputs: true,
514
563
  handler: () => setShowDiagnostics(prev => !prev),
515
564
  },
565
+ // Settings exposes local-binary controls that don't apply to embedded hosts.
566
+ // Register the shortcut only when standalone (matching the gear button) —
567
+ // `enabled: false` would still list it in the `?` help overlay, which shows
568
+ // all registered shortcuts regardless of enabled state.
569
+ ...(showNavRail
570
+ ? [{
571
+ id: 'open-settings',
572
+ keys: 'g s',
573
+ description: 'Open settings',
574
+ category: 'General' as const,
575
+ scope: 'global' as const,
576
+ handler: () => setShowSettings(true),
577
+ }]
578
+ : []),
516
579
  ])
517
580
 
518
581
  // Separate registration for help-close — its `enabled` changes with showHelp,
@@ -1141,12 +1204,38 @@ function AppInner() {
1141
1204
 
1142
1205
  return (
1143
1206
  <PortForwardProvider>
1144
- <div className="relative flex flex-col h-screen bg-theme-base min-w-[800px]">
1145
- {/* Header */}
1207
+ {/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
1208
+ the outer minimum must include it (176px pinned / 56px collapsed) or the
1209
+ content column (min-w-0, shrinkable) would fall below the old desktop
1210
+ floor at small windows. Embedded mode has no rail → plain 800. */}
1211
+ <div
1212
+ className="relative flex h-screen bg-theme-base"
1213
+ style={{ minWidth: 800 + (showNavRail ? (navRailEffectivePinned ? 176 : 56) : 0) }}
1214
+ >
1215
+ {showNavRail && (
1216
+ <PrimaryNavRail
1217
+ activeView={mainView}
1218
+ onNavigate={setMainView}
1219
+ pinned={navRailEffectivePinned}
1220
+ onTogglePinned={toggleNavRailPinned}
1221
+ showPinToggle={!railForcedSlim}
1222
+ onOpenSettings={() => setShowSettings(true)}
1223
+ accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
1224
+ />
1225
+ )}
1226
+ {/* `relative` makes this column the containing block for the absolute
1227
+ overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
1228
+ span the content area AFTER the rail rather than the full viewport
1229
+ under it. `fixed` splashes (connecting/switching) are unaffected. */}
1230
+ <div className="relative flex flex-col flex-1 min-w-0 h-full">
1231
+ {/* Header — suppressed in chromeless embed; the host owns the chrome. */}
1232
+ {!chromeless && (
1146
1233
  <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
1234
  {/* Left: Logo + Cluster info */}
1148
1235
  <div className="flex items-center gap-4 shrink-0">
1149
- {navCustomization.brandSlot ?? <Logo />}
1236
+ {/* Standalone rail owns the brand; only the embedded/pill layout
1237
+ shows it in the header (host may override via brandSlot). */}
1238
+ {navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
1150
1239
 
1151
1240
  <div className="flex items-center gap-2">
1152
1241
  {navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
@@ -1200,7 +1289,10 @@ function AppInner() {
1200
1289
  </div>
1201
1290
  </div>
1202
1291
 
1203
- {/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
1292
+ {/* Center: View tabs — embedded/pill layout only. Standalone Radar
1293
+ navigates via the left rail (showNavRail), so the pill bar is
1294
+ suppressed there to avoid a duplicate primary nav. */}
1295
+ {!showNavRail && (
1204
1296
  <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
1297
  {([
1206
1298
  { view: 'home' as const, icon: Home, label: 'Home' },
@@ -1217,7 +1309,7 @@ function AppInner() {
1217
1309
  // Cost is intentionally hidden from the pill bar for now — the view still
1218
1310
  // exists and is reachable via /cost, the Home dashboard card, and the
1219
1311
  // command palette (⌘K). Remove this comment to restore it.
1220
- { view: 'audit' as const, icon: ShieldCheck, label: 'Audit' },
1312
+ { view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
1221
1313
  ] as const)
1222
1314
  // In Cloud, Checks is a fleet-scoped feature owned by the host's
1223
1315
  // left rail; the per-cluster view is just that fleet queue filtered
@@ -1227,7 +1319,7 @@ function AppInner() {
1227
1319
  // via the Home "Cluster Audit" card (→ /audit, redirected to the
1228
1320
  // scoped fleet Checks by the clusterChecksHref effect above), ⌘K,
1229
1321
  // and bookmarks. Standalone OSS keeps the Audit tab.
1230
- .filter(({ view }) => !(view === 'audit' && clusterChecksHref))
1322
+ .filter(({ view }) => !(view === 'checks' && clusterChecksHref))
1231
1323
  .map(({ view, icon: Icon, label }) => (
1232
1324
  <Tooltip key={view} content={label} delay={100} position="bottom">
1233
1325
  <button
@@ -1254,6 +1346,30 @@ function AppInner() {
1254
1346
  </Tooltip>
1255
1347
  ))}
1256
1348
  </div>
1349
+ )}
1350
+
1351
+ {/* Center: omnibar — standalone search + command surface (the ⌘K entry).
1352
+ Fills the space the pill bar left; embedded keeps the pills + modal. */}
1353
+ {showNavRail && (
1354
+ <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1355
+ <Omnibar
1356
+ ref={omnibarRef}
1357
+ onNavigateView={(view) => setMainView(view)}
1358
+ onNavigateKind={(kind, group) => {
1359
+ const params = new URLSearchParams(searchParams)
1360
+ params.delete('kind')
1361
+ if (group) params.set('apiGroup', group); else params.delete('apiGroup')
1362
+ params.delete('resource')
1363
+ navigate({ pathname: `/resources/${kind}`, search: params.toString() })
1364
+ }}
1365
+ onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
1366
+ onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
1367
+ onToggleTheme={toggleTheme}
1368
+ onShowDiagnostics={() => setShowDiagnostics(true)}
1369
+ onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
1370
+ />
1371
+ </div>
1372
+ )}
1257
1373
 
1258
1374
  {/* Right: Controls */}
1259
1375
  <div className="flex items-center gap-3 shrink-0">
@@ -1264,7 +1380,9 @@ function AppInner() {
1264
1380
  />
1265
1381
 
1266
1382
 
1267
- {/* Command palette trigger */}
1383
+ {/* Command palette trigger — embedded only; standalone has the
1384
+ top-center omnibar (which is the ⌘K surface). */}
1385
+ {!showNavRail && (
1268
1386
  <button
1269
1387
  onClick={() => setShowCommandPalette(true)}
1270
1388
  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 +1392,7 @@ function AppInner() {
1274
1392
  {typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}K
1275
1393
  </kbd>
1276
1394
  </button>
1395
+ )}
1277
1396
 
1278
1397
  {/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
1279
1398
  {!navCustomization.embedded && (
@@ -1305,33 +1424,37 @@ function AppInner() {
1305
1424
  </div>
1306
1425
  )}
1307
1426
 
1308
- {/* Settingshidden in embedded mode. The standalone dialog
1309
- exposes local-binary controls (kubeconfig paths, server port,
1310
- "open browser on start", "Stop and restart the radar command
1311
- to apply") that don't apply to a hosted user who doesn't SSH
1312
- into the cluster. The audit view still opens the dialog via
1313
- its "N namespaces hidden" link for the narrow audit-ignores
1314
- setting that's a deliberate escape hatch, not a general
1315
- surface. */}
1316
- {!navCustomization.embedded && (
1317
- <button
1318
- onClick={() => setShowSettings(true)}
1319
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1320
- title="Settings"
1321
- >
1322
- <Settings className="w-4 h-4" />
1323
- </button>
1427
+ {/* Help + Report-a-bug standalone only (the left rail owns chrome;
1428
+ embedded hosts provide their own help/support). These replace the
1429
+ old floating bottom-right pair. Settings moved to the rail bottom. */}
1430
+ {showNavRail && (
1431
+ <>
1432
+ <button
1433
+ onClick={() => setShowHelp(true)}
1434
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1435
+ title="Keyboard shortcuts (?)"
1436
+ >
1437
+ <HelpCircle className="w-4 h-4" />
1438
+ </button>
1439
+ <button
1440
+ onClick={() => setShowDiagnostics(true)}
1441
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1442
+ title="Report a bug / Diagnostics"
1443
+ >
1444
+ <Bug className="w-4 h-4" />
1445
+ </button>
1446
+ </>
1324
1447
  )}
1325
1448
 
1326
- {/* User menu (when auth enabled) hidden in embedded mode;
1327
- host app typically provides its own via rightExtras. */}
1328
- {!navCustomization.embedded && <UserMenu />}
1449
+ {/* Account moved to the rail bottom (standalone). Embedded never showed
1450
+ Radar's UserMenu the host provides its own via rightExtras. */}
1329
1451
 
1330
1452
  {/* Consumer-provided extras (e.g. Radar Hub's Install button +
1331
1453
  avatar menu) appended to the right of the action bar. */}
1332
1454
  {navCustomization.rightExtras}
1333
1455
  </div>
1334
1456
  </header>
1457
+ )}
1335
1458
 
1336
1459
  {/* Auth barrier - show when auth is enabled but user is not authenticated */}
1337
1460
  {authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
@@ -1684,16 +1807,15 @@ function AppInner() {
1684
1807
  fleet Checks queue (clusterChecksHref effect above) — render a brief
1685
1808
  splash instead of the single-cluster view while the cross-document
1686
1809
  nav lands. */}
1687
- {mainView === 'audit' && clusterChecksHref && (
1810
+ {mainView === 'checks' && clusterChecksHref && (
1688
1811
  <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
1689
1812
  <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1690
1813
  <p className="text-sm text-theme-text-secondary">Opening Checks…</p>
1691
1814
  </div>
1692
1815
  )}
1693
- {mainView === 'audit' && !clusterChecksHref && (
1816
+ {mainView === 'checks' && !clusterChecksHref && (
1694
1817
  <AuditView
1695
1818
  namespaces={namespaces}
1696
- onBack={() => setMainView('home')}
1697
1819
  onNavigateToResource={navigateToResourceList}
1698
1820
  />
1699
1821
  )}
@@ -1705,7 +1827,6 @@ function AppInner() {
1705
1827
  {mainView === 'issues' && (
1706
1828
  <IssuesPane
1707
1829
  namespaces={namespaces}
1708
- onBack={() => setMainView('home')}
1709
1830
  onNavigateToResource={navigateFromIssue}
1710
1831
  />
1711
1832
  )}
@@ -1783,8 +1904,12 @@ function AppInner() {
1783
1904
  {/* Spacer for dock */}
1784
1905
  <DockSpacer />
1785
1906
 
1786
- {/* Floating action buttons — bottom-right, above dock */}
1787
- <FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
1907
+ {/* Floating action buttons — embedded only, and not in chromeless (the
1908
+ host owns help/diagnostics chrome). Standalone moved help + bug to
1909
+ visible top-bar icons (the rail owns chrome). */}
1910
+ {!showNavRail && !chromeless && (
1911
+ <FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
1912
+ )}
1788
1913
 
1789
1914
  {/* Keyboard shortcut help overlay */}
1790
1915
  {helpOverlay.shouldRender && <ShortcutHelpOverlay isOpen={helpOverlay.isOpen} onClose={() => setShowHelp(false)} currentView={mainView} />}
@@ -1841,6 +1966,7 @@ function AppInner() {
1841
1966
 
1842
1967
  {/* Debug overlay - only in dev mode */}
1843
1968
  {import.meta.env.DEV && <DebugOverlay />}
1969
+ </div>
1844
1970
  </div>
1845
1971
  </PortForwardProvider>
1846
1972
  )
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,68 @@ 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
+ }
721
+
722
+ export interface SearchResult {
723
+ hits: SearchHit[]
724
+ total: number
725
+ searched: number
726
+ total_matched: number
727
+ }
728
+
729
+ const SEARCH_MIN_QUERY = 2
730
+
731
+ // useSearch hits the resource-search engine. The caller supplies the (already
732
+ // debounced) query; the hook is enabled only past the min length. include=none
733
+ // keeps the per-hit payload identity-only; context=summary attaches
734
+ // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
735
+ // overlapping scans on a new query. keepPreviousData avoids flicker while the
736
+ // next query resolves.
737
+ export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
738
+ const trimmed = query.trim()
739
+ const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
740
+ const limit = opts?.limit ?? 20
741
+ const context = opts?.context ?? 'summary'
742
+ // globalNs makes search ignore the per-user namespace-switcher pick and scan
743
+ // the user's full RBAC ceiling (scope then comes only from the query's `ns:`
744
+ // tokens). The omnibar opts in so ⌘K is a genuinely global lookup.
745
+ const globalNs = opts?.globalNs ?? false
746
+ return useQuery<SearchResult>({
747
+ queryKey: ['search', trimmed, limit, context, globalNs],
748
+ queryFn: ({ signal }) =>
749
+ fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
750
+ enabled,
751
+ staleTime: 2000,
752
+ placeholderData: (prev) => prev, // keepPreviousData
753
+ })
754
+ }
755
+
694
756
  export interface HealthResponse {
695
757
  status: string
696
758
  resourceCount: number
@@ -927,6 +989,7 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
927
989
  data: query.data?.resource,
928
990
  relationships: query.data?.relationships,
929
991
  certificateInfo: query.data?.certificateInfo,
992
+ hpaDiagnosis: query.data?.hpaDiagnosis,
930
993
  }
931
994
  }
932
995
 
@@ -1,9 +1,19 @@
1
1
  import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { User, LogOut } from 'lucide-react'
3
+ import { clsx } from 'clsx'
3
4
  import { useAuthMe } from '../api/client'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
6
 
6
- export function UserMenu() {
7
+ interface UserMenuProps {
8
+ // 'topbar' (default): 27px avatar, dropdown opens downward.
9
+ // 'rail': a rail-bottom row (avatar + username, fly-out when slim), dropdown
10
+ // opens UPWARD + escapes the narrow column to the right.
11
+ variant?: 'topbar' | 'rail'
12
+ /** Rail variant only: expanded (labels) vs slim (icon + fly-out). */
13
+ pinned?: boolean
14
+ }
15
+
16
+ export function UserMenu({ variant = 'topbar', pinned = true }: UserMenuProps = {}) {
7
17
  const { data: authMe } = useAuthMe()
8
18
  const [isOpen, setIsOpen] = useState(false)
9
19
  const menuRef = useRef<HTMLDivElement>(null)
@@ -47,18 +57,54 @@ export function UserMenu() {
47
57
  .map(s => s[0]?.toUpperCase() || '')
48
58
  .join('')
49
59
 
60
+ const isRail = variant === 'rail'
61
+ const avatar = (
62
+ <span className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium shrink-0">
63
+ {initials || <User className="w-3.5 h-3.5" />}
64
+ </span>
65
+ )
66
+
50
67
  return (
51
- <div ref={menuRef} className="relative">
52
- <button
53
- onClick={() => setIsOpen(!isOpen)}
54
- className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium hover:bg-blue-500/25 transition-colors"
55
- title={authMe.username}
56
- >
57
- {initials || <User className="w-3.5 h-3.5" />}
58
- </button>
68
+ <div ref={menuRef} className={clsx('relative', isRail && 'group/item', isRail && !pinned && 'w-10')}>
69
+ {isRail ? (
70
+ <button
71
+ onClick={() => setIsOpen(!isOpen)}
72
+ title={authMe.username}
73
+ className={clsx(
74
+ 'relative flex h-9 w-full items-center rounded-md text-sm font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary transition-colors',
75
+ !pinned && 'max-w-10 overflow-hidden',
76
+ )}
77
+ >
78
+ <span className="flex w-10 shrink-0 items-center justify-center">{avatar}</span>
79
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{authMe.username.split('@')[0]}</span>
80
+ </button>
81
+ ) : (
82
+ <button
83
+ onClick={() => setIsOpen(!isOpen)}
84
+ className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium hover:bg-blue-500/25 transition-colors"
85
+ title={authMe.username}
86
+ >
87
+ {initials || <User className="w-3.5 h-3.5" />}
88
+ </button>
89
+ )}
90
+
91
+ {/* Slim-rail fly-out label (account row, collapsed) */}
92
+ {isRail && !pinned && !isOpen && (
93
+ <span
94
+ aria-hidden
95
+ className="pointer-events-none absolute left-full top-1/2 z-50 ml-1 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-theme-border bg-theme-hover px-2.5 py-1 text-[13px] font-medium text-theme-text-primary opacity-0 shadow-lg shadow-black/30 transition-opacity duration-75 group-hover/item:block group-hover/item:opacity-100"
96
+ >
97
+ Account
98
+ </span>
99
+ )}
59
100
 
60
101
  {isOpen && (
61
- <div className="absolute right-0 top-full mt-1.5 w-56 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50 py-1">
102
+ <div className={clsx(
103
+ 'absolute w-56 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50 py-1',
104
+ // Rail: open UP (it sits at the viewport bottom) and align to the rail's
105
+ // left edge so a 56px slim column doesn't clip it (it extends right).
106
+ isRail ? 'bottom-full left-2 mb-1.5' : 'right-0 top-full mt-1.5',
107
+ )}>
62
108
  <div className="px-3 py-2 border-b border-theme-border">
63
109
  <p className="text-sm font-medium text-theme-text-primary truncate">{authMe.username}</p>
64
110
  {authMe.groups && authMe.groups.length > 0 && (