@skyhook-io/radar-app 1.8.2 → 1.8.3
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 +1 -1
- package/src/App.tsx +164 -54
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +112 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +575 -31
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { useRefreshAnimation } from './hooks/useRefreshAnimation'
|
|
4
4
|
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
5
|
+
import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
|
|
5
6
|
import { useQueryClient } from '@tanstack/react-query'
|
|
6
7
|
import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
|
|
7
8
|
import { HomeView } from './components/home/HomeView'
|
|
@@ -44,10 +45,12 @@ import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
|
44
45
|
import { CommandPalette } from './components/ui/CommandPalette'
|
|
45
46
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
46
47
|
import { useEventSource } from './hooks/useEventSource'
|
|
47
|
-
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
|
|
48
|
+
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
49
|
+
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
48
50
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
49
51
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
50
52
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
53
|
+
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
51
54
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
52
55
|
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
|
|
53
56
|
import { useTheme } from './context/ThemeContext'
|
|
@@ -56,7 +59,7 @@ import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNam
|
|
|
56
59
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
57
60
|
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
58
61
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
59
|
-
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
62
|
+
import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
60
63
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
61
64
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
62
65
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
@@ -123,6 +126,54 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
123
126
|
return 'home'
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
// Browser tab label for every Radar view, derived from the route URL so it's
|
|
130
|
+
// correct regardless of which component renders it. A detail drawer that opens
|
|
131
|
+
// over a list (?resource=…) is deliberately NOT titled — it's the same page, so
|
|
132
|
+
// it keeps the list's title.
|
|
133
|
+
function radarPageTitle(pathname: string, search = '', apiResources?: { name: string; kind: string; group?: string }[]): string | null {
|
|
134
|
+
const decode = (s: string) => {
|
|
135
|
+
try {
|
|
136
|
+
return decodeURIComponent(s)
|
|
137
|
+
} catch {
|
|
138
|
+
return s
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const capitalize = (text: string) =>
|
|
142
|
+
text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
|
143
|
+
const pluralKindTitle = (kind: string, resourceName: string) =>
|
|
144
|
+
kind.toLowerCase() === resourceName.toLowerCase() ? kind : englishPlural(kind)
|
|
145
|
+
const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
|
|
146
|
+
const view = getViewFromPath(pathname)
|
|
147
|
+
|
|
148
|
+
// Full-page resource detail: /workload/<kind>/<ns>/<name> (name may contain '/').
|
|
149
|
+
if (view === 'workload') return pathSegments.slice(3).map(decode).join('/') || null
|
|
150
|
+
// Resources is browsed per-kind: /resources/<kind> → "<Kind>" (e.g. ConfigMap);
|
|
151
|
+
// bare /resources (before it redirects to a default kind) → "Resources".
|
|
152
|
+
if (view === 'resources') {
|
|
153
|
+
const resourceName = decode(pathSegments[1] ?? '')
|
|
154
|
+
if (!resourceName) return 'Resources'
|
|
155
|
+
const match = apiResources?.find((r) => r.name === resourceName)
|
|
156
|
+
return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
|
|
157
|
+
}
|
|
158
|
+
// GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
|
|
159
|
+
// anything else (the list) → "GitOps".
|
|
160
|
+
if (view === 'gitops')
|
|
161
|
+
return pathSegments[1] === 'detail' ? decode(pathSegments[4] ?? '') || 'GitOps' : 'GitOps'
|
|
162
|
+
if (view === 'applications') {
|
|
163
|
+
const appKey = new URLSearchParams(search).get('app')
|
|
164
|
+
if (!appKey) return 'Applications'
|
|
165
|
+
const decoded = decode(appKey)
|
|
166
|
+
const slash = decoded.lastIndexOf('/')
|
|
167
|
+
return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
171
|
+
if (view === 'home') return 'Overview'
|
|
172
|
+
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
173
|
+
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
174
|
+
return capitalize(view)
|
|
175
|
+
}
|
|
176
|
+
|
|
126
177
|
function AuthBarrier({ authMode }: { authMode: string }) {
|
|
127
178
|
useEffect(() => {
|
|
128
179
|
if (authMode === 'oidc') {
|
|
@@ -133,12 +184,12 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
133
184
|
if (authMode === 'oidc') {
|
|
134
185
|
return (
|
|
135
186
|
<div className="flex-1 relative bg-theme-base">
|
|
136
|
-
<div className="
|
|
187
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
137
188
|
<img
|
|
138
189
|
src={radarLoadingIcon}
|
|
139
190
|
alt=""
|
|
140
191
|
aria-hidden
|
|
141
|
-
// Integer offset (
|
|
192
|
+
// Integer offset (50% − 22) — matches the Connecting/Opening splashes;
|
|
142
193
|
// avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
|
|
143
194
|
className="absolute w-11 h-11"
|
|
144
195
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -182,10 +233,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
182
233
|
// detail to list would otherwise leave the peek orphaned. Only `app` is included
|
|
183
234
|
// (not the whole query) so filter/tab/namespace churn doesn't close the peek.
|
|
184
235
|
function peekOwnerKey(pathname: string, search: string): string {
|
|
185
|
-
return `${pathname}
|
|
236
|
+
return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
|
|
186
237
|
}
|
|
187
238
|
|
|
188
|
-
function AppInner() {
|
|
239
|
+
function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
|
|
189
240
|
const navigate = useNavigate()
|
|
190
241
|
const location = useLocation()
|
|
191
242
|
const navigationType = useNavigationType()
|
|
@@ -279,6 +330,18 @@ function AppInner() {
|
|
|
279
330
|
// Get mainView from URL path
|
|
280
331
|
const mainView = getViewFromPath(location.pathname)
|
|
281
332
|
|
|
333
|
+
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
334
|
+
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
335
|
+
// view — kindToPlural would otherwise English-guess the route before a
|
|
336
|
+
// resources view has run initNavigationMap().
|
|
337
|
+
const { data: navApiResources } = useAPIResources()
|
|
338
|
+
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
339
|
+
|
|
340
|
+
// One URL-derived tab title for every view (see radarPageTitle). Driving it
|
|
341
|
+
// from the URL — not the mounted component. Off unless the host opts in
|
|
342
|
+
// (standalone passes manageDocumentTitle), so embedders keep title ownership.
|
|
343
|
+
useDocumentTitle(manageDocumentTitle ? radarPageTitle(location.pathname, location.search, navApiResources) : null, documentTitleSuffix)
|
|
344
|
+
|
|
282
345
|
// Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
|
|
283
346
|
const normalizedResourcesKindSlug = useMemo(() => {
|
|
284
347
|
const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
|
|
@@ -387,15 +450,6 @@ function AppInner() {
|
|
|
387
450
|
const [showSettings, setShowSettings] = useState(false)
|
|
388
451
|
const [showMyPermissions, setShowMyPermissions] = useState(false)
|
|
389
452
|
|
|
390
|
-
// Listen for desktop "open-settings" event from native menu
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
|
|
393
|
-
| { EventsOn?: (event: string, callback: () => void) => () => void }
|
|
394
|
-
| undefined
|
|
395
|
-
if (!wailsRuntime?.EventsOn) return
|
|
396
|
-
return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
|
|
397
|
-
}, [])
|
|
398
|
-
|
|
399
453
|
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
400
454
|
useEffect(() => {
|
|
401
455
|
const handler = () => setShowSettings(true)
|
|
@@ -535,11 +589,32 @@ function AppInner() {
|
|
|
535
589
|
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
536
590
|
}, [searchParams, navigate])
|
|
537
591
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
592
|
+
const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
|
|
593
|
+
const newParams = new URLSearchParams()
|
|
594
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
595
|
+
if (globalNamespaces) {
|
|
596
|
+
newParams.set('namespaces', globalNamespaces)
|
|
597
|
+
}
|
|
598
|
+
newParams.set('release', `${namespace}/${name}`)
|
|
599
|
+
if (storageNamespace) {
|
|
600
|
+
newParams.set('releaseStorage', storageNamespace)
|
|
601
|
+
}
|
|
602
|
+
setSelectedHelmRelease({ namespace, name, storageNamespace })
|
|
603
|
+
if (mainView === 'helm') {
|
|
604
|
+
setSearchParams(newParams, { replace: true })
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
navigate({ pathname: '/helm', search: newParams.toString() })
|
|
608
|
+
}, [mainView, searchParams, navigate, setSearchParams])
|
|
609
|
+
|
|
610
|
+
// From the Issues queue: special controller/manager subjects route to their
|
|
611
|
+
// rich detail pages, not the generic resource drawer that's a dead-end for
|
|
612
|
+
// them. Member resources (Pods, Services, …) fall through to resources.
|
|
542
613
|
const navigateFromIssue = useCallback((resource: SelectedResource) => {
|
|
614
|
+
if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
|
|
615
|
+
navigateToHelmRelease(resource.namespace, resource.name)
|
|
616
|
+
return
|
|
617
|
+
}
|
|
543
618
|
const gitOpsPath = gitOpsRouteForResource({
|
|
544
619
|
apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
|
|
545
620
|
kind: resource.kind,
|
|
@@ -550,7 +625,7 @@ function AppInner() {
|
|
|
550
625
|
return
|
|
551
626
|
}
|
|
552
627
|
navigateToResourceList(resource)
|
|
553
|
-
}, [navigate, navigateToResourceList])
|
|
628
|
+
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
554
629
|
|
|
555
630
|
// Collapse from expanded WorkloadView back to drawer
|
|
556
631
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
@@ -569,12 +644,6 @@ function AppInner() {
|
|
|
569
644
|
const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
|
|
570
645
|
const omnibarRef = useRef<OmnibarHandle>(null)
|
|
571
646
|
|
|
572
|
-
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
573
|
-
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
574
|
-
// view — kindToPlural would otherwise English-guess the route before a
|
|
575
|
-
// resources view has run n().
|
|
576
|
-
const { data: navApiResources } = useAPIResources()
|
|
577
|
-
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
578
647
|
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
579
648
|
|
|
580
649
|
// View switching keyboard shortcuts
|
|
@@ -724,6 +793,16 @@ function AppInner() {
|
|
|
724
793
|
// Connection state (for graceful startup)
|
|
725
794
|
const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
|
|
726
795
|
|
|
796
|
+
// The app's content surface is ready to show: auth resolved, not mid context-
|
|
797
|
+
// switch, and the cluster connection is live. The main content area gates on
|
|
798
|
+
// exactly this, and so do the overlay drawers — otherwise a deep-link/refresh
|
|
799
|
+
// with `?resource=`/`?release=` renders the drawer on top of the connecting/
|
|
800
|
+
// switching splash, pushing the centered loading logo off-center and showing an
|
|
801
|
+
// empty drawer over a not-yet-loaded view. Gating both on the SAME readiness so
|
|
802
|
+
// a drawer only ever sits over a real content surface.
|
|
803
|
+
const contentReady = !isSwitching && !authMePending &&
|
|
804
|
+
!(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
|
|
805
|
+
|
|
727
806
|
// Query client for cache invalidation
|
|
728
807
|
const queryClient = useQueryClient()
|
|
729
808
|
|
|
@@ -986,12 +1065,13 @@ function AppInner() {
|
|
|
986
1065
|
// mutation immediately, which re-introduces the same race. The state→URL
|
|
987
1066
|
// effect propagates state=[] → URL on its own after onSuccess flips state.
|
|
988
1067
|
const clearAllNamespaces = useCallback(() => {
|
|
1068
|
+
if (namespaceScope?.cacheScoped) return
|
|
989
1069
|
if (namespaces.length === 0) return
|
|
990
1070
|
setActiveNamespace.mutate(
|
|
991
1071
|
{ namespaces: [] },
|
|
992
1072
|
{ onSuccess: () => setNamespaces([]) },
|
|
993
1073
|
)
|
|
994
|
-
}, [namespaces.length, setActiveNamespace])
|
|
1074
|
+
}, [namespaceScope?.cacheScoped, namespaces.length, setActiveNamespace])
|
|
995
1075
|
const initialBookmarkReconciledRef = useRef(false)
|
|
996
1076
|
const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
|
|
997
1077
|
const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
|
|
@@ -1015,6 +1095,14 @@ function AppInner() {
|
|
|
1015
1095
|
if (!initialBookmarkReconciledRef.current) {
|
|
1016
1096
|
initialBookmarkReconciledRef.current = true
|
|
1017
1097
|
if (!sameAsState && sortedState.length > 0) {
|
|
1098
|
+
if (namespaceScope.cacheScoped && (!namespaceScope.namespaceRescope || sortedState.length !== 1)) {
|
|
1099
|
+
debugNamespaceLog('app:scope-mirror-cache-scope-preserve', {
|
|
1100
|
+
stateNamespaces: sortedState,
|
|
1101
|
+
scopeActives: sortedScope,
|
|
1102
|
+
})
|
|
1103
|
+
setNamespaces(scopeActives)
|
|
1104
|
+
return
|
|
1105
|
+
}
|
|
1018
1106
|
debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
|
|
1019
1107
|
stateNamespaces: sortedState,
|
|
1020
1108
|
scopeActives: sortedScope,
|
|
@@ -1117,6 +1205,12 @@ function AppInner() {
|
|
|
1117
1205
|
})
|
|
1118
1206
|
|
|
1119
1207
|
if (urlNamespaces.join(',') !== namespacesKey) {
|
|
1208
|
+
if (namespaceScope?.cacheScoped && (!namespaceScope.namespaceRescope || urlNamespaces.length !== 1)) {
|
|
1209
|
+
const scopedNamespaces = namespaceScope.actives ?? []
|
|
1210
|
+
debugNamespaceLog('app:url-sync-cache-scope-preserve', { scopedNamespaces })
|
|
1211
|
+
setNamespaces(scopedNamespaces)
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1120
1214
|
debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
|
|
1121
1215
|
setNamespaces(urlNamespaces)
|
|
1122
1216
|
if (namespaceScope) {
|
|
@@ -1263,6 +1357,29 @@ function AppInner() {
|
|
|
1263
1357
|
}
|
|
1264
1358
|
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
1265
1359
|
|
|
1360
|
+
// Cluster Audit findings, joined onto topology nodes by the audit key the
|
|
1361
|
+
// backend stamps on each node (data.auditKey). The graph surfaces DANGER only
|
|
1362
|
+
// (warnings would turn a dense graph into a heatmap); the node component reads
|
|
1363
|
+
// data.auditDanger. Re-runs only when findings change, and copies nodes only
|
|
1364
|
+
// when there are findings to attach — no overhead on clusters with none.
|
|
1365
|
+
const audit = useAudit(namespaces)
|
|
1366
|
+
const auditSeverityMap = useMemo(
|
|
1367
|
+
() => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
|
|
1368
|
+
[audit.data?.findings, audit.data?.checks],
|
|
1369
|
+
)
|
|
1370
|
+
const topologyWithAudit = useMemo((): Topology | null => {
|
|
1371
|
+
if (!filteredTopology) return null
|
|
1372
|
+
if (auditSeverityMap.size === 0) return filteredTopology
|
|
1373
|
+
return {
|
|
1374
|
+
...filteredTopology,
|
|
1375
|
+
nodes: filteredTopology.nodes.map(node => {
|
|
1376
|
+
const counts = auditSeverityMap.get(node.data.auditKey as string)
|
|
1377
|
+
if (!counts) return node
|
|
1378
|
+
return { ...node, data: { ...node.data, auditDanger: counts.danger, auditWarning: counts.warning, auditMessages: counts.messages } }
|
|
1379
|
+
}),
|
|
1380
|
+
}
|
|
1381
|
+
}, [filteredTopology, auditSeverityMap])
|
|
1382
|
+
|
|
1266
1383
|
// The graph node id of the currently open resource, used to highlight it on
|
|
1267
1384
|
// the canvas. Looked up from the topology (not reconstructed) because node
|
|
1268
1385
|
// ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
|
|
@@ -1579,20 +1696,20 @@ function AppInner() {
|
|
|
1579
1696
|
)}
|
|
1580
1697
|
|
|
1581
1698
|
{/* Connecting view — shown during initial connection or retry.
|
|
1582
|
-
Icon is
|
|
1699
|
+
Icon is pane-anchored so its screen position matches the
|
|
1583
1700
|
host hub splash across cross-document transitions. */}
|
|
1584
1701
|
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
1585
1702
|
<div className="flex-1 relative bg-theme-base">
|
|
1586
|
-
{/* Icon absolutely anchored to
|
|
1703
|
+
{/* Icon absolutely anchored to the pane center. The label block
|
|
1587
1704
|
sits at a fixed offset below — independent of label height
|
|
1588
1705
|
so multi-line messages (context + progress) don't shift the
|
|
1589
1706
|
icon's screen position. */}
|
|
1590
|
-
<div className="
|
|
1707
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1591
1708
|
<img
|
|
1592
1709
|
src={radarLoadingIcon}
|
|
1593
1710
|
alt=""
|
|
1594
1711
|
aria-hidden
|
|
1595
|
-
// Integer offset (
|
|
1712
|
+
// Integer offset (50% − 22) — avoids sub-pixel jitter from
|
|
1596
1713
|
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1597
1714
|
className="absolute w-11 h-11"
|
|
1598
1715
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -1620,15 +1737,15 @@ function AppInner() {
|
|
|
1620
1737
|
</div>
|
|
1621
1738
|
)}
|
|
1622
1739
|
|
|
1623
|
-
{/* Context switching overlay — icon
|
|
1740
|
+
{/* Context switching overlay — icon pane-anchored, label below. */}
|
|
1624
1741
|
{isSwitching && (
|
|
1625
1742
|
<div className="flex-1 relative bg-theme-base">
|
|
1626
|
-
<div className="
|
|
1743
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1627
1744
|
<img
|
|
1628
1745
|
src={radarLoadingIcon}
|
|
1629
1746
|
alt=""
|
|
1630
1747
|
aria-hidden
|
|
1631
|
-
// Integer offset (
|
|
1748
|
+
// Integer offset (50% − 22) — avoids sub-pixel jitter from
|
|
1632
1749
|
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1633
1750
|
className="absolute w-11 h-11"
|
|
1634
1751
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -1674,7 +1791,7 @@ function AppInner() {
|
|
|
1674
1791
|
)}
|
|
1675
1792
|
|
|
1676
1793
|
{/* Main content - only show when connected and authenticated */}
|
|
1677
|
-
{
|
|
1794
|
+
{contentReady && <div className="flex-1 flex overflow-hidden">
|
|
1678
1795
|
<ErrorBoundary>
|
|
1679
1796
|
{/* Home dashboard */}
|
|
1680
1797
|
{mainView === 'home' && (
|
|
@@ -1773,7 +1890,7 @@ function AppInner() {
|
|
|
1773
1890
|
|
|
1774
1891
|
<div className="flex-1 relative">
|
|
1775
1892
|
<TopologyGraph
|
|
1776
|
-
topology={
|
|
1893
|
+
topology={topologyWithAudit}
|
|
1777
1894
|
viewMode={topologyMode}
|
|
1778
1895
|
groupingMode={effectiveGroupingMode}
|
|
1779
1896
|
hideGroupHeader={hideGroupHeader}
|
|
@@ -1858,17 +1975,7 @@ function AppInner() {
|
|
|
1858
1975
|
<HelmView
|
|
1859
1976
|
namespaces={namespaces}
|
|
1860
1977
|
selectedRelease={selectedHelmRelease}
|
|
1861
|
-
onReleaseClick={
|
|
1862
|
-
setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
|
|
1863
|
-
const params = new URLSearchParams(window.location.search)
|
|
1864
|
-
params.set('release', `${ns}/${name}`)
|
|
1865
|
-
if (storageNamespace) {
|
|
1866
|
-
params.set('releaseStorage', storageNamespace)
|
|
1867
|
-
} else {
|
|
1868
|
-
params.delete('releaseStorage')
|
|
1869
|
-
}
|
|
1870
|
-
setSearchParams(params, { replace: true })
|
|
1871
|
-
}}
|
|
1978
|
+
onReleaseClick={navigateToHelmRelease}
|
|
1872
1979
|
/>
|
|
1873
1980
|
)}
|
|
1874
1981
|
|
|
@@ -1926,7 +2033,7 @@ function AppInner() {
|
|
|
1926
2033
|
<div className="flex-1 relative bg-theme-base">
|
|
1927
2034
|
{/* Viewport-anchored, 17px — identical to the "Connecting" splash so
|
|
1928
2035
|
the mark doesn't move or resize across the takeover hand-off. */}
|
|
1929
|
-
<div className="
|
|
2036
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1930
2037
|
<img
|
|
1931
2038
|
src={radarLoadingIcon}
|
|
1932
2039
|
alt=""
|
|
@@ -1982,8 +2089,10 @@ function AppInner() {
|
|
|
1982
2089
|
</ErrorBoundary>
|
|
1983
2090
|
</div>}
|
|
1984
2091
|
|
|
1985
|
-
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView
|
|
1986
|
-
|
|
2092
|
+
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
|
|
2093
|
+
Gated on contentReady so it never renders over the connecting/switching
|
|
2094
|
+
splash (which would push the centered logo off-center). */}
|
|
2095
|
+
{contentReady && resourceDrawer.shouldRender && drawerResource && (
|
|
1987
2096
|
<ResourceDetailDrawer
|
|
1988
2097
|
resource={drawerResource}
|
|
1989
2098
|
initialTab={drawerInitialTab}
|
|
@@ -2007,8 +2116,8 @@ function AppInner() {
|
|
|
2007
2116
|
/>
|
|
2008
2117
|
)}
|
|
2009
2118
|
|
|
2010
|
-
{/* Helm release drawer */}
|
|
2011
|
-
{helmDrawer.shouldRender && drawerHelmRelease && (
|
|
2119
|
+
{/* Helm release drawer — same contentReady gate as the resource drawer. */}
|
|
2120
|
+
{contentReady && helmDrawer.shouldRender && drawerHelmRelease && (
|
|
2012
2121
|
<HelmReleaseDrawer
|
|
2013
2122
|
release={drawerHelmRelease}
|
|
2014
2123
|
isOpen={helmDrawer.isOpen}
|
|
@@ -2081,6 +2190,7 @@ function AppInner() {
|
|
|
2081
2190
|
{ onSettled: () => setNamespaces([]) },
|
|
2082
2191
|
)}
|
|
2083
2192
|
onSetNamespaces={(ns) => {
|
|
2193
|
+
if (namespaceScope?.cacheScoped && ns.length !== 1) return
|
|
2084
2194
|
setNamespaces(ns)
|
|
2085
2195
|
setActiveNamespace.mutate({ namespaces: ns })
|
|
2086
2196
|
}}
|
|
@@ -2154,14 +2264,14 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
|
|
|
2154
2264
|
}
|
|
2155
2265
|
|
|
2156
2266
|
// Main App component wrapped with providers
|
|
2157
|
-
function App() {
|
|
2267
|
+
function App({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
|
|
2158
2268
|
return (
|
|
2159
2269
|
<ConnectionProvider>
|
|
2160
2270
|
<CapabilitiesProvider>
|
|
2161
2271
|
<ContextSwitchProvider>
|
|
2162
2272
|
<DockProvider>
|
|
2163
2273
|
<KeyboardShortcutProvider>
|
|
2164
|
-
<AppInner />
|
|
2274
|
+
<AppInner manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
|
|
2165
2275
|
</KeyboardShortcutProvider>
|
|
2166
2276
|
</DockProvider>
|
|
2167
2277
|
</ContextSwitchProvider>
|
package/src/RadarApp.tsx
CHANGED
|
@@ -70,6 +70,21 @@ export interface RadarAppProps {
|
|
|
70
70
|
* See ./context/NavCustomization for the slot shape.
|
|
71
71
|
*/
|
|
72
72
|
navSlots?: NavCustomization;
|
|
73
|
+
/**
|
|
74
|
+
* Whether Radar may set the browser tab title (`document.title`) per view.
|
|
75
|
+
* Defaults to OFF: embedders keep title ownership without opting out. The
|
|
76
|
+
* standalone binary opts in (`web/src/main.tsx` renders
|
|
77
|
+
* `<RadarApp manageDocumentTitle />`), and any full-page embed that wants
|
|
78
|
+
* Radar's per-view titles can do the same.
|
|
79
|
+
*/
|
|
80
|
+
manageDocumentTitle?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Trailing string appended after the per-view label (only when
|
|
83
|
+
* `manageDocumentTitle` is on). It's the *full* suffix including any
|
|
84
|
+
* separator, so a host can rebrand (`' — My Cloud'`) or drop it (`''`).
|
|
85
|
+
* Defaults to `' · Radar'`.
|
|
86
|
+
*/
|
|
87
|
+
documentTitleSuffix?: string;
|
|
73
88
|
/**
|
|
74
89
|
* Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
|
|
75
90
|
* deep-link a specific view (e.g. '/topology') without owning the URL bar —
|
|
@@ -116,6 +131,8 @@ export function RadarApp({
|
|
|
116
131
|
router = 'browser',
|
|
117
132
|
queryClient,
|
|
118
133
|
navSlots,
|
|
134
|
+
manageDocumentTitle = false,
|
|
135
|
+
documentTitleSuffix,
|
|
119
136
|
initialPath,
|
|
120
137
|
}: RadarAppProps): React.ReactElement {
|
|
121
138
|
// Apply runtime config during render so module-level singletons are set
|
|
@@ -136,7 +153,7 @@ export function RadarApp({
|
|
|
136
153
|
<QueryClientProvider client={client}>
|
|
137
154
|
<ToastProvider>
|
|
138
155
|
<NavCustomizationProvider value={navSlots}>
|
|
139
|
-
<App />
|
|
156
|
+
<App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
|
|
140
157
|
</NavCustomizationProvider>
|
|
141
158
|
</ToastProvider>
|
|
142
159
|
</QueryClientProvider>
|
package/src/api/client.ts
CHANGED
|
@@ -16,8 +16,11 @@ import type {
|
|
|
16
16
|
HelmReleaseDetail,
|
|
17
17
|
HelmValues,
|
|
18
18
|
ManifestDiff,
|
|
19
|
+
NotesDiff,
|
|
20
|
+
ResourceDiff,
|
|
19
21
|
UpgradeInfo,
|
|
20
22
|
BatchUpgradeInfo,
|
|
23
|
+
ValuesDiff,
|
|
21
24
|
ValuesPreviewResponse,
|
|
22
25
|
HelmRepository,
|
|
23
26
|
ChartSearchResult,
|
|
@@ -377,6 +380,28 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
|
|
|
377
380
|
})
|
|
378
381
|
}
|
|
379
382
|
|
|
383
|
+
// Live Issues that touch ONE resource — its own issues plus, for a workload, its
|
|
384
|
+
// owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
|
|
385
|
+
// the "Operational Issues" section in the resource detail. Cluster-scoped
|
|
386
|
+
// resources pass "_" for namespace; namespaced ones also scope the scan via
|
|
387
|
+
// ?namespaces= for a cheap, bounded Compose.
|
|
388
|
+
export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
|
|
389
|
+
const clusterScoped = !namespace
|
|
390
|
+
const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
|
|
391
|
+
const params = new URLSearchParams()
|
|
392
|
+
if (group) params.set('group', group)
|
|
393
|
+
const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
|
|
394
|
+
const qs = params.toString()
|
|
395
|
+
return useQuery<Issue[]>({
|
|
396
|
+
queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
|
|
397
|
+
queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
|
|
398
|
+
// No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
|
|
399
|
+
// on reopen without re-running an uncapped Compose every 30s.
|
|
400
|
+
staleTime: 30000,
|
|
401
|
+
enabled: enabled && !!kind && !!name,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
380
405
|
// Audit settings
|
|
381
406
|
export interface AuditSettings {
|
|
382
407
|
ignoredNamespaces: string[]
|
|
@@ -2358,11 +2383,14 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
|
|
|
2358
2383
|
}
|
|
2359
2384
|
|
|
2360
2385
|
// Get values for a Helm release. `enabled` see useHelmManifest.
|
|
2361
|
-
export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
|
|
2362
|
-
const params =
|
|
2386
|
+
export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
|
|
2387
|
+
const params = new URLSearchParams()
|
|
2388
|
+
if (allValues) params.set('all', 'true')
|
|
2389
|
+
if (revision && revision > 0) params.set('revision', String(revision))
|
|
2390
|
+
const query = params.toString() ? `?${params.toString()}` : ''
|
|
2363
2391
|
return useQuery<HelmValues>({
|
|
2364
|
-
queryKey: ['helm-values', namespace, name, allValues],
|
|
2365
|
-
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${
|
|
2392
|
+
queryKey: ['helm-values', namespace, name, allValues, revision],
|
|
2393
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
|
|
2366
2394
|
enabled: Boolean(namespace && name && enabled),
|
|
2367
2395
|
staleTime: 60000,
|
|
2368
2396
|
})
|
|
@@ -2385,6 +2413,61 @@ export function useHelmManifestDiff(
|
|
|
2385
2413
|
})
|
|
2386
2414
|
}
|
|
2387
2415
|
|
|
2416
|
+
export function useHelmValuesDiff(
|
|
2417
|
+
namespace: string,
|
|
2418
|
+
name: string,
|
|
2419
|
+
revision1: number,
|
|
2420
|
+
revision2: number,
|
|
2421
|
+
allValues = false,
|
|
2422
|
+
enabled = true,
|
|
2423
|
+
) {
|
|
2424
|
+
return useQuery<ValuesDiff>({
|
|
2425
|
+
queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
|
|
2426
|
+
queryFn: () => {
|
|
2427
|
+
const params = new URLSearchParams({
|
|
2428
|
+
revision1: String(revision1),
|
|
2429
|
+
revision2: String(revision2),
|
|
2430
|
+
})
|
|
2431
|
+
if (allValues) params.set('all', 'true')
|
|
2432
|
+
return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
|
|
2433
|
+
},
|
|
2434
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2435
|
+
staleTime: 60000,
|
|
2436
|
+
})
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
export function useHelmNotesDiff(
|
|
2440
|
+
namespace: string,
|
|
2441
|
+
name: string,
|
|
2442
|
+
revision1: number,
|
|
2443
|
+
revision2: number,
|
|
2444
|
+
enabled = true,
|
|
2445
|
+
) {
|
|
2446
|
+
return useQuery<NotesDiff>({
|
|
2447
|
+
queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
|
|
2448
|
+
queryFn: () =>
|
|
2449
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
2450
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2451
|
+
staleTime: 60000,
|
|
2452
|
+
})
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
export function useHelmResourceDiff(
|
|
2456
|
+
namespace: string,
|
|
2457
|
+
name: string,
|
|
2458
|
+
revision1: number,
|
|
2459
|
+
revision2: number,
|
|
2460
|
+
enabled = true,
|
|
2461
|
+
) {
|
|
2462
|
+
return useQuery<ResourceDiff>({
|
|
2463
|
+
queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
|
|
2464
|
+
queryFn: () =>
|
|
2465
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
2466
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2467
|
+
staleTime: 60000,
|
|
2468
|
+
})
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2388
2471
|
// Check for upgrade availability (lazy - called when drawer opens)
|
|
2389
2472
|
export function useHelmUpgradeInfo(namespace: string, name: string, enabled = true) {
|
|
2390
2473
|
return useQuery<UpgradeInfo>({
|
|
@@ -3147,6 +3230,11 @@ export interface NamespaceScope {
|
|
|
3147
3230
|
authoritative: boolean
|
|
3148
3231
|
/** false when clearing would leave no usable namespace fallback. */
|
|
3149
3232
|
canClearNamespace: boolean
|
|
3233
|
+
/** true when the backend informer cache is pinned to a namespace. */
|
|
3234
|
+
cacheScoped: boolean
|
|
3235
|
+
cacheScopeNamespace?: string
|
|
3236
|
+
/** true when this client may rebuild the local cache for another namespace. */
|
|
3237
|
+
namespaceRescope: boolean
|
|
3150
3238
|
}
|
|
3151
3239
|
|
|
3152
3240
|
export function useNamespaceScope() {
|
|
@@ -3158,6 +3246,7 @@ export function useNamespaceScope() {
|
|
|
3158
3246
|
}
|
|
3159
3247
|
|
|
3160
3248
|
const NAMESPACE_SWITCH_TIMEOUT = 5000
|
|
3249
|
+
const NAMESPACE_RESCOPE_TIMEOUT = 120000
|
|
3161
3250
|
|
|
3162
3251
|
export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
|
|
3163
3252
|
if (typeof window === 'undefined') return
|
|
@@ -3184,7 +3273,16 @@ export function useSetActiveNamespace() {
|
|
|
3184
3273
|
mutationFn: async ({ namespaces }) => {
|
|
3185
3274
|
debugNamespaceLog('mutation:start', { namespaces })
|
|
3186
3275
|
const controller = new AbortController()
|
|
3187
|
-
const
|
|
3276
|
+
const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
|
|
3277
|
+
// cacheScoped is a stable per-process property (the server's --namespace-scope
|
|
3278
|
+
// flag). If the scope query is missing/stale we can't yet tell a cheap
|
|
3279
|
+
// view-filter change from a cache-rebuilding rescope, so bias to the long
|
|
3280
|
+
// timeout — only a confirmed non-scoped session gets the fast switch timeout.
|
|
3281
|
+
// Aborting a real rebuild at 5s surfaces a spurious failure while the server
|
|
3282
|
+
// keeps going.
|
|
3283
|
+
const isRescope = currentScope?.cacheScoped !== false
|
|
3284
|
+
const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
|
|
3285
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
3188
3286
|
const startedAt = performance.now()
|
|
3189
3287
|
try {
|
|
3190
3288
|
const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
|
|
@@ -3212,7 +3310,9 @@ export function useSetActiveNamespace() {
|
|
|
3212
3310
|
error: error instanceof Error ? error.message : String(error),
|
|
3213
3311
|
})
|
|
3214
3312
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3215
|
-
throw new Error(
|
|
3313
|
+
throw new Error(isRescope
|
|
3314
|
+
? 'Namespace rescope timed out. The cluster may still be loading.'
|
|
3315
|
+
: 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3216
3316
|
}
|
|
3217
3317
|
throw error
|
|
3218
3318
|
}
|
|
@@ -3223,7 +3323,13 @@ export function useSetActiveNamespace() {
|
|
|
3223
3323
|
mode: scope.mode,
|
|
3224
3324
|
accessibleCount: scope.accessibleNamespaces.length,
|
|
3225
3325
|
})
|
|
3326
|
+
if (scope.cacheScoped) {
|
|
3327
|
+
queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
|
|
3328
|
+
}
|
|
3226
3329
|
queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
|
|
3330
|
+
if (scope.cacheScoped) {
|
|
3331
|
+
queryClient.invalidateQueries()
|
|
3332
|
+
}
|
|
3227
3333
|
debugNamespaceLog('mutation:success-after-scope-cache-write')
|
|
3228
3334
|
},
|
|
3229
3335
|
onError: () => {
|