@skyhook-io/radar-app 1.16.1 → 1.16.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.
Files changed (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.16.1",
3
+ "version": "1.16.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",
@@ -36,13 +36,13 @@
36
36
  "diff": "^9.0.0",
37
37
  "monaco-editor": "0.55.1",
38
38
  "react-markdown": "^10.1.0",
39
- "react-virtuoso": "^4.18.13",
39
+ "react-virtuoso": "^4.18.16",
40
40
  "remark-gfm": "^4.0.1",
41
41
  "shiki": "^4.4.2",
42
42
  "yaml": "^2.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@skyhook-io/k8s-ui": ">=1.16.1",
45
+ "@skyhook-io/k8s-ui": ">=1.16.3",
46
46
  "@tanstack/react-query": ">=5",
47
47
  "@xyflow/react": ">=12.0.0",
48
48
  "clsx": ">=2",
@@ -73,14 +73,14 @@
73
73
  "globals": "^17.11.0",
74
74
  "lucide-react": "^1.48.0",
75
75
  "postcss": "^8.5.28",
76
- "prettier": "^3.9.8",
76
+ "prettier": "^3.9.9",
77
77
  "react": "^19.3.0",
78
78
  "react-dom": "^19.3.0",
79
79
  "react-router-dom": "^7.18.4",
80
80
  "tailwind-merge": "^3.7.0",
81
81
  "tailwindcss": "^4.3.3",
82
82
  "typescript": "^6.0.2",
83
- "typescript-eslint": "^8.70.0",
83
+ "typescript-eslint": "^8.71.0",
84
84
  "vite": "^8.3.1"
85
85
  },
86
86
  "sideEffects": [
package/src/App.tsx CHANGED
@@ -38,6 +38,9 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
38
38
  import { useNavCustomization } from './context/NavCustomization'
39
39
  import type { FleetTakeoverTarget } from './context/NavCustomization'
40
40
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
41
+ import { CNPGView } from './components/cnpg/CNPGView'
42
+ import { CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, parseCNPGRoute } from './components/cnpg/routes'
43
+ import { currentPageLabel } from './components/cnpg/paths'
41
44
  import { navigateFromPrimaryRail } from './components/nav/navigation'
42
45
  import { useNavRailPinned } from './hooks/useNavRailPinned'
43
46
  import { useMediaQuery } from './hooks/useMediaQuery'
@@ -45,6 +48,7 @@ import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitch
45
48
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
46
49
  import { ConnectionErrorView } from './components/ConnectionErrorView'
47
50
  import { SyncProgressPanel } from './components/SyncProgressPanel'
51
+ import { NoClusterAccessBanner } from './components/NoClusterAccessBanner'
48
52
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
49
53
  import { RadarUpgradeProvider } from './context/RadarUpgrade'
50
54
  import { UserMenu } from './components/UserMenu'
@@ -124,7 +128,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
124
128
 
125
129
  // Convert API resource name back to topology node ID prefix
126
130
  // Extended MainView type that includes traffic and cost
127
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
131
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'cnpg' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
128
132
 
129
133
  // Extract view from URL path
130
134
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -138,6 +142,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
138
142
  if (path === 'traffic') return 'traffic'
139
143
  if (path === 'cost') return 'cost'
140
144
  if (path === 'capacity') return 'capacity'
145
+ if (path === 'cnpg') return 'cnpg'
141
146
  if (path === 'workload') return 'workload'
142
147
  if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
143
148
  if (path === 'gitops') return 'gitops'
@@ -165,7 +170,7 @@ function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean):
165
170
  const CRASH_LABELS: Record<ExtendedMainView, string> = {
166
171
  home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
167
172
  issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
168
- cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
173
+ cost: 'Cost', capacity: 'Capacity', cnpg: 'CloudNativePG', checks: 'Checks', gitops: 'GitOps',
169
174
  applications: 'Applications', workload: 'Workload', compare: 'Compare',
170
175
  investigations: 'Investigations',
171
176
  }
@@ -282,6 +287,12 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
282
287
  if (pathSegments[1] === 'activity') return 'Capacity Activity'
283
288
  }
284
289
 
290
+ if (view === 'cnpg') {
291
+ const route = parseCNPGRoute(pathname)
292
+ if (route.detail) return route.detail.name
293
+ const screen = CNPG_SCREENS.find((s) => s.id === route.screen)
294
+ return `CloudNativePG ${screen?.label ?? 'Overview'}`
295
+ }
285
296
  if (view === 'home') return 'Overview'
286
297
  // Every other view's label is its id capitalized — getViewFromPath has already
287
298
  // normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
@@ -529,8 +540,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
529
540
  navigate(withCrossViewParams(path, location.search))
530
541
  }, [location.search, navigate])
531
542
 
532
- // The standalone rail expresses intent to leave the full-width investigation
533
- // workspace. Close it before routing so the destination is immediately visible;
543
+ // The standalone rail expresses intent to leave the full-width investigations
544
+ // page. Close it before routing so the destination is immediately visible;
534
545
  // docked investigations stay open across views as a persistent side panel.
535
546
  const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
536
547
  navigateFromPrimaryRail(
@@ -875,7 +886,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
875
886
  gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
876
887
  // Non-rail views (reachable via deep links / actions, not the rail) get no
877
888
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
878
- workload: '', compare: '', helmCompare: '', investigations: '',
889
+ workload: '', compare: '', helmCompare: '', investigations: '', cnpg: '',
879
890
  }
880
891
  const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
881
892
  (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
@@ -1064,6 +1075,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1064
1075
  secretsChanged: boolean
1065
1076
  timer: number | null
1066
1077
  }>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
1078
+ const cnpgInvalidationPendingRef = useRef(false)
1067
1079
  const slowInvalidationRef = useRef<{
1068
1080
  updatedKinds: Set<string> // update-only churn → throttled list + dashboard
1069
1081
  timer: number | null
@@ -1095,6 +1107,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1095
1107
  const structural = event.operation === 'add' || event.operation === 'delete'
1096
1108
  const applicationWorkload = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
1097
1109
 
1110
+ if (event.group?.endsWith('.cnpg.io')) cnpgInvalidationPendingRef.current = true
1111
+
1098
1112
  const fast = fastInvalidationRef.current
1099
1113
  fast.changedKinds.add(kind)
1100
1114
  if (structural) fast.structuralKinds.add(kind)
@@ -1138,6 +1152,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1138
1152
  // GitOps view is mounted (Phase 2 will make this relevance-aware).
1139
1153
  queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
1140
1154
  queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
1155
+ if (cnpgInvalidationPendingRef.current) {
1156
+ cnpgInvalidationPendingRef.current = false
1157
+ queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace'] })
1158
+ }
1141
1159
  fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1142
1160
  }, 3000)
1143
1161
  }
@@ -1223,6 +1241,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1223
1241
  const nextParams = new URLSearchParams()
1224
1242
  const diagnoseRun = new URLSearchParams(location.search).get('ai-run')
1225
1243
  if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1244
+ // A CNPG detail keeps the context it belongs to, so it can say it is
1245
+ // not in the new one instead of loading a same-named object.
1246
+ const pinnedCtx = new URLSearchParams(location.search).get('ctx')
1247
+ if (pinnedCtx && location.pathname.startsWith('/cnpg/')) nextParams.set('ctx', pinnedCtx)
1226
1248
  navigate(
1227
1249
  { pathname: location.pathname, search: nextParams.toString() },
1228
1250
  { replace: true, state: location.state },
@@ -1749,7 +1771,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1749
1771
  setVisibleKinds(new Set())
1750
1772
  }, [])
1751
1773
 
1752
- const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
1774
+ const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView === 'cnpg' ? 'resources' : mainView
1753
1775
 
1754
1776
  return (
1755
1777
  <PortForwardProvider>
@@ -1890,11 +1912,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1890
1912
 
1891
1913
  {/* Local terminal */}
1892
1914
  {capabilities.localTerminal && (
1893
- <Tooltip content="Open local terminal">
1915
+ <Tooltip content={connection.context || connection.state === 'disconnected' ? 'Open local terminal' : 'Waiting for a context to open a terminal'}>
1894
1916
  <button
1895
1917
  onClick={() => openLocalTerminal()}
1918
+ disabled={!connection.context && connection.state !== 'disconnected'}
1896
1919
  aria-label="Open local terminal"
1897
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1920
+ className="p-1.5 rounded-md bg-theme-elevated enabled:hover:bg-theme-hover text-theme-text-secondary enabled:hover:text-theme-text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
1898
1921
  >
1899
1922
  <SquareTerminal className="w-4 h-4" />
1900
1923
  </button>
@@ -2009,6 +2032,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2009
2032
  </PaneLoader>
2010
2033
  )}
2011
2034
 
2035
+ {contentReady && <NoClusterAccessBanner />}
2012
2036
  {/* Main content - only show when connected and authenticated */}
2013
2037
  {/* inert while a fullscreen detail overlay covers the views — keeps the
2014
2038
  retained background list out of the focus order + a11y tree (the visual
@@ -2301,6 +2325,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2301
2325
  <CapacityView onOpenResource={navigateToResource} />
2302
2326
  )}
2303
2327
 
2328
+ {!viewsSyncGated && mainView === 'cnpg' && (
2329
+ <CNPGView
2330
+ namespaces={namespaces}
2331
+ selectedResource={routeSelectedResource}
2332
+ onOpenResource={navigateToResource}
2333
+ onCloseResource={() => setSelectedResource(null)}
2334
+ onClearNamespaces={clearAllNamespaces}
2335
+ />
2336
+ )}
2337
+
2304
2338
  {/* Takeover splash. When the host claims the current view via
2305
2339
  fleetTakeoverHref, the redirect effect above is mid-flight — render a
2306
2340
  brief splash instead of the inline view (which would flash + fire its
@@ -2368,7 +2402,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2368
2402
  onClose={closeDrawer}
2369
2403
  onNavigate={(res) => navigateToResource(res)}
2370
2404
  canCollapseToDrawer={!isMobile}
2371
- onExpand={(_res, opts) => {
2405
+ onExpand={(res, opts) => {
2406
+ const cnpgPlural = cnpgDetailKindFor(res.kind, res.group)
2407
+ if (cnpgPlural) {
2408
+ navigate(
2409
+ cnpgDetailPath({ plural: cnpgPlural, namespace: res.namespace, name: res.name }, connection.context || undefined, opts?.yaml ? 'yaml' : undefined),
2410
+ { state: { returnLabel: currentPageLabel(), returnCtx: connection.context } },
2411
+ )
2412
+ return
2413
+ }
2372
2414
  // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2373
2415
  // collapses) over whatever view is underneath — list, topology graph,
2374
2416
  // GitOps, Applications — which stays mounted. Carry the YAML tab when
package/src/RadarApp.tsx CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  } from "./components/ui/Toast";
35
35
  import { setApiBase, setBasename } from "./api/config";
36
36
  import { shouldRetryRadarQuery } from "./api/radarFeatures";
37
+ import { isShownInline } from "./api/httpErrors";
37
38
  import { NavCustomizationProvider } from "./context/NavCustomization";
38
39
  import { FilterLocationBridge } from "./filter/FilterLocationBridge";
39
40
  import type { NavCustomization } from "./context/NavCustomization";
@@ -49,6 +50,7 @@ import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
49
50
  import type {
50
51
  RenderDiagnoseAction,
51
52
  RenderInvestigationRunActions,
53
+ RenderInvestigationRefusalAction,
52
54
  DiagnoseConsentCopy,
53
55
  } from "./context/DiagnoseCustomization";
54
56
  import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
@@ -132,6 +134,10 @@ export interface RadarAppProps {
132
134
  renderDiagnoseAction?: RenderDiagnoseAction;
133
135
  /** Host-owned controls for the focused investigation; absent in standalone Radar. */
134
136
  renderInvestigationRunActions?: RenderInvestigationRunActions;
137
+ /** Host action beside a refused investigation start or follow-up, e.g. Radar
138
+ * Cloud's "Upgrade" when the organization has used its investigations.
139
+ * Absent in standalone Radar, whose backend sends no refusal details. */
140
+ renderInvestigationRefusalAction?: RenderInvestigationRefusalAction;
135
141
  /**
136
142
  * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
137
143
  * backend runs the agent somewhere other than the user's own machine — the
@@ -179,7 +185,7 @@ export interface RadarAppProps {
179
185
  // Default QueryClient with the same shape Radar's standalone binary uses.
180
186
  // Extracted so both standalone + library consumers get identical
181
187
  // toast-on-error / toast-on-success behavior.
182
- function makeDefaultQueryClient(): QueryClient {
188
+ export function makeDefaultQueryClient(): QueryClient {
183
189
  return new QueryClient({
184
190
  defaultOptions: {
185
191
  queries: {
@@ -190,7 +196,7 @@ function makeDefaultQueryClient(): QueryClient {
190
196
  mutationCache: new MutationCache({
191
197
  onError: (error, _variables, _context, mutation) => {
192
198
  const message = mutation.options.meta?.errorMessage;
193
- if (message) showApiError(message, (error as Error).message);
199
+ if (message && !isShownInline(error)) showApiError(message, (error as Error).message);
194
200
  },
195
201
  onSuccess: (_data, _variables, _context, mutation) => {
196
202
  const message = mutation.options.meta?.successMessage;
@@ -224,6 +230,7 @@ export function RadarApp({
224
230
  documentTitleSuffix,
225
231
  renderDiagnoseAction,
226
232
  renderInvestigationRunActions,
233
+ renderInvestigationRefusalAction,
227
234
  diagnoseConsent,
228
235
  initialPath,
229
236
  onClusterLoadStateChange,
@@ -263,6 +270,7 @@ export function RadarApp({
263
270
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
264
271
  consentCopy={diagnoseConsent}
265
272
  renderRunActions={renderInvestigationRunActions}
273
+ renderRefusalAction={renderInvestigationRefusalAction}
266
274
  >
267
275
  <DiagnoseProvider
268
276
  browserURLState={router !== "memory"}
package/src/api/client.ts CHANGED
@@ -22,7 +22,7 @@ import type {
22
22
  } from '@skyhook-io/k8s-ui'
23
23
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
24
24
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
25
- import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
25
+ import { useIsAuthEnabled, useNamespacedCapabilities } from '../contexts/CapabilitiesContext'
26
26
  import type {
27
27
  Topology,
28
28
  ClusterInfo,
@@ -59,7 +59,7 @@ import type {
59
59
  } from '../types'
60
60
  import type { GitOpsOperationResponse } from '../types/gitops'
61
61
  import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
62
- import { httpStatusMessage, readErrorBody, readErrorResponse } from './httpErrors'
62
+ import { httpStatusMessage, markShownInline, readErrorBody, readErrorResponse } from './httpErrors'
63
63
  import {
64
64
  RadarFeatureUnsupportedError,
65
65
  guardRadarFeature,
@@ -2256,6 +2256,9 @@ export interface AuthMe {
2256
2256
  * When false, logout clears Radar's cookie but the proxy may re-auth
2257
2257
  * the same user on the next request. */
2258
2258
  proxyLogoutConfigured?: boolean;
2259
+ /** Connected and the user's access is known: true when their RBAC allows
2260
+ * reading no namespace at all. Absent when access couldn't be determined. */
2261
+ noNamespaceAccess?: boolean;
2259
2262
  }
2260
2263
 
2261
2264
  export function useAuthMe() {
@@ -2266,6 +2269,24 @@ export function useAuthMe() {
2266
2269
  });
2267
2270
  }
2268
2271
 
2272
+ /**
2273
+ * useNamespaceAccess asks the server to run namespace discovery for the caller,
2274
+ * so the answer never depends on whether the permission cache happens to hold
2275
+ * an entry. A user with no access is waiting on an admin; re-check every
2276
+ * minute so the banner clears once a binding lands.
2277
+ */
2278
+ export function useNamespaceAccess(enabled: boolean) {
2279
+ return useQuery<AuthMe>({
2280
+ queryKey: ["namespace-access"],
2281
+ queryFn: () => fetchJSON("/auth/me?check=namespaces"),
2282
+ enabled,
2283
+ staleTime: 60000,
2284
+ // Stop only on an explicit false: an absent field means discovery couldn't
2285
+ // answer this time, which says nothing about the user's access.
2286
+ refetchInterval: (query) => (query.state.data?.noNamespaceAccess === false ? false : 60000),
2287
+ });
2288
+ }
2289
+
2269
2290
  // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
2270
2291
  // CloudRole.AtLeast — the frontend must agree with the backend on what
2271
2292
  // "member-or-higher" means; otherwise we'd hide a button the
@@ -2282,16 +2303,12 @@ const CLOUD_ROLE_RANK: Record<string, number> = {
2282
2303
  * present (OSS, OIDC, no role group, OR auth/me is still loading),
2283
2304
  * `canAtLeast` returns true — the gate is strictly additive for
2284
2305
  * Cloud-attributed users, mirroring the backend's `requireCloudRole`
2285
- * semantics. Use for passive content gating (panels, sections); use
2286
- * `useCanHelmAct` (or similar) for *click-prone* surfaces where you
2287
- * need fail-closed behavior during the auth/me round-trip to prevent
2288
- * a viewer from clicking through during the loading window.
2306
+ * semantics. It gates Radar's own features (settings, integrations), never
2307
+ * operations that run as the user, which Kubernetes RBAC decides.
2289
2308
  *
2290
- * Why optimistic during load: the gated empty state ("Your role can't
2291
- * view…") rendered briefly to OSS / kubectl-plugin users before
2292
- * auth/me resolves is a worse regression than a Cloud viewer seeing
2293
- * a content tab populate for a tick before being gated out. Click-
2294
- * prevention belongs in the action-button hook, not here.
2309
+ * Why optimistic during load: a gated empty state rendered briefly to
2310
+ * OSS / kubectl-plugin users before auth/me resolves is a worse regression
2311
+ * than a Cloud viewer seeing content for a tick before being gated out.
2295
2312
  */
2296
2313
  export function useCloudRole() {
2297
2314
  const { data, isLoading } = useAuthMe();
@@ -2308,42 +2325,35 @@ export function useCloudRole() {
2308
2325
  }
2309
2326
 
2310
2327
  /**
2311
- * useCanHelmAct combines the K8s capability gate (rbac.helm=true) and
2312
- * the Cloud role gate (member+) into a single answer for any Helm
2313
- * write or sensitive-read button. Returns { allowed, reason } so the
2314
- * tooltip can explain which gate failed.
2315
- *
2316
- * Cloud role check runs FIRST so the message is actionable for Cloud
2317
- * users — telling them "Helm write permissions required" is wrong if
2318
- * the chart is fine and the actual gate is their viewer role.
2328
+ * useCanHelmAct answers whether Helm write buttons are usable for a release in
2329
+ * `namespace`. With auth on, Helm runs as the signed-in user, so the check is
2330
+ * the user's own Kubernetes RBAC in that namespace (Helm writes each release as
2331
+ * a Secret there); the Cloud role says nothing about cluster access when IdP
2332
+ * groups grant it. Without auth, Helm runs as Radar's ServiceAccount, which
2333
+ * needs the chart's rbac.helm=true.
2319
2334
  */
2320
- export function useCanHelmAct(): { allowed: boolean; reason?: string } {
2321
- const helmWrite = useCanHelmWrite();
2322
- const { role, canAtLeast, isLoading } = useCloudRole();
2323
- // Fail-closed for action buttons during the auth/me round-trip:
2324
- // a Cloud viewer who clicks during loading would otherwise fire a
2325
- // real request that gets 403'd. For OSS / kubectl-plugin the
2326
- // round-trip is sub-ms so this is imperceptible; for Cloud it
2327
- // prevents the click-through window. Distinct from useCloudRole's
2328
- // canAtLeast (which is optimistic during loading) because passive
2329
- // content gates don't have a click-handler to misfire.
2330
- if (isLoading) {
2331
- return { allowed: false, reason: "Loading permissions…" };
2332
- }
2333
- if (!canAtLeast("member")) {
2334
- return {
2335
- allowed: false,
2336
- reason: `Your Radar Cloud role (${role ?? "unknown"}) cannot run Helm operations. Ask a member or owner.`,
2337
- };
2335
+ export function useCanHelmAct(namespace?: string): { allowed: boolean; reason?: string } {
2336
+ const { canHelmWrite, helmWriteUnknown } = useNamespacedCapabilities(namespace);
2337
+ const authEnabled = useIsAuthEnabled();
2338
+ // Without a namespace answer (still loading, or the check failed) the global
2339
+ // value only says the user can create Secrets somewhere, which would enable
2340
+ // actions the server then refuses.
2341
+ if (authEnabled && helmWriteUnknown) {
2342
+ return { allowed: false, reason: `Couldn't confirm your permissions in ${namespace} yet.` };
2338
2343
  }
2339
- if (!helmWrite) {
2344
+ if (canHelmWrite) return { allowed: true };
2345
+ if (authEnabled) {
2340
2346
  return {
2341
2347
  allowed: false,
2342
- reason:
2343
- "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
2348
+ reason: namespace
2349
+ ? `Your Kubernetes permissions don't allow managing Helm releases in ${namespace} (creating Secrets there).`
2350
+ : "Your Kubernetes permissions don't allow managing Helm releases.",
2344
2351
  };
2345
2352
  }
2346
- return { allowed: true };
2353
+ return {
2354
+ allowed: false,
2355
+ reason: "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
2356
+ };
2347
2357
  }
2348
2358
 
2349
2359
  // Namespaces
@@ -4092,7 +4102,13 @@ export function useUpdateResource() {
4092
4102
  });
4093
4103
  if (!response.ok) {
4094
4104
  const error = await readErrorBody(response);
4095
- throw new Error(error.error || `HTTP ${response.status}`);
4105
+ const failure = new Error(error.error || `HTTP ${response.status}`);
4106
+ // A reviewed apply's failure is shown in the review itself (the
4107
+ // changed-after-review notice, or the error), so it gets no toast.
4108
+ if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
4109
+ markShownInline(failure);
4110
+ }
4111
+ throw failure;
4096
4112
  }
4097
4113
  return response.json();
4098
4114
  },
@@ -5451,14 +5467,10 @@ export function useHelmRelease(
5451
5467
  }
5452
5468
 
5453
5469
  // Get manifest for a Helm release (optionally at a specific revision).
5454
- // `enabled` lets callers skip the query when the user's Cloud role
5455
- // would 403 the read — saves a round-trip and avoids a transient
5456
- // "error" state that the role-gated empty panel doesn't need.
5457
5470
  export function useHelmManifest(
5458
5471
  namespace: string,
5459
5472
  name: string,
5460
5473
  revision?: number,
5461
- enabled = true,
5462
5474
  ) {
5463
5475
  const params = revision ? `?revision=${revision}` : "";
5464
5476
  return useQuery<string>({
@@ -5473,12 +5485,12 @@ export function useHelmManifest(
5473
5485
  }
5474
5486
  return response.text();
5475
5487
  },
5476
- enabled: Boolean(namespace && name && enabled),
5488
+ enabled: Boolean(namespace && name),
5477
5489
  staleTime: 60000, // 1 minute
5478
5490
  });
5479
5491
  }
5480
5492
 
5481
- // Get values for a Helm release. `enabled` see useHelmManifest.
5493
+ // Get values for a Helm release.
5482
5494
  export function useHelmValues(
5483
5495
  namespace: string,
5484
5496
  name: string,
@@ -5499,7 +5511,7 @@ export function useHelmValues(
5499
5511
  });
5500
5512
  }
5501
5513
 
5502
- // Get diff between two revisions. `enabled` see useHelmManifest.
5514
+ // Get diff between two revisions.
5503
5515
  export function useHelmManifestDiff(
5504
5516
  namespace: string,
5505
5517
  name: string,
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // A reviewed YAML apply shows its failure in the review itself, so the global
4
+ // "Failed to update resource" toast would say the same thing twice.
5
+ import { act } from 'react'
6
+ import { createRoot } from 'react-dom/client'
7
+ import { QueryClientProvider } from '@tanstack/react-query'
8
+ import { afterEach, expect, it, vi } from 'vitest'
9
+
10
+ const toasts = vi.hoisted(() => ({ errors: [] as string[] }))
11
+ vi.mock('@skyhook-io/k8s-ui', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('@skyhook-io/k8s-ui')>()),
13
+ showApiError: (title: string) => toasts.errors.push(title),
14
+ }))
15
+
16
+ const { makeDefaultQueryClient } = await import('../RadarApp')
17
+ const { useUpdateResource } = await import('./client')
18
+
19
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals()
22
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
23
+ toasts.errors = []
24
+ })
25
+
26
+ async function save(reviewed: boolean) {
27
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(
28
+ JSON.stringify({ error: 'resource changed after review; review the latest version before applying' }),
29
+ { status: 409, headers: { 'Content-Type': 'application/json' } },
30
+ )))
31
+ let mutate: ReturnType<typeof useUpdateResource>['mutateAsync'] | undefined
32
+ function Probe() {
33
+ mutate = useUpdateResource().mutateAsync
34
+ return null
35
+ }
36
+ const host = document.createElement('div')
37
+ const root = createRoot(host)
38
+ await act(async () => {
39
+ root.render(<QueryClientProvider client={makeDefaultQueryClient()}><Probe /></QueryClientProvider>)
40
+ })
41
+ await act(async () => {
42
+ await mutate!({
43
+ kind: 'configmaps', namespace: 'shop', name: 'app', yaml: 'kind: ConfigMap',
44
+ ...(reviewed ? { reviewedResourceVersion: '42', reviewedContext: 'kind-a' } : {}),
45
+ }).catch(() => {})
46
+ })
47
+ act(() => root.unmount())
48
+ }
49
+
50
+ it('does not toast a reviewed apply, whose review shows the failure', async () => {
51
+ await save(true)
52
+ expect(toasts.errors).toEqual([])
53
+ })
54
+
55
+ it('still toasts a save that did not come from a review', async () => {
56
+ await save(false)
57
+ expect(toasts.errors).toEqual(['Failed to update resource'])
58
+ })
@@ -0,0 +1,95 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import type { CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui'
3
+ import { fetchJSON } from './client'
4
+
5
+ // /api/cnpg/workspace
6
+ //
7
+ // Every CloudNativePG object the caller may read, with per-kind coverage, the
8
+ // CNPG issues on them and the no-schedule audit finding. One query feeds the
9
+ // workspace screens, the Resources sidebar counts and the composed summaries,
10
+ // so they can never disagree about what they count.
11
+ export function useCNPGWorkspace(namespaces: string[], options?: { enabled?: boolean }) {
12
+ const ns = [...namespaces].sort().join(',')
13
+ return useQuery<CNPGWorkspaceResponse>({
14
+ queryKey: ['cnpg', 'workspace', ns],
15
+ queryFn: ({ signal }) => fetchJSON<CNPGWorkspaceResponse>(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal),
16
+ enabled: options?.enabled ?? true,
17
+ staleTime: 10_000,
18
+ refetchInterval: 30_000,
19
+ placeholderData: (prev) => prev,
20
+ })
21
+ }
22
+
23
+ export interface CNPGOperatorCoverage {
24
+ state: 'full' | 'partial' | 'denied' | 'syncing' | 'error'
25
+ deniedNamespaces?: string[]
26
+ }
27
+
28
+ export interface CNPGOperatorComponent {
29
+ role: 'operator' | 'plugin'
30
+ pluginName?: string
31
+ namespace: string
32
+ deployment: string
33
+ image?: string
34
+ version?: string
35
+ readyReplicas: number | null
36
+ replicas: number | null
37
+ }
38
+
39
+ export interface CNPGOperatorConfig {
40
+ kind: 'ConfigMap' | 'Secret'
41
+ namespace: string
42
+ name: string
43
+ purpose: 'operator' | 'monitoring'
44
+ exists?: boolean | null
45
+ readable?: boolean
46
+ reason?: string
47
+ data?: Record<string, string>
48
+ }
49
+
50
+ export interface CNPGOperatorResponse {
51
+ coverage: { deployments: CNPGOperatorCoverage; services: CNPGOperatorCoverage }
52
+ components: CNPGOperatorComponent[]
53
+ config: CNPGOperatorConfig[]
54
+ }
55
+
56
+ // /api/cnpg/operator
57
+ //
58
+ // Operator and plugin workloads plus where the operator's configuration lives.
59
+ // Deliberately not filtered by the namespace view filter: the operator runs in
60
+ // its own namespace, which users rarely have selected.
61
+ export function useCNPGOperator(options?: { enabled?: boolean }) {
62
+ return useQuery<CNPGOperatorResponse>({
63
+ queryKey: ['cnpg', 'operator'],
64
+ queryFn: ({ signal }) => fetchJSON<CNPGOperatorResponse>('/cnpg/operator', signal),
65
+ enabled: options?.enabled ?? true,
66
+ staleTime: 30_000,
67
+ refetchInterval: 60_000,
68
+ })
69
+ }
70
+
71
+ export interface CNPGClusterActivityResponse {
72
+ events: TimelineEvent[]
73
+ oldest: string | null
74
+ attributionSince: string | null
75
+ truncated: boolean
76
+ }
77
+
78
+ // /api/cnpg/clusters/{ns}/{name}/activity
79
+ //
80
+ // The Cluster's history together with its instance Pods and every CNPG object
81
+ // attributed to it, including ones since deleted.
82
+ export function useCNPGClusterActivity(namespace: string, name: string, sinceHours = 24) {
83
+ return useQuery<CNPGClusterActivityResponse>({
84
+ queryKey: ['cnpg', 'activity', namespace, name, sinceHours],
85
+ queryFn: ({ signal }) => {
86
+ const since = new Date(Date.now() - sinceHours * 3600_000).toISOString()
87
+ return fetchJSON<CNPGClusterActivityResponse>(
88
+ `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&limit=500`,
89
+ signal,
90
+ )
91
+ },
92
+ staleTime: 10_000,
93
+ refetchInterval: 30_000,
94
+ })
95
+ }
@@ -310,6 +310,32 @@ export class DiagnoseError extends Error {
310
310
  }
311
311
  }
312
312
 
313
+ /** A host's refusal of an investigation request, with what would unblock it. */
314
+ export interface InvestigationRefusal {
315
+ status: number;
316
+ /** The sentence shown to the user. */
317
+ message: string;
318
+ code?: string;
319
+ reason?: string;
320
+ action?: string;
321
+ }
322
+
323
+ /** The refusal a DiagnoseError carries, or null when the host sent none
324
+ * (Radar's own backend never does). */
325
+ export function investigationRefusal(
326
+ error: unknown,
327
+ ): InvestigationRefusal | null {
328
+ if (!(error instanceof DiagnoseError)) return null;
329
+ if (!error.code && !error.reason && !error.action) return null;
330
+ return {
331
+ status: error.status,
332
+ message: error.message,
333
+ code: error.code,
334
+ reason: error.reason,
335
+ action: error.action,
336
+ };
337
+ }
338
+
313
339
  const RUNS = () => `${getApiBase()}/diagnose/runs`;
314
340
 
315
341
  // createRun starts a server-side investigation (or focuses a live one for the same