@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
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",
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="fixed inset-0 pointer-events-none">
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 (vw/2 − 22) — matches the Connecting/Opening splashes;
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}${new URLSearchParams(search).get('app') ?? ''}`
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
- // From the Issues queue: a GitOps reconciler subject (Argo Application / Flux
539
- // Kustomization / HelmRelease) routes to its rich detail page (tree + insights
540
- // + ops), not the generic resource drawer that's a dead-end for it. Member
541
- // resources (Pods, Services, …) fall through to the standard resource view.
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 viewport-anchored so its screen position matches the
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 viewport-center. The label block
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="fixed inset-0 pointer-events-none">
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 (vw/2 − 22) — avoids sub-pixel jitter from
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 viewport-anchored, label below. */}
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="fixed inset-0 pointer-events-none">
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 (vw/2 − 22) — avoids sub-pixel jitter from
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
- {!isSwitching && !authMePending && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected' && <div className="flex-1 flex overflow-hidden">
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={filteredTopology}
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={(ns, name, storageNamespace) => {
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="fixed inset-0 pointer-events-none">
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
- {resourceDrawer.shouldRender && drawerResource && (
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 = allValues ? '?all=true' : ''
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${params}`),
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 timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
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('Namespace switch timed out. The cluster may be unreachable.', { cause: 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: () => {