@skyhook-io/radar-app 1.7.0 → 1.8.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 (47) hide show
  1. package/package.json +9 -3
  2. package/src/App.tsx +150 -44
  3. package/src/api/client.ts +3 -3
  4. package/src/components/ConnectionErrorView.tsx +5 -2
  5. package/src/components/DebugOverlay.tsx +5 -3
  6. package/src/components/UserMenu.tsx +5 -2
  7. package/src/components/applications/ApplicationsView.tsx +1 -1
  8. package/src/components/audit/AuditSettingsDialog.tsx +14 -10
  9. package/src/components/audit/AuditView.tsx +3 -1
  10. package/src/components/compare/CompareViewRoute.tsx +13 -5
  11. package/src/components/compare/useCompareLauncher.tsx +6 -3
  12. package/src/components/cost/CostTrendChart.tsx +3 -3
  13. package/src/components/cost/CostView.tsx +14 -5
  14. package/src/components/helm/ChartBrowser.tsx +7 -5
  15. package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
  16. package/src/components/helm/HelmView.tsx +3 -2
  17. package/src/components/helm/InstallWizard.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +24 -13
  19. package/src/components/helm/RevisionHistory.tsx +2 -1
  20. package/src/components/helm/ValuesViewer.tsx +4 -2
  21. package/src/components/home/ActivitySummary.tsx +4 -1
  22. package/src/components/home/ClusterHealthCard.tsx +21 -18
  23. package/src/components/home/HelmSummary.tsx +3 -1
  24. package/src/components/home/MCPSetupDialog.tsx +10 -4
  25. package/src/components/issues/IssuesPane.tsx +2 -2
  26. package/src/components/nav/PrimaryNavRail.tsx +5 -2
  27. package/src/components/portforward/PortForwardButton.tsx +14 -8
  28. package/src/components/portforward/PortForwardManager.tsx +70 -8
  29. package/src/components/resource/PrometheusChartsGrid.tsx +10 -3
  30. package/src/components/resource/RestartChart.tsx +6 -5
  31. package/src/components/resource/RightsizingStrip.tsx +4 -1
  32. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  33. package/src/components/resources/ImageFilesystemModal.tsx +16 -8
  34. package/src/components/resources/PodFilesystemModal.tsx +4 -2
  35. package/src/components/resources/ResourcesView.tsx +2 -0
  36. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  37. package/src/components/settings/SettingsDialog.tsx +371 -141
  38. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  39. package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
  40. package/src/components/traffic/TrafficFlowList.tsx +13 -4
  41. package/src/components/traffic/TrafficGraph.tsx +37 -23
  42. package/src/components/traffic/TrafficView.tsx +7 -5
  43. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  44. package/src/components/ui/Omnibar.tsx +0 -1
  45. package/src/components/workload/WorkloadView.tsx +32 -27
  46. package/src/context/NavCustomization.tsx +19 -5
  47. package/src/main.tsx +1 -1
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.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",
@@ -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,18 +51,23 @@
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",
57
58
  "@tailwindcss/vite": "^4.3.1",
58
59
  "@tanstack/react-query": "^5.100.14",
59
- "@types/node": "^25.9.3",
60
+ "@types/node": "^26.0.0",
60
61
  "@types/react": "^19.2.17",
61
62
  "@types/react-dom": "^19.2.3",
62
63
  "@vitejs/plugin-react": "^6.0.2",
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
@@ -138,14 +138,19 @@ function AuthBarrier({ authMode }: { authMode: string }) {
138
138
  src={radarLoadingIcon}
139
139
  alt=""
140
140
  aria-hidden
141
- className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11"
141
+ // Integer offset (vw/2 − 22) — matches the Connecting/Opening splashes;
142
+ // avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
143
+ className="absolute w-11 h-11"
144
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
142
145
  />
143
- <p
144
- className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary"
146
+ <div
147
+ className="absolute left-1/2 -translate-x-1/2 text-center"
145
148
  style={{ top: 'calc(50% + 34px)' }}
146
149
  >
147
- Redirecting to login…
148
- </p>
150
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
151
+ Redirecting to login…
152
+ </p>
153
+ </div>
149
154
  </div>
150
155
  </div>
151
156
  )
@@ -171,6 +176,15 @@ function AuthBarrier({ authMode }: { authMode: string }) {
171
176
  )
172
177
  }
173
178
 
179
+ // Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
180
+ // is not enough: Applications keeps the list and an app's detail on the same
181
+ // `/applications` pathname and distinguishes them with `?app=`, so a Back from
182
+ // detail to list would otherwise leave the peek orphaned. Only `app` is included
183
+ // (not the whole query) so filter/tab/namespace churn doesn't close the peek.
184
+ function peekOwnerKey(pathname: string, search: string): string {
185
+ return `${pathname}${new URLSearchParams(search).get('app') ?? ''}`
186
+ }
187
+
174
188
  function AppInner() {
175
189
  const navigate = useNavigate()
176
190
  const location = useLocation()
@@ -179,6 +193,32 @@ function AppInner() {
179
193
  const capabilities = useCapabilitiesContext()
180
194
  const openLocalTerminal = useOpenLocalTerminal()
181
195
  const navCustomization = useNavCustomization()
196
+ // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
197
+ // cross-tree swap) navigates same-document so the chrome morphs instead of
198
+ // cold-booting; without it we fall back to a hard `window.location` nav.
199
+ const goHost = useCallback(
200
+ (url: string) => {
201
+ if (navCustomization.onHostNavigate) navCustomization.onHostNavigate(url)
202
+ else window.location.assign(url)
203
+ },
204
+ [navCustomization],
205
+ )
206
+ // Resolve every host-takeover URL ONCE (memoized on navCustomization) so the
207
+ // setMainView intercept, redirect effect, nav-pill filtering, inline-view
208
+ // gating, and the cert click handler all consume the SAME value — host
209
+ // callbacks aren't guaranteed idempotent (scope / flags / signed URLs can
210
+ // shift between calls). undefined = not taken over → Radar renders the view
211
+ // itself. `clusterChecksHref` is the deprecated pre-1.7 hook, folded into the
212
+ // 'checks' target for back-compat.
213
+ const takeover: Record<FleetTakeoverTarget, string | undefined> = useMemo(
214
+ () => ({
215
+ issues: navCustomization.fleetTakeoverHref?.('issues'),
216
+ gitops: navCustomization.fleetTakeoverHref?.('gitops'),
217
+ checks: navCustomization.fleetTakeoverHref?.('checks') ?? navCustomization.clusterChecksHref?.(),
218
+ certs: navCustomization.fleetTakeoverHref?.('certs'),
219
+ }),
220
+ [navCustomization],
221
+ )
182
222
  const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
183
223
  // Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
184
224
  // the left chrome via their own fleet rail and keep Radar's top-bar pills.
@@ -258,6 +298,20 @@ function AppInner() {
258
298
 
259
299
  // Set mainView by navigating to the path
260
300
  const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
301
+ // Host takeover: fleet-shaped views (issues/gitops/checks) are owned by the
302
+ // host's fleet pages. Hand straight to the host instead of navigating to
303
+ // our own /<view> first — that intermediate hop mounts the view machinery
304
+ // and flashes the "Opening…" splash before the redirect effect bounces out.
305
+ // Skipping it makes the hand-off a single smooth cross-tree swap. (Direct
306
+ // /<view> URL entry still funnels through the redirect effect below.)
307
+ if (view === 'issues' || view === 'gitops' || view === 'checks') {
308
+ const href = takeover[view]
309
+ if (href) {
310
+ goHost(href)
311
+ return
312
+ }
313
+ }
314
+
261
315
  const path = view === 'home' ? '/' : `/${view}`
262
316
 
263
317
  // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
@@ -275,32 +329,15 @@ function AppInner() {
275
329
  }
276
330
 
277
331
  navigate({ pathname: path, search: newParams.toString() })
278
- }, [navigate, searchParams])
332
+ }, [navigate, searchParams, takeover, goHost])
279
333
 
280
334
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
281
335
  // 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
- }
336
+ // Radar drops the matching pills (see the nav below). In-app nav hands off in
337
+ // setMainView (above); direct /<view> URL entry funnels through the redirect
338
+ // effect below. Both consume the memoized `takeover` resolved above. Standalone
339
+ // OSS (no fleetTakeoverHref) is unaffected and renders the in-app view.
340
+ //
304
341
  // Has the host claimed this view? View-shaped targets only ('certs' has no
305
342
  // Radar view — only its Home card consults `takeover`). Used to drop the nav
306
343
  // pill and gate the inline view render in favor of the "Opening…" splash.
@@ -387,6 +424,9 @@ function AppInner() {
387
424
  // selected drawer resource. This covers both in-view kind switches and
388
425
  // cross-kind navigations from expanded drawers (for example Node -> View Pods).
389
426
  const prevResourcesKindKeyRef = useRef<string | null>(null)
427
+ // Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
428
+ // navigateToResource and peekOwnerKey.
429
+ const peekOwnerKeyRef = useRef<string | null>(null)
390
430
  const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
391
431
  const currentResourceGroup = searchParams.get('apiGroup') ?? ''
392
432
  const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
@@ -398,9 +438,28 @@ function AppInner() {
398
438
  const resourcesKindRouteChanged = mainView === 'resources' &&
399
439
  prevResourcesKindKeyRef.current !== null &&
400
440
  prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
401
- const routeSelectedResource = resourcesKindRouteChanged && selectedResourceRouteMismatch
402
- ? null
403
- : selectedResource
441
+
442
+ // A peek opened outside /resources (topology, GitOps, Applications) carries no
443
+ // URL backing, so the only signal that the page beneath it has navigated is
444
+ // that its owner-key (pathname + ?app) no longer matches where it was opened.
445
+ // Hiding it here, at render time, closes the orphan on Back without adding
446
+ // another clearing effect that would race the `suppressViewClearRef` lifecycle.
447
+ // The /resources case is URL-backed and handled above; an expanded drawer
448
+ // (drawerExpanded) legitimately lives at /workload and must stay open there.
449
+ const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
450
+ peekOwnerKeyRef.current !== null &&
451
+ peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
452
+
453
+ // In Applications the inline WorkloadView (?workload) and the peek drawer are
454
+ // mutually exclusive — never two detail surfaces at once. ?workload is the
455
+ // single source of truth: while it's set the peek yields to the inline view.
456
+ // (Opening a child peek from Applications clears ?workload, see onOpenResource.)
457
+ const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
458
+
459
+ const routeSelectedResource =
460
+ (resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
461
+ ? null
462
+ : selectedResource
404
463
 
405
464
  useEffect(() => {
406
465
  if (mainView !== 'resources') {
@@ -435,6 +494,13 @@ function AppInner() {
435
494
 
436
495
  // Navigate to a resource — uses View Transitions cross-fade when drawer is already open
437
496
  const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
497
+ // Record the page this peek was opened on. Outside /resources the drawer is
498
+ // not URL-backed, so this ref is what lets the render-time gate below close
499
+ // the peek when the page under it changes (e.g. browser Back off a GitOps
500
+ // detail page, or Applications detail → list via ?app). window.location is
501
+ // read (not the `location` closure) so the value is always current
502
+ // regardless of this callback's memoization.
503
+ peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
438
504
  const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
439
505
  // Skip the cross-fade animation entirely on first open (no
440
506
  // `selectedResource`); otherwise route through
@@ -897,7 +963,7 @@ function AppInner() {
897
963
  name: node.name,
898
964
  group: apiVersionToGroup(node.data.apiVersion as string | undefined),
899
965
  })
900
- }, [navigate])
966
+ }, [navigate, navigateToResource])
901
967
 
902
968
  // Serialize namespaces for stable dependency tracking
903
969
  const namespacesKey = namespaces.join(',')
@@ -1113,6 +1179,11 @@ function AppInner() {
1113
1179
  // Switching to specific namespaces - disable namespace grouping
1114
1180
  setGroupingMode('none')
1115
1181
  }
1182
+ // Intentionally runs ONLY when the namespace selection changes. It reads the
1183
+ // current groupingMode but must not re-run when grouping changes, or it would
1184
+ // immediately revert a manual/fleet grouping choice. namespacesKey is the
1185
+ // manual dependency standing in for the namespaces array.
1186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1116
1187
  }, [namespacesKey])
1117
1188
 
1118
1189
  // Clear resource selection when changing views or namespaces
@@ -1307,14 +1378,15 @@ function AppInner() {
1307
1378
  </span>
1308
1379
  )}
1309
1380
  {!connected && (
1381
+ <Tooltip content="Reconnect">
1310
1382
  <button
1311
1383
  onClick={reconnect}
1312
1384
  disabled={isReconnecting}
1313
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
1314
- title="Reconnect"
1385
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1315
1386
  >
1316
1387
  <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
1317
1388
  </button>
1389
+ </Tooltip>
1318
1390
  )}
1319
1391
  </div>
1320
1392
  {/* Port forwards indicator — shown only when sessions exist */}
@@ -1433,13 +1505,14 @@ function AppInner() {
1433
1505
 
1434
1506
  {/* Local terminal */}
1435
1507
  {capabilities.localTerminal && (
1508
+ <Tooltip content="Open local terminal">
1436
1509
  <button
1437
1510
  onClick={() => openLocalTerminal()}
1438
1511
  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
1512
  >
1441
1513
  <SquareTerminal className="w-4 h-4" />
1442
1514
  </button>
1515
+ </Tooltip>
1443
1516
  )}
1444
1517
 
1445
1518
  {/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
@@ -1459,20 +1532,22 @@ function AppInner() {
1459
1532
  old floating bottom-right pair. Settings moved to the rail bottom. */}
1460
1533
  {showNavRail && (
1461
1534
  <>
1535
+ <Tooltip content="Keyboard shortcuts (?)">
1462
1536
  <button
1463
1537
  onClick={() => setShowHelp(true)}
1464
1538
  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
1539
  >
1467
1540
  <HelpCircle className="w-4 h-4" />
1468
1541
  </button>
1542
+ </Tooltip>
1543
+ <Tooltip content="Report a bug / Diagnostics">
1469
1544
  <button
1470
1545
  onClick={() => setShowDiagnostics(true)}
1471
1546
  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
1547
  >
1474
1548
  <Bug className="w-4 h-4" />
1475
1549
  </button>
1550
+ </Tooltip>
1476
1551
  </>
1477
1552
  )}
1478
1553
 
@@ -1641,7 +1716,7 @@ function AppInner() {
1641
1716
  // belongs in history. Omitted → the card falls back to Radar's own
1642
1717
  // TLS-secrets resource list.
1643
1718
  onNavigateToCerts={
1644
- takeover.certs ? () => window.location.assign(takeover.certs!) : undefined
1719
+ takeover.certs ? () => goHost(takeover.certs!) : undefined
1645
1720
  }
1646
1721
  />
1647
1722
  )}
@@ -1802,7 +1877,10 @@ function AppInner() {
1802
1877
  <GitOpsView
1803
1878
  namespaces={namespaces}
1804
1879
  onOpenResource={(resource) => {
1805
- setSelectedResource(resource)
1880
+ // Route through navigateToResource so the peek records the page it
1881
+ // opened on — that's what lets Back off the GitOps detail page close
1882
+ // the drawer instead of orphaning it on the list.
1883
+ navigateToResource(resource)
1806
1884
  }}
1807
1885
  onClearNamespaces={clearAllNamespaces}
1808
1886
  />
@@ -1813,7 +1891,17 @@ function AppInner() {
1813
1891
  <ApplicationsView
1814
1892
  namespaces={namespaces}
1815
1893
  onOpenResource={(resource) => {
1816
- setSelectedResource(resource)
1894
+ // The peek and the inline WorkloadView are mutually exclusive: drop
1895
+ // the inline workload selection so the app graph (not a second
1896
+ // detail panel) sits behind the peek. Search-only change keeps the
1897
+ // pathname — and thus the peek's owner-path — intact.
1898
+ const params = new URLSearchParams(window.location.search)
1899
+ if (params.has('workload') || params.has('tab')) {
1900
+ params.delete('workload')
1901
+ params.delete('tab')
1902
+ navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
1903
+ }
1904
+ navigateToResource(resource)
1817
1905
  }}
1818
1906
  />
1819
1907
  )}
@@ -1834,9 +1922,26 @@ function AppInner() {
1834
1922
  own fetches) while the cross-document nav lands. Covers checks /
1835
1923
  issues / gitops with one block since only one view is active. */}
1836
1924
  {viewTakeoverHref && (
1837
- <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-theme-base">
1838
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1839
- <p className="text-sm text-theme-text-secondary">Opening…</p>
1925
+ <div className="flex-1 relative bg-theme-base">
1926
+ {/* Viewport-anchored, 17px — identical to the "Connecting" splash so
1927
+ the mark doesn't move or resize across the takeover hand-off. */}
1928
+ <div className="fixed inset-0 pointer-events-none">
1929
+ <img
1930
+ src={radarLoadingIcon}
1931
+ alt=""
1932
+ aria-hidden
1933
+ className="absolute w-11 h-11"
1934
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1935
+ />
1936
+ <div
1937
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1938
+ style={{ top: 'calc(50% + 34px)' }}
1939
+ >
1940
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
1941
+ Opening…
1942
+ </p>
1943
+ </div>
1944
+ </div>
1840
1945
  </div>
1841
1946
  )}
1842
1947
 
@@ -2243,10 +2348,10 @@ function ThemeToggle() {
2243
2348
  const { theme, toggleTheme } = useTheme()
2244
2349
 
2245
2350
  return (
2351
+ <Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
2246
2352
  <button
2247
2353
  onClick={toggleTheme}
2248
2354
  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
2355
  >
2251
2356
  {theme === 'dark' ? (
2252
2357
  <Sun className="w-4 h-4" />
@@ -2254,6 +2359,7 @@ function ThemeToggle() {
2254
2359
  <Moon className="w-4 h-4" />
2255
2360
  )}
2256
2361
  </button>
2362
+ </Tooltip>
2257
2363
  )
2258
2364
  }
2259
2365
 
package/src/api/client.ts CHANGED
@@ -1509,7 +1509,7 @@ export function useAutoPromConnect(): void {
1509
1509
  if (attemptedRef.current === context) return
1510
1510
  let cached: string | null = null
1511
1511
  try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
1512
- cached = null
1512
+ // keep the null fallback
1513
1513
  }
1514
1514
 
1515
1515
  attemptedRef.current = context
@@ -3048,7 +3048,7 @@ export function useSwitchContext() {
3048
3048
  } catch (error) {
3049
3049
  clearTimeout(timeoutId)
3050
3050
  if (error instanceof Error && error.name === 'AbortError') {
3051
- throw new Error('Context switch timed out. The cluster may be unreachable.')
3051
+ throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
3052
3052
  }
3053
3053
  throw error
3054
3054
  }
@@ -3151,7 +3151,7 @@ export function useSetActiveNamespace() {
3151
3151
  error: error instanceof Error ? error.message : String(error),
3152
3152
  })
3153
3153
  if (error instanceof Error && error.name === 'AbortError') {
3154
- throw new Error('Namespace switch timed out. The cluster may be unreachable.')
3154
+ throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
3155
3155
  }
3156
3156
  throw error
3157
3157
  }
@@ -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
 
@@ -86,7 +88,7 @@ export function DebugOverlay() {
86
88
  )}
87
89
  </>
88
90
  ) : (
89
- <span className="text-theme-text-tertiary">Loading...</span>
91
+ <span className="text-theme-text-tertiary">Loading…</span>
90
92
  )}
91
93
  </div>
92
94
  </div>
@@ -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) */}
@@ -28,7 +28,7 @@ interface ApplicationsViewProps {
28
28
 
29
29
  export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
30
30
  const query = useApplications(namespaces)
31
- const apps = query.data?.applications ?? []
31
+ const apps = useMemo(() => query.data?.applications ?? [], [query.data])
32
32
 
33
33
  // Which app is open lives in the URL (?app=<key>) so the detail view is
34
34
  // deep-linkable and the browser back button returns to the list. Opening or
@@ -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
  />
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useMemo, useState } from 'react'
2
2
  import { useNavigate, useSearchParams } from 'react-router-dom'
3
3
  import {
4
4
  ResourceCompareView,
@@ -23,13 +23,21 @@ export function CompareViewRoute() {
23
23
  // reserved for topology grouping mode and gets stripped by App.tsx's URL
24
24
  // sync on every non-topology view.
25
25
  const group = searchParams.get('apiGroup') ?? undefined
26
- const aParsed = parseRef(searchParams.get('a'))
27
- const bParsed = parseRef(searchParams.get('b'))
26
+ const aRaw = searchParams.get('a')
27
+ const bRaw = searchParams.get('b')
28
28
 
29
29
  const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
30
30
 
31
- const a: CompareResourceRef | null = aParsed ? { kind, namespace: aParsed.namespace, name: aParsed.name, group } : null
32
- const b: CompareResourceRef | null = bParsed ? { kind, namespace: bParsed.namespace, name: bParsed.name, group } : null
31
+ // Memoized so the refs keep a stable identity across renders otherwise the
32
+ // useCallbacks below (which depend on a/b) would be rebuilt every render.
33
+ const a: CompareResourceRef | null = useMemo(() => {
34
+ const p = parseRef(aRaw)
35
+ return p ? { kind, namespace: p.namespace, name: p.name, group } : null
36
+ }, [aRaw, kind, group])
37
+ const b: CompareResourceRef | null = useMemo(() => {
38
+ const p = parseRef(bRaw)
39
+ return p ? { kind, namespace: p.namespace, name: p.name, group } : null
40
+ }, [bRaw, kind, group])
33
41
 
34
42
  const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
35
43
  const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)