@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.
- package/package.json +9 -3
- package/src/App.tsx +150 -44
- package/src/api/client.ts +3 -3
- package/src/components/ConnectionErrorView.tsx +5 -2
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/UserMenu.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +1 -1
- package/src/components/audit/AuditSettingsDialog.tsx +14 -10
- package/src/components/audit/AuditView.tsx +3 -1
- package/src/components/compare/CompareViewRoute.tsx +13 -5
- package/src/components/compare/useCompareLauncher.tsx +6 -3
- package/src/components/cost/CostTrendChart.tsx +3 -3
- package/src/components/cost/CostView.tsx +14 -5
- package/src/components/helm/ChartBrowser.tsx +7 -5
- package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
- package/src/components/helm/HelmView.tsx +3 -2
- package/src/components/helm/InstallWizard.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +24 -13
- package/src/components/helm/RevisionHistory.tsx +2 -1
- package/src/components/helm/ValuesViewer.tsx +4 -2
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +21 -18
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/MCPSetupDialog.tsx +10 -4
- package/src/components/issues/IssuesPane.tsx +2 -2
- package/src/components/nav/PrimaryNavRail.tsx +5 -2
- package/src/components/portforward/PortForwardButton.tsx +14 -8
- package/src/components/portforward/PortForwardManager.tsx +70 -8
- package/src/components/resource/PrometheusChartsGrid.tsx +10 -3
- package/src/components/resource/RestartChart.tsx +6 -5
- package/src/components/resource/RightsizingStrip.tsx +4 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/ImageFilesystemModal.tsx +16 -8
- package/src/components/resources/PodFilesystemModal.tsx +4 -2
- package/src/components/resources/ResourcesView.tsx +2 -0
- package/src/components/settings/MyPermissionsDialog.tsx +64 -4
- package/src/components/settings/SettingsDialog.tsx +371 -141
- package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
- package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
- package/src/components/traffic/TrafficFlowList.tsx +13 -4
- package/src/components/traffic/TrafficGraph.tsx +37 -23
- package/src/components/traffic/TrafficView.tsx +7 -5
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
- package/src/components/ui/Omnibar.tsx +0 -1
- package/src/components/workload/WorkloadView.tsx +32 -27
- package/src/context/NavCustomization.tsx +19 -5
- 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.
|
|
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
|
|
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": "^
|
|
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
|
-
|
|
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
|
-
<
|
|
144
|
-
className="absolute left-1/2 -translate-x-1/2
|
|
146
|
+
<div
|
|
147
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
145
148
|
style={{ top: 'calc(50% + 34px)' }}
|
|
146
149
|
>
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 ? () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
|
27
|
-
const
|
|
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
|
-
|
|
32
|
-
|
|
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)
|