@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.
Files changed (36) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +178 -44
  3. package/src/RadarApp.tsx +9 -1
  4. package/src/api/client.ts +75 -5
  5. package/src/components/UserMenu.tsx +56 -10
  6. package/src/components/applications/ApplicationsView.tsx +27 -19
  7. package/src/components/audit/AuditSettingsDialog.tsx +1 -1
  8. package/src/components/audit/AuditView.tsx +23 -35
  9. package/src/components/gitops/GitOpsView.tsx +24 -2
  10. package/src/components/helm/ChartBrowser.tsx +1 -1
  11. package/src/components/helm/HelmView.tsx +12 -8
  12. package/src/components/helm/RoleGatedPanel.tsx +1 -1
  13. package/src/components/home/HomeView.tsx +1 -1
  14. package/src/components/home/mcpToolCatalog.ts +34 -0
  15. package/src/components/issues/IssuesPane.tsx +82 -28
  16. package/src/components/nav/PrimaryNavRail.tsx +282 -0
  17. package/src/components/resource/HPACharts.tsx +7 -2
  18. package/src/components/resource/RestartChart.tsx +8 -0
  19. package/src/components/resources/ResourceDetailDrawer.tsx +3 -0
  20. package/src/components/resources/renderers/HPARenderer.tsx +4 -1
  21. package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
  22. package/src/components/settings/SettingsDialog.tsx +18 -1
  23. package/src/components/timeline/TimelineList.tsx +6 -1
  24. package/src/components/timeline/TimelineView.tsx +9 -0
  25. package/src/components/ui/CommandPalette.tsx +6 -215
  26. package/src/components/ui/Omnibar.tsx +603 -0
  27. package/src/components/ui/RadarOmnibar.tsx +52 -0
  28. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  29. package/src/components/ui/command-items.ts +178 -0
  30. package/src/components/workload/WorkloadView.tsx +3 -1
  31. package/src/context/NavCustomization.tsx +11 -0
  32. package/src/hooks/useMediaQuery.ts +21 -0
  33. package/src/hooks/useNavRailPinned.ts +46 -0
  34. package/src/hooks/useRecentResources.ts +49 -0
  35. package/src/index.ts +15 -0
  36. 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.5.0",
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.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,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, 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 { 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' | 'audit' | 'gitops' | 'compare' | 'issues' | 'applications'
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 === 'audit') {
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
- const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'applications', 'traffic', 'cost', 'audit']
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, i) => ({
507
+ ...views.map((view) => ({
459
508
  id: `view-${view}`,
460
- keys: String(i + 1),
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
- handler: () => setShowHelp(prev => !prev),
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: 'Open command palette',
552
+ description: 'Search resources & commands',
502
553
  category: 'General' as const,
503
554
  scope: 'global' as const,
504
555
  allowInInputs: true,
505
- handler: () => setShowCommandPalette(true),
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 (frontend-side) and by visible kinds
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
- <div className="relative flex flex-col h-screen bg-theme-base min-w-[800px]">
1145
- {/* Header */}
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
- {navCustomization.brandSlot ?? <Logo />}
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 — absolute centered on wide, flows after left section on narrow */}
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: 'audit' as const, icon: ShieldCheck, label: 'Audit' },
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 === 'audit' && clusterChecksHref))
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
- {/* 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>
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
- {/* User menu (when auth enabled) hidden in embedded mode;
1327
- host app typically provides its own via rightExtras. */}
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 === 'audit' && clusterChecksHref && (
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 === 'audit' && !clusterChecksHref && (
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 — bottom-right, above dock */}
1787
- <FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
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 SPA must agree with the backend on what
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