@skyhook-io/radar-app 1.6.2 → 1.8.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.
Files changed (38) hide show
  1. package/package.json +8 -2
  2. package/src/App.tsx +110 -45
  3. package/src/api/client.ts +10 -6
  4. package/src/components/ConnectionErrorView.tsx +5 -2
  5. package/src/components/DebugOverlay.tsx +4 -2
  6. package/src/components/UserMenu.tsx +5 -2
  7. package/src/components/audit/AuditSettingsDialog.tsx +14 -10
  8. package/src/components/audit/AuditView.tsx +3 -1
  9. package/src/components/compare/useCompareLauncher.tsx +6 -3
  10. package/src/components/cost/CostView.tsx +13 -4
  11. package/src/components/helm/ChartBrowser.tsx +6 -4
  12. package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
  13. package/src/components/helm/HelmView.tsx +7 -6
  14. package/src/components/helm/InstallWizard.tsx +3 -2
  15. package/src/components/helm/OwnedResources.tsx +24 -13
  16. package/src/components/helm/RevisionHistory.tsx +2 -1
  17. package/src/components/helm/ValuesViewer.tsx +4 -2
  18. package/src/components/home/ClusterHealthCard.tsx +21 -18
  19. package/src/components/home/HelmSummary.tsx +3 -1
  20. package/src/components/home/HomeView.tsx +9 -2
  21. package/src/components/home/MCPSetupDialog.tsx +10 -4
  22. package/src/components/nav/PrimaryNavRail.tsx +5 -2
  23. package/src/components/portforward/PortForwardButton.tsx +14 -8
  24. package/src/components/resource/PrometheusChartsGrid.tsx +9 -2
  25. package/src/components/resource/RestartChart.tsx +6 -5
  26. package/src/components/resource/RightsizingStrip.tsx +4 -1
  27. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  28. package/src/components/resources/ImageFilesystemModal.tsx +16 -8
  29. package/src/components/resources/PodFilesystemModal.tsx +4 -2
  30. package/src/components/settings/SettingsDialog.tsx +371 -141
  31. package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
  32. package/src/components/traffic/TrafficFlowList.tsx +13 -4
  33. package/src/components/traffic/TrafficGraph.tsx +8 -3
  34. package/src/components/traffic/TrafficView.tsx +4 -2
  35. package/src/components/ui/Omnibar.tsx +0 -1
  36. package/src/components/workload/WorkloadView.tsx +15 -10
  37. package/src/context/NavCustomization.tsx +48 -9
  38. package/src/index.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.6.2",
3
+ "version": "1.8.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",
@@ -24,7 +24,7 @@
24
24
  "preview": "vite preview",
25
25
  "tsc": "tsc --noEmit",
26
26
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
27
- "lint": "eslint src --ext ts,tsx"
27
+ "lint": "eslint src && node scripts/check-no-native-title.mjs"
28
28
  },
29
29
  "dependencies": {
30
30
  "@fontsource-variable/dm-sans": "^5.2.8",
@@ -51,6 +51,7 @@
51
51
  "tailwind-merge": ">=2"
52
52
  },
53
53
  "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
54
55
  "@playwright/test": "^1.59.1",
55
56
  "@skyhook-io/k8s-ui": "*",
56
57
  "@tailwindcss/typography": "^0.5.20",
@@ -63,6 +64,10 @@
63
64
  "@xyflow/react": "^12.10.2",
64
65
  "clsx": "^2.1.1",
65
66
  "elkjs": "^0.11.1",
67
+ "eslint": "^10.5.0",
68
+ "eslint-plugin-react-hooks": "^7.1.1",
69
+ "eslint-plugin-react-refresh": "^0.5.3",
70
+ "globals": "^17.7.0",
66
71
  "lucide-react": "^1.16.0",
67
72
  "postcss": "^8.5.14",
68
73
  "prettier": "^3.8.1",
@@ -72,6 +77,7 @@
72
77
  "tailwind-merge": "^3.6.0",
73
78
  "tailwindcss": "^4.3.1",
74
79
  "typescript": "^6.0.2",
80
+ "typescript-eslint": "^8.62.0",
75
81
  "vite": "^8.0.12"
76
82
  },
77
83
  "sideEffects": [
package/src/App.tsx CHANGED
@@ -29,6 +29,7 @@ import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
29
29
  import { ContextSwitcher } from './components/ContextSwitcher'
30
30
  import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
31
31
  import { useNavCustomization } from './context/NavCustomization'
32
+ import type { FleetTakeoverTarget } from './context/NavCustomization'
32
33
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
33
34
  import { useNavRailPinned } from './hooks/useNavRailPinned'
34
35
  import { useMediaQuery } from './hooks/useMediaQuery'
@@ -178,6 +179,32 @@ function AppInner() {
178
179
  const capabilities = useCapabilitiesContext()
179
180
  const openLocalTerminal = useOpenLocalTerminal()
180
181
  const navCustomization = useNavCustomization()
182
+ // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
183
+ // cross-tree swap) navigates same-document so the chrome morphs instead of
184
+ // cold-booting; without it we fall back to a hard `window.location` nav.
185
+ const goHost = useCallback(
186
+ (url: string) => {
187
+ if (navCustomization.onHostNavigate) navCustomization.onHostNavigate(url)
188
+ else window.location.assign(url)
189
+ },
190
+ [navCustomization],
191
+ )
192
+ // Resolve every host-takeover URL ONCE (memoized on navCustomization) so the
193
+ // setMainView intercept, redirect effect, nav-pill filtering, inline-view
194
+ // gating, and the cert click handler all consume the SAME value — host
195
+ // callbacks aren't guaranteed idempotent (scope / flags / signed URLs can
196
+ // shift between calls). undefined = not taken over → Radar renders the view
197
+ // itself. `clusterChecksHref` is the deprecated pre-1.7 hook, folded into the
198
+ // 'checks' target for back-compat.
199
+ const takeover: Record<FleetTakeoverTarget, string | undefined> = useMemo(
200
+ () => ({
201
+ issues: navCustomization.fleetTakeoverHref?.('issues'),
202
+ gitops: navCustomization.fleetTakeoverHref?.('gitops'),
203
+ checks: navCustomization.fleetTakeoverHref?.('checks') ?? navCustomization.clusterChecksHref?.(),
204
+ certs: navCustomization.fleetTakeoverHref?.('certs'),
205
+ }),
206
+ [navCustomization],
207
+ )
181
208
  const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
182
209
  // Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
183
210
  // the left chrome via their own fleet rail and keep Radar's top-bar pills.
@@ -257,6 +284,20 @@ function AppInner() {
257
284
 
258
285
  // Set mainView by navigating to the path
259
286
  const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
287
+ // Host takeover: fleet-shaped views (issues/gitops/checks) are owned by the
288
+ // host's fleet pages. Hand straight to the host instead of navigating to
289
+ // our own /<view> first — that intermediate hop mounts the view machinery
290
+ // and flashes the "Opening…" splash before the redirect effect bounces out.
291
+ // Skipping it makes the hand-off a single smooth cross-tree swap. (Direct
292
+ // /<view> URL entry still funnels through the redirect effect below.)
293
+ if (view === 'issues' || view === 'gitops' || view === 'checks') {
294
+ const href = takeover[view]
295
+ if (href) {
296
+ goHost(href)
297
+ return
298
+ }
299
+ }
300
+
260
301
  const path = view === 'home' ? '/' : `/${view}`
261
302
 
262
303
  // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
@@ -274,23 +315,31 @@ function AppInner() {
274
315
  }
275
316
 
276
317
  navigate({ pathname: path, search: newParams.toString() })
277
- }, [navigate, searchParams])
278
-
279
- // Cloud (embedded) makes the host's fleet Checks queue the one canonical
280
- // surface — owned by the host's left rail — so Radar drops its own Audit
281
- // pill (see the nav below) and any route to /audit redirects to the fleet
282
- // Checks queue scoped to this cluster. Entry points that still land on
283
- // /audit the Home "Cluster Audit" card, ⌘K, WorkloadView's "view all"
284
- // findings, bookmarks/deep links all funnel through here. `replace` (not
285
- // assign) keeps the transient /audit URL out of history so Back doesn't
286
- // bounce off the redirect. Standalone OSS (no clusterChecksHref) is
287
- // unaffected and renders the in-app audit view as before.
288
- const clusterChecksHref = navCustomization.clusterChecksHref
318
+ }, [navigate, searchParams, takeover, goHost])
319
+
320
+ // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
321
+ // own fleet pages scoped to this cluster — owned by the host's left rail — so
322
+ // Radar drops the matching pills (see the nav below). In-app nav hands off in
323
+ // setMainView (above); direct /<view> URL entry funnels through the redirect
324
+ // effect below. Both consume the memoized `takeover` resolved above. Standalone
325
+ // OSS (no fleetTakeoverHref) is unaffected and renders the in-app view.
326
+ //
327
+ // Has the host claimed this view? View-shaped targets only ('certs' has no
328
+ // Radar view only its Home card consults `takeover`). Used to drop the nav
329
+ // pill and gate the inline view render in favor of the "Opening…" splash.
330
+ const isViewTakenOver = (view: ExtendedMainView): boolean =>
331
+ (view === 'issues' || view === 'gitops' || view === 'checks') && !!takeover[view]
332
+ // The host's URL for the CURRENT view, if taken over. Drives the redirect
333
+ // effect and the "Opening…" splash.
334
+ const viewTakeoverHref =
335
+ mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
336
+ ? takeover[mainView]
337
+ : undefined
289
338
  useEffect(() => {
290
- if (clusterChecksHref && mainView === 'checks') {
291
- window.location.replace(clusterChecksHref())
339
+ if (viewTakeoverHref) {
340
+ window.location.replace(viewTakeoverHref)
292
341
  }
293
- }, [clusterChecksHref, mainView])
342
+ }, [viewTakeoverHref])
294
343
 
295
344
  const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
296
345
  // For large clusters: force SSE to reconnect with namespace filter
@@ -1281,14 +1330,15 @@ function AppInner() {
1281
1330
  </span>
1282
1331
  )}
1283
1332
  {!connected && (
1333
+ <Tooltip content="Reconnect">
1284
1334
  <button
1285
1335
  onClick={reconnect}
1286
1336
  disabled={isReconnecting}
1287
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
1288
- title="Reconnect"
1337
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1289
1338
  >
1290
1339
  <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
1291
1340
  </button>
1341
+ </Tooltip>
1292
1342
  )}
1293
1343
  </div>
1294
1344
  {/* Port forwards indicator — shown only when sessions exist */}
@@ -1318,15 +1368,14 @@ function AppInner() {
1318
1368
  // command palette (⌘K). Remove this comment to restore it.
1319
1369
  { view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
1320
1370
  ] as const)
1321
- // In Cloud, Checks is a fleet-scoped feature owned by the host's
1322
- // left rail; the per-cluster view is just that fleet queue filtered
1323
- // to this cluster, so duplicating it as a peer pill here would be a
1324
- // second "Checks" that teleports out of the cluster shell. Drop the
1325
- // Audit tab when embedded — cluster-scoped access stays available
1326
- // via the Home "Cluster Audit" card ( /audit, redirected to the
1327
- // scoped fleet Checks by the clusterChecksHref effect above), ⌘K,
1328
- // and bookmarks. Standalone OSS keeps the Audit tab.
1329
- .filter(({ view }) => !(view === 'checks' && clusterChecksHref))
1371
+ // In Cloud, fleet-shaped views (Checks, Issues, GitOps) are owned by
1372
+ // the host's left rail; the per-cluster view is just that fleet page
1373
+ // filtered to this cluster, so duplicating it as a peer pill here
1374
+ // would be a second copy that teleports out of the cluster shell.
1375
+ // Drop any pill the host took over — cluster-scoped access stays
1376
+ // available via the Home cards (redirected by the takeover effect
1377
+ // above), ⌘K, and bookmarks. Standalone OSS keeps every pill.
1378
+ .filter(({ view }) => !isViewTakenOver(view))
1330
1379
  .map(({ view, icon: Icon, label }) => (
1331
1380
  <Tooltip key={view} content={label} delay={100} position="bottom">
1332
1381
  <button
@@ -1382,8 +1431,6 @@ function AppInner() {
1382
1431
  <div className="flex items-center gap-3 shrink-0">
1383
1432
  <NamespaceSwitcher
1384
1433
  ref={namespaceSwitcherRef}
1385
- disabled={mainView === 'helm'}
1386
- disabledTooltip="Helm view always shows all namespaces"
1387
1434
  />
1388
1435
 
1389
1436
 
@@ -1410,13 +1457,14 @@ function AppInner() {
1410
1457
 
1411
1458
  {/* Local terminal */}
1412
1459
  {capabilities.localTerminal && (
1460
+ <Tooltip content="Open local terminal">
1413
1461
  <button
1414
1462
  onClick={() => openLocalTerminal()}
1415
1463
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1416
- title="Open local terminal"
1417
1464
  >
1418
1465
  <SquareTerminal className="w-4 h-4" />
1419
1466
  </button>
1467
+ </Tooltip>
1420
1468
  )}
1421
1469
 
1422
1470
  {/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
@@ -1436,20 +1484,22 @@ function AppInner() {
1436
1484
  old floating bottom-right pair. Settings moved to the rail bottom. */}
1437
1485
  {showNavRail && (
1438
1486
  <>
1487
+ <Tooltip content="Keyboard shortcuts (?)">
1439
1488
  <button
1440
1489
  onClick={() => setShowHelp(true)}
1441
1490
  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="Keyboard shortcuts (?)"
1443
1491
  >
1444
1492
  <HelpCircle className="w-4 h-4" />
1445
1493
  </button>
1494
+ </Tooltip>
1495
+ <Tooltip content="Report a bug / Diagnostics">
1446
1496
  <button
1447
1497
  onClick={() => setShowDiagnostics(true)}
1448
1498
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1449
- title="Report a bug / Diagnostics"
1450
1499
  >
1451
1500
  <Bug className="w-4 h-4" />
1452
1501
  </button>
1502
+ </Tooltip>
1453
1503
  </>
1454
1504
  )}
1455
1505
 
@@ -1611,6 +1661,15 @@ function AppInner() {
1611
1661
  navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
1612
1662
  }}
1613
1663
  onNavigateToResource={navigateFromIssue}
1664
+ // Certs has no Radar view, so it can't ride the view-redirect effect
1665
+ // above — wire the Certificate Health card straight to the host's
1666
+ // fleet Certs page (scoped to this cluster) when claimed. `assign`
1667
+ // (not replace): the user is navigating forward from a card, so this
1668
+ // belongs in history. Omitted → the card falls back to Radar's own
1669
+ // TLS-secrets resource list.
1670
+ onNavigateToCerts={
1671
+ takeover.certs ? () => goHost(takeover.certs!) : undefined
1672
+ }
1614
1673
  />
1615
1674
  )}
1616
1675
 
@@ -1746,10 +1805,9 @@ function AppInner() {
1746
1805
  />
1747
1806
  )}
1748
1807
 
1749
- {/* Helm view - always show all namespaces since releases span multiple ns */}
1750
1808
  {mainView === 'helm' && (
1751
1809
  <HelmView
1752
- namespace=""
1810
+ namespaces={namespaces}
1753
1811
  selectedRelease={selectedHelmRelease}
1754
1812
  onReleaseClick={(ns, name, storageNamespace) => {
1755
1813
  setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
@@ -1765,8 +1823,9 @@ function AppInner() {
1765
1823
  />
1766
1824
  )}
1767
1825
 
1768
- {/* GitOps view */}
1769
- {mainView === 'gitops' && (
1826
+ {/* GitOps view (inline only when the host hasn't taken it over — see
1827
+ the takeover splash below). */}
1828
+ {mainView === 'gitops' && !isViewTakenOver('gitops') && (
1770
1829
  <GitOpsView
1771
1830
  namespaces={namespaces}
1772
1831
  onOpenResource={(resource) => {
@@ -1796,17 +1855,21 @@ function AppInner() {
1796
1855
  <CostView onBack={() => setMainView('home')} />
1797
1856
  )}
1798
1857
 
1799
- {/* Best practices detail view. In Cloud this redirects to the host's
1800
- fleet Checks queue (clusterChecksHref effect above) — render a brief
1801
- splash instead of the single-cluster view while the cross-document
1802
- nav lands. */}
1803
- {mainView === 'checks' && clusterChecksHref && (
1858
+ {/* Takeover splash. When the host claims the current view via
1859
+ fleetTakeoverHref, the redirect effect above is mid-flight — render a
1860
+ brief splash instead of the inline view (which would flash + fire its
1861
+ own fetches) while the cross-document nav lands. Covers checks /
1862
+ issues / gitops with one block since only one view is active. */}
1863
+ {viewTakeoverHref && (
1804
1864
  <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
1805
1865
  <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1806
- <p className="text-sm text-theme-text-secondary">Opening Checks…</p>
1866
+ <p className="text-sm text-theme-text-secondary">Opening…</p>
1807
1867
  </div>
1808
1868
  )}
1809
- {mainView === 'checks' && !clusterChecksHref && (
1869
+
1870
+ {/* Best practices detail view (inline only when the host hasn't taken
1871
+ Checks over — standalone OSS, or Cloud without a checks takeover). */}
1872
+ {mainView === 'checks' && !isViewTakenOver('checks') && (
1810
1873
  <AuditView
1811
1874
  namespaces={namespaces}
1812
1875
  onNavigateToResource={navigateToResourceList}
@@ -1816,8 +1879,9 @@ function AppInner() {
1816
1879
  {/* Issues — per-cluster live triage queue (hidden route: not yet in the
1817
1880
  nav `views` list; reachable at /issues). Same shared <IssuesView> the
1818
1881
  Hub fleet uses; a GitOps reconciler subject routes to its detail page,
1819
- other resources open the standard resource view. */}
1820
- {mainView === 'issues' && (
1882
+ other resources open the standard resource view. Inline only when the
1883
+ host hasn't taken it over. */}
1884
+ {mainView === 'issues' && !isViewTakenOver('issues') && (
1821
1885
  <IssuesPane
1822
1886
  namespaces={namespaces}
1823
1887
  onNavigateToResource={navigateFromIssue}
@@ -2206,10 +2270,10 @@ function ThemeToggle() {
2206
2270
  const { theme, toggleTheme } = useTheme()
2207
2271
 
2208
2272
  return (
2273
+ <Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
2209
2274
  <button
2210
2275
  onClick={toggleTheme}
2211
2276
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
2212
- title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
2213
2277
  >
2214
2278
  {theme === 'dark' ? (
2215
2279
  <Sun className="w-4 h-4" />
@@ -2217,6 +2281,7 @@ function ThemeToggle() {
2217
2281
  <Moon className="w-4 h-4" />
2218
2282
  )}
2219
2283
  </button>
2284
+ </Tooltip>
2220
2285
  )
2221
2286
  }
2222
2287
 
package/src/api/client.ts CHANGED
@@ -2311,11 +2311,15 @@ export function useDrainNode() {
2311
2311
  // Helm API hooks
2312
2312
  // ============================================================================
2313
2313
 
2314
+ function helmNamespaceParams(namespaces: string[] = []) {
2315
+ return namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
2316
+ }
2317
+
2314
2318
  // List all Helm releases
2315
- export function useHelmReleases(namespace?: string) {
2316
- const params = namespace ? `?namespace=${namespace}` : ''
2319
+ export function useHelmReleases(namespaces: string[] = []) {
2320
+ const params = helmNamespaceParams(namespaces)
2317
2321
  return useQuery<HelmRelease[]>({
2318
- queryKey: ['helm-releases', namespace],
2322
+ queryKey: ['helm-releases', namespaces],
2319
2323
  queryFn: () => fetchJSON(`/helm/releases${params}`),
2320
2324
  staleTime: 30000, // 30 seconds
2321
2325
  })
@@ -2393,10 +2397,10 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
2393
2397
  }
2394
2398
 
2395
2399
  // Batch check for upgrade availability (for list view)
2396
- export function useHelmBatchUpgradeInfo(namespace?: string, enabled = true) {
2397
- const params = namespace ? `?namespace=${namespace}` : ''
2400
+ export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
2401
+ const params = helmNamespaceParams(namespaces)
2398
2402
  return useQuery<BatchUpgradeInfo>({
2399
- queryKey: ['helm-batch-upgrade-info', namespace],
2403
+ queryKey: ['helm-batch-upgrade-info', namespaces],
2400
2404
  queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
2401
2405
  enabled,
2402
2406
  staleTime: 30000, // 30 seconds - keep in sync with release list
@@ -5,6 +5,7 @@ import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe } from '../api/client'
8
+ import { Tooltip } from './ui/Tooltip'
8
9
 
9
10
  interface ConnectionErrorViewProps {
10
11
  connection: ConnectionState
@@ -137,18 +138,19 @@ function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunI
137
138
  {command}
138
139
  </code>
139
140
  {onRunInTerminal && (
141
+ <Tooltip content="Run in terminal" wrapperClassName="shrink-0">
140
142
  <button
141
143
  onClick={() => onRunInTerminal(command)}
142
144
  className="shrink-0 text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
143
- title="Run in terminal"
144
145
  >
145
146
  <TerminalSquare className="w-3.5 h-3.5" />
146
147
  </button>
148
+ </Tooltip>
147
149
  )}
150
+ <Tooltip content="Copy to clipboard" wrapperClassName="shrink-0">
148
151
  <button
149
152
  onClick={handleCopy}
150
153
  className="shrink-0 text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
151
- title="Copy to clipboard"
152
154
  >
153
155
  {copied ? (
154
156
  <Check className="w-3.5 h-3.5 text-green-400" />
@@ -156,6 +158,7 @@ function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunI
156
158
  <Copy className="w-3.5 h-3.5" />
157
159
  )}
158
160
  </button>
161
+ </Tooltip>
159
162
  </div>
160
163
  )
161
164
  }
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { Bug, X, ChevronDown, ChevronUp } from 'lucide-react'
3
3
  import { useRuntimeStats } from '../api/client'
4
+ import { Tooltip } from './ui/Tooltip'
4
5
 
5
6
  function formatUptime(seconds: number): string {
6
7
  if (seconds < 60) return `${seconds}s`
@@ -17,13 +18,14 @@ export function DebugOverlay() {
17
18
 
18
19
  if (!visible) {
19
20
  return (
21
+ <Tooltip content="Show debug stats" position="left" wrapperClassName="fixed bottom-3 right-3 z-50">
20
22
  <button
21
23
  onClick={() => setVisible(true)}
22
- className="fixed bottom-3 right-3 z-50 p-2 bg-theme-surface/90 border border-theme-border rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
23
- title="Show debug stats"
24
+ className="p-2 bg-theme-surface/90 border border-theme-border rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
24
25
  >
25
26
  <Bug className="w-4 h-4" />
26
27
  </button>
28
+ </Tooltip>
27
29
  )
28
30
  }
29
31
 
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { User, LogOut } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { useAuthMe } from '../api/client'
5
+ import { Tooltip } from './ui/Tooltip'
5
6
  import { useQueryClient } from '@tanstack/react-query'
6
7
 
7
8
  interface UserMenuProps {
@@ -67,9 +68,9 @@ export function UserMenu({ variant = 'topbar', pinned = true }: UserMenuProps =
67
68
  return (
68
69
  <div ref={menuRef} className={clsx('relative', isRail && 'group/item', isRail && !pinned && 'w-10')}>
69
70
  {isRail ? (
71
+ <Tooltip content={authMe.username} position="right" wrapperClassName="!block w-full">
70
72
  <button
71
73
  onClick={() => setIsOpen(!isOpen)}
72
- title={authMe.username}
73
74
  className={clsx(
74
75
  '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
76
  !pinned && 'max-w-10 overflow-hidden',
@@ -78,14 +79,16 @@ export function UserMenu({ variant = 'topbar', pinned = true }: UserMenuProps =
78
79
  <span className="flex w-10 shrink-0 items-center justify-center">{avatar}</span>
79
80
  <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{authMe.username.split('@')[0]}</span>
80
81
  </button>
82
+ </Tooltip>
81
83
  ) : (
84
+ <Tooltip content={authMe.username}>
82
85
  <button
83
86
  onClick={() => setIsOpen(!isOpen)}
84
87
  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
88
  >
87
89
  {initials || <User className="w-3.5 h-3.5" />}
88
90
  </button>
91
+ </Tooltip>
89
92
  )}
90
93
 
91
94
  {/* Slim-rail fly-out label (account row, collapsed) */}
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from '../../api/client'
5
5
  import type { CheckMeta } from '@skyhook-io/k8s-ui'
6
6
  import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface AuditSettingsDialogProps {
9
10
  namespaces: string[]
@@ -192,6 +193,17 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
192
193
  >
193
194
  Cancel
194
195
  </button>
196
+ <Tooltip
197
+ content={
198
+ !canEdit
199
+ ? 'Audit settings can only be changed by owners'
200
+ : newNsError
201
+ ? 'Fix or clear the pending namespace input before saving'
202
+ : newNsDuplicate
203
+ ? 'Clear the duplicate pending input before saving'
204
+ : ''
205
+ }
206
+ >
195
207
  <button
196
208
  onClick={handleSave}
197
209
  // Block save while the namespace input has unfixed pending
@@ -200,19 +212,11 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
200
212
  disabled={
201
213
  !canEdit || updateSettings.isPending || newNsError !== null || newNsDuplicate
202
214
  }
203
- title={
204
- !canEdit
205
- ? 'Audit settings can only be changed by owners'
206
- : newNsError
207
- ? 'Fix or clear the pending namespace input before saving'
208
- : newNsDuplicate
209
- ? 'Clear the duplicate pending input before saving'
210
- : undefined
211
- }
212
- className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
215
+ className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
213
216
  >
214
217
  {updateSettings.isPending ? 'Saving...' : 'Save'}
215
218
  </button>
219
+ </Tooltip>
216
220
  </div>
217
221
  </div>
218
222
  </div>
@@ -4,6 +4,7 @@ import type { SelectedResource } from '../../types'
4
4
  import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
5
  import { ShieldCheck, Settings } from 'lucide-react'
6
6
  import { AuditSettingsDialog } from './AuditSettingsDialog'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface AuditViewProps {
9
10
  namespaces: string[]
@@ -82,13 +83,14 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
82
83
  {ignoredCount > 0 && (
83
84
  <button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
84
85
  )}
86
+ <Tooltip content="Checks settings">
85
87
  <button
86
88
  onClick={() => setShowSettings(true)}
87
89
  className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
88
- title="Checks settings"
89
90
  >
90
91
  <Settings className="w-4 h-4" />
91
92
  </button>
93
+ </Tooltip>
92
94
  </>
93
95
  }
94
96
  />
@@ -31,15 +31,18 @@ export function useCompareLauncher({ kind, namespace, name, group }: UseCompareL
31
31
  const [open, setOpen] = useState(false)
32
32
  const kindLower = kind.toLowerCase()
33
33
  const { candidates, isPending, error } = useCompareCandidates(kindLower, group, open)
34
- const { crossClusterCompareHref } = useNavCustomization()
34
+ const { crossClusterCompareHref, onHostNavigate } = useNavCustomization()
35
35
 
36
36
  const onCompareTo = useCallback(() => setOpen(true), [])
37
37
 
38
38
  const onCompareAcrossClusters = useCallback(() => {
39
39
  if (!crossClusterCompareHref) return
40
40
  const href = crossClusterCompareHref({ kind: kindLower, namespace, name, group })
41
- window.location.assign(href)
42
- }, [crossClusterCompareHref, kindLower, namespace, name, group])
41
+ // Smooth same-document hand-off when the host supports it (Radar Cloud's
42
+ // cross-tree swap), else a hard nav — same contract as App.tsx's goHost.
43
+ if (onHostNavigate) onHostNavigate(href)
44
+ else window.location.assign(href)
45
+ }, [crossClusterCompareHref, onHostNavigate, kindLower, namespace, name, group])
43
46
 
44
47
  const handlePick = useCallback(
45
48
  (picked: CompareResourceRef) => {
@@ -4,6 +4,7 @@ import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } fr
4
4
  import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
5
5
  import { PaneLoader } from '@skyhook-io/k8s-ui'
6
6
  import { CostTrendChart } from './CostTrendChart'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface CostViewProps {
9
10
  onBack: () => void
@@ -177,8 +178,12 @@ export function CostView({ onBack }: CostViewProps) {
177
178
  <div className="grid grid-cols-[minmax(180px,1fr)_90px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2 border-b border-theme-border text-[11px] font-medium text-theme-text-tertiary uppercase tracking-wider">
178
179
  <span>Namespace</span>
179
180
  <span className="text-right">Hourly</span>
180
- <span className="text-right cursor-help" title="Projected from current hourly rate — not historical spend">Monthly*</span>
181
- <span className="text-right cursor-help" title="% of reserved resources actually being used, weighted by cost">Efficiency</span>
181
+ <Tooltip content="Projected from current hourly rate — not historical spend" wrapperClassName="!block text-right">
182
+ <span className="cursor-help">Monthly*</span>
183
+ </Tooltip>
184
+ <Tooltip content="% of reserved resources actually being used, weighted by cost" wrapperClassName="!block text-right">
185
+ <span className="cursor-help">Efficiency</span>
186
+ </Tooltip>
182
187
  <span>CPU / Memory</span>
183
188
  <span className="text-right">Cost Split</span>
184
189
  </div>
@@ -368,7 +373,9 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
368
373
  <span>Node</span>
369
374
  <span>Instance Type</span>
370
375
  <span className="text-right">Hourly</span>
371
- <span className="text-right cursor-help" title="Projected from current hourly rate — not historical spend">Monthly*</span>
376
+ <Tooltip content="Projected from current hourly rate — not historical spend" wrapperClassName="!block text-right">
377
+ <span className="cursor-help">Monthly*</span>
378
+ </Tooltip>
372
379
  <span className="text-right">CPU / Memory</span>
373
380
  </div>
374
381
 
@@ -387,9 +394,11 @@ function NodeCostRow({ node }: { node: OpenCostNodeCost }) {
387
394
 
388
395
  return (
389
396
  <div className="grid grid-cols-[minmax(200px,1fr)_minmax(120px,1fr)_90px_100px_140px] gap-2 px-4 py-2.5">
390
- <span className="text-sm text-theme-text-primary truncate font-medium" title={node.name}>
397
+ <Tooltip content={node.name} wrapperClassName="!block min-w-0">
398
+ <span className="text-sm text-theme-text-primary truncate font-medium block">
391
399
  {node.name}
392
400
  </span>
401
+ </Tooltip>
393
402
  <span className="text-xs text-theme-text-secondary truncate">
394
403
  {node.instanceType || '-'}
395
404
  {node.region && <span className="text-theme-text-quaternary ml-1.5">({node.region})</span>}