@skyhook-io/radar-app 1.7.0 → 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 (35) hide show
  1. package/package.json +8 -2
  2. package/src/App.tsx +58 -30
  3. package/src/components/ConnectionErrorView.tsx +5 -2
  4. package/src/components/DebugOverlay.tsx +4 -2
  5. package/src/components/UserMenu.tsx +5 -2
  6. package/src/components/audit/AuditSettingsDialog.tsx +14 -10
  7. package/src/components/audit/AuditView.tsx +3 -1
  8. package/src/components/compare/useCompareLauncher.tsx +6 -3
  9. package/src/components/cost/CostView.tsx +13 -4
  10. package/src/components/helm/ChartBrowser.tsx +6 -4
  11. package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
  12. package/src/components/helm/HelmView.tsx +3 -2
  13. package/src/components/helm/InstallWizard.tsx +3 -2
  14. package/src/components/helm/OwnedResources.tsx +24 -13
  15. package/src/components/helm/RevisionHistory.tsx +2 -1
  16. package/src/components/helm/ValuesViewer.tsx +4 -2
  17. package/src/components/home/ClusterHealthCard.tsx +21 -18
  18. package/src/components/home/HelmSummary.tsx +3 -1
  19. package/src/components/home/MCPSetupDialog.tsx +10 -4
  20. package/src/components/nav/PrimaryNavRail.tsx +5 -2
  21. package/src/components/portforward/PortForwardButton.tsx +14 -8
  22. package/src/components/resource/PrometheusChartsGrid.tsx +9 -2
  23. package/src/components/resource/RestartChart.tsx +6 -5
  24. package/src/components/resource/RightsizingStrip.tsx +4 -1
  25. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  26. package/src/components/resources/ImageFilesystemModal.tsx +16 -8
  27. package/src/components/resources/PodFilesystemModal.tsx +4 -2
  28. package/src/components/settings/SettingsDialog.tsx +371 -141
  29. package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
  30. package/src/components/traffic/TrafficFlowList.tsx +13 -4
  31. package/src/components/traffic/TrafficGraph.tsx +8 -3
  32. package/src/components/traffic/TrafficView.tsx +4 -2
  33. package/src/components/ui/Omnibar.tsx +0 -1
  34. package/src/components/workload/WorkloadView.tsx +15 -10
  35. package/src/context/NavCustomization.tsx +19 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.7.0",
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
@@ -179,6 +179,32 @@ function AppInner() {
179
179
  const capabilities = useCapabilitiesContext()
180
180
  const openLocalTerminal = useOpenLocalTerminal()
181
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
+ )
182
208
  const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
183
209
  // Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
184
210
  // the left chrome via their own fleet rail and keep Radar's top-bar pills.
@@ -258,6 +284,20 @@ function AppInner() {
258
284
 
259
285
  // Set mainView by navigating to the path
260
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
+
261
301
  const path = view === 'home' ? '/' : `/${view}`
262
302
 
263
303
  // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
@@ -275,32 +315,15 @@ function AppInner() {
275
315
  }
276
316
 
277
317
  navigate({ pathname: path, search: newParams.toString() })
278
- }, [navigate, searchParams])
318
+ }, [navigate, searchParams, takeover, goHost])
279
319
 
280
320
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
281
321
  // own fleet pages scoped to this cluster — owned by the host's left rail — so
282
- // Radar drops the matching pills (see the nav below) and any route into one
283
- // of these views redirects to the host. Entry points that still land on the
284
- // view Home cards, ⌘K, WorkloadView's "view all" findings, bookmarks/deep
285
- // links all funnel through here. The view name doubles as the takeover
286
- // target ('issues' | 'gitops' | 'checks'; 'audit' normalizes to 'checks' via
287
- // getViewFromPath). `replace` (not assign) keeps the transient /<view> URL
288
- // out of history so Back doesn't bounce off the redirect. Standalone OSS (no
289
- // fleetTakeoverHref) is unaffected and renders the in-app view as before.
290
- // Resolve every takeover target's host URL ONCE per render so the redirect
291
- // effect, nav-pill filtering, inline-view gating, and the cert click handler
292
- // all close over the SAME value — host callbacks aren't guaranteed idempotent
293
- // (scope / flags / signed URLs can shift between calls). undefined = not taken
294
- // over → Radar renders the view itself. `clusterChecksHref` is the deprecated
295
- // pre-1.7 hook, folded into the 'checks' target for back-compat.
296
- const fleetTakeoverHref = navCustomization.fleetTakeoverHref
297
- const clusterChecksHref = navCustomization.clusterChecksHref
298
- const takeover: Record<FleetTakeoverTarget, string | undefined> = {
299
- issues: fleetTakeoverHref?.('issues'),
300
- gitops: fleetTakeoverHref?.('gitops'),
301
- checks: fleetTakeoverHref?.('checks') ?? clusterChecksHref?.(),
302
- certs: fleetTakeoverHref?.('certs'),
303
- }
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
+ //
304
327
  // Has the host claimed this view? View-shaped targets only ('certs' has no
305
328
  // Radar view — only its Home card consults `takeover`). Used to drop the nav
306
329
  // pill and gate the inline view render in favor of the "Opening…" splash.
@@ -1307,14 +1330,15 @@ function AppInner() {
1307
1330
  </span>
1308
1331
  )}
1309
1332
  {!connected && (
1333
+ <Tooltip content="Reconnect">
1310
1334
  <button
1311
1335
  onClick={reconnect}
1312
1336
  disabled={isReconnecting}
1313
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
1314
- title="Reconnect"
1337
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1315
1338
  >
1316
1339
  <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
1317
1340
  </button>
1341
+ </Tooltip>
1318
1342
  )}
1319
1343
  </div>
1320
1344
  {/* Port forwards indicator — shown only when sessions exist */}
@@ -1433,13 +1457,14 @@ function AppInner() {
1433
1457
 
1434
1458
  {/* Local terminal */}
1435
1459
  {capabilities.localTerminal && (
1460
+ <Tooltip content="Open local terminal">
1436
1461
  <button
1437
1462
  onClick={() => openLocalTerminal()}
1438
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"
1439
- title="Open local terminal"
1440
1464
  >
1441
1465
  <SquareTerminal className="w-4 h-4" />
1442
1466
  </button>
1467
+ </Tooltip>
1443
1468
  )}
1444
1469
 
1445
1470
  {/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
@@ -1459,20 +1484,22 @@ function AppInner() {
1459
1484
  old floating bottom-right pair. Settings moved to the rail bottom. */}
1460
1485
  {showNavRail && (
1461
1486
  <>
1487
+ <Tooltip content="Keyboard shortcuts (?)">
1462
1488
  <button
1463
1489
  onClick={() => setShowHelp(true)}
1464
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"
1465
- title="Keyboard shortcuts (?)"
1466
1491
  >
1467
1492
  <HelpCircle className="w-4 h-4" />
1468
1493
  </button>
1494
+ </Tooltip>
1495
+ <Tooltip content="Report a bug / Diagnostics">
1469
1496
  <button
1470
1497
  onClick={() => setShowDiagnostics(true)}
1471
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"
1472
- title="Report a bug / Diagnostics"
1473
1499
  >
1474
1500
  <Bug className="w-4 h-4" />
1475
1501
  </button>
1502
+ </Tooltip>
1476
1503
  </>
1477
1504
  )}
1478
1505
 
@@ -1641,7 +1668,7 @@ function AppInner() {
1641
1668
  // belongs in history. Omitted → the card falls back to Radar's own
1642
1669
  // TLS-secrets resource list.
1643
1670
  onNavigateToCerts={
1644
- takeover.certs ? () => window.location.assign(takeover.certs!) : undefined
1671
+ takeover.certs ? () => goHost(takeover.certs!) : undefined
1645
1672
  }
1646
1673
  />
1647
1674
  )}
@@ -2243,10 +2270,10 @@ function ThemeToggle() {
2243
2270
  const { theme, toggleTheme } = useTheme()
2244
2271
 
2245
2272
  return (
2273
+ <Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
2246
2274
  <button
2247
2275
  onClick={toggleTheme}
2248
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"
2249
- title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
2250
2277
  >
2251
2278
  {theme === 'dark' ? (
2252
2279
  <Sun className="w-4 h-4" />
@@ -2254,6 +2281,7 @@ function ThemeToggle() {
2254
2281
  <Moon className="w-4 h-4" />
2255
2282
  )}
2256
2283
  </button>
2284
+ </Tooltip>
2257
2285
  )
2258
2286
  }
2259
2287
 
@@ -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>}
@@ -316,15 +316,16 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
316
316
  ) : (
317
317
  <div className="text-sm mt-1 flex flex-col items-center gap-2">
318
318
  <p>Your repositories may be out of date.</p>
319
+ <Tooltip content={canHelmWrite ? 'Run helm repo update on every configured repository' : helmWriteReason}>
319
320
  <button
320
321
  onClick={handleUpdateAllRepos}
321
322
  disabled={isBatchUpdating || !canHelmWrite}
322
- className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs btn-brand rounded disabled:opacity-50"
323
- title={canHelmWrite ? 'Run helm repo update on every configured repository' : helmWriteReason}
323
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs btn-brand rounded disabled:opacity-50 disabled:pointer-events-none"
324
324
  >
325
325
  <RefreshCw className={`w-3.5 h-3.5 ${isBatchUpdating ? 'animate-spin' : ''}`} />
326
326
  {isBatchUpdating ? 'Updating…' : 'Update all repositories'}
327
327
  </button>
328
+ </Tooltip>
328
329
  <button
329
330
  onClick={() => setChartSource('artifacthub')}
330
331
  className="text-xs text-blue-400 hover:underline"
@@ -436,14 +437,15 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
436
437
  </span>
437
438
  )}
438
439
  </button>
440
+ <Tooltip content={canUpdate ? "Update repository" : (cantUpdateReason ?? "Helm write permissions required")}>
439
441
  <button
440
442
  onClick={(e) => { e.stopPropagation(); onUpdate() }}
441
443
  disabled={isUpdating || !canUpdate}
442
- className="p-1 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
443
- title={canUpdate ? "Update repository" : (cantUpdateReason ?? "Helm write permissions required")}
444
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:pointer-events-none"
444
445
  >
445
446
  <RefreshCw className={clsx('w-3.5 h-3.5', isUpdating && 'animate-spin')} />
446
447
  </button>
448
+ </Tooltip>
447
449
  </div>
448
450
  )
449
451
  }
@@ -10,6 +10,7 @@ import { clsx } from 'clsx'
10
10
  import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
11
11
  import { useQueryClient } from '@tanstack/react-query'
12
12
  import { ConfirmDialog } from '../ui/ConfirmDialog'
13
+ import { Tooltip } from '../ui/Tooltip'
13
14
  import { Markdown } from '../ui/Markdown'
14
15
  import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types'
15
16
  import type { NavigateToResource } from '../../utils/navigation'
@@ -334,63 +335,72 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
334
335
  checking...
335
336
  </span>
336
337
  ) : upgradeError ? (
338
+ <Tooltip content={upgradeErrorMessage ?? ''}>
337
339
  <span
338
340
  className="badge bg-theme-hover/50 text-theme-text-secondary"
339
- title={upgradeErrorMessage}
340
341
  >
341
342
  upgrade check failed
342
343
  </span>
344
+ </Tooltip>
343
345
  ) : upgradeInfo?.updateAvailable ? (
346
+ <Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}>
344
347
  <button
345
348
  onClick={() => setShowUpgradeConfirm(true)}
346
349
  disabled={!canHelmWrite}
347
350
  className={clsx(
348
- 'badge transition-colors', SEVERITY_BADGE.warning,
351
+ 'badge transition-colors disabled:pointer-events-none', SEVERITY_BADGE.warning,
349
352
  canHelmWrite ? 'hover:bg-amber-500/30 cursor-pointer' : 'opacity-50 cursor-not-allowed'
350
353
  )}
351
- title={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}
352
354
  >
353
355
  <ArrowUpCircle className="w-3 h-3" />
354
356
  {upgradeInfo.latestVersion}
355
357
  </button>
358
+ </Tooltip>
356
359
  ) : upgradeInfo && !upgradeInfo.error ? (
357
- <span className={clsx('badge', SEVERITY_BADGE.success)} title="Chart is up to date">
360
+ <Tooltip content="Chart is up to date">
361
+ <span className={clsx('badge', SEVERITY_BADGE.success)}>
358
362
  latest
359
363
  </span>
364
+ </Tooltip>
360
365
  ) : upgradeInfo?.error ? (
366
+ <Tooltip content={upgradeInfo.error}>
361
367
  <span
362
368
  className="badge bg-theme-hover/50 text-theme-text-secondary"
363
- title={upgradeInfo.error}
364
369
  >
365
370
  upstream unknown
366
371
  </span>
372
+ </Tooltip>
367
373
  ) : null}
368
374
  </div>
369
375
  <div className="flex items-center gap-1">
376
+ <Tooltip content="Refresh">
370
377
  <button
371
378
  onClick={refetch}
372
379
  disabled={isRefreshAnimating}
373
- className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
374
- title="Refresh"
380
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
375
381
  >
376
382
  <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
377
383
  </button>
384
+ </Tooltip>
385
+ <Tooltip content={canHelmWrite ? 'Uninstall release' : helmActReason}>
378
386
  <button
379
387
  onClick={() => setShowUninstallConfirm(true)}
380
388
  disabled={!canHelmWrite}
381
389
  className={clsx(
382
- 'p-1.5 rounded',
390
+ 'p-1.5 rounded disabled:pointer-events-none',
383
391
  canHelmWrite
384
392
  ? 'text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10'
385
393
  : 'text-theme-text-disabled cursor-not-allowed'
386
394
  )}
387
- title={canHelmWrite ? 'Uninstall release' : helmActReason}
388
395
  >
389
396
  <Trash2 className="w-4 h-4" />
390
397
  </button>
391
- <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" title="Close (Esc)">
398
+ </Tooltip>
399
+ <Tooltip content="Close (Esc)">
400
+ <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded">
392
401
  <X className="w-4 h-4" />
393
402
  </button>
403
+ </Tooltip>
394
404
  </div>
395
405
  </div>
396
406
 
@@ -399,28 +409,30 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
399
409
  <div className="flex items-center gap-2">
400
410
  <Package className="w-5 h-5 text-purple-400" />
401
411
  <h2 className="text-lg font-semibold text-theme-text-primary truncate">{release.name}</h2>
412
+ <Tooltip content="Copy name" wrapperClassName="shrink-0">
402
413
  <button
403
414
  onClick={() => copyToClipboard(release.name, 'name')}
404
415
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
405
- title="Copy name"
406
416
  >
407
417
  {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
408
418
  </button>
419
+ </Tooltip>
409
420
  </div>
410
421
  <p className="text-sm text-theme-text-tertiary">{release.namespace}</p>
411
422
  {releaseDetail?.managedByFluxHelmRelease && (
423
+ <Tooltip content={`Installed by Flux helm-controller via HelmRelease ${releaseDetail.managedByFluxHelmRelease}. Changes here would be reverted at the next reconcile.`} wrapperClassName="mt-1">
412
424
  <button
413
425
  type="button"
414
426
  onClick={() => {
415
427
  const [ns, name] = releaseDetail.managedByFluxHelmRelease!.split('/')
416
428
  navigate(`/gitops/detail/helmreleases/${encodeURIComponent(ns || '_')}/${encodeURIComponent(name)}`)
417
429
  }}
418
- className="mt-1 inline-flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[11px] text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 transition-colors"
419
- title={`Installed by Flux helm-controller via HelmRelease ${releaseDetail.managedByFluxHelmRelease}. Changes here would be reverted at the next reconcile.`}
430
+ className="inline-flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[11px] text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 transition-colors"
420
431
  >
421
432
  <GitBranch className="w-3 h-3" />
422
433
  Managed by Flux · {releaseDetail.managedByFluxHelmRelease}
423
434
  </button>
435
+ </Tooltip>
424
436
  )}
425
437
  </div>
426
438
 
@@ -226,14 +226,15 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
226
226
  className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
227
227
  />
228
228
  </div>
229
+ <Tooltip content="Refresh">
229
230
  <button
230
231
  onClick={handleRefresh}
231
232
  disabled={isRefreshAnimating}
232
- className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50"
233
- title="Refresh"
233
+ className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50 disabled:pointer-events-none"
234
234
  >
235
235
  <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
236
236
  </button>
237
+ </Tooltip>
237
238
  </div>
238
239
 
239
240
  {/* Releases Table */}