@skyhook-io/radar-app 1.16.2 → 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 (42) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +46 -5
  3. package/src/api/client.ts +53 -47
  4. package/src/api/cnpg.ts +95 -0
  5. package/src/api/timelineSource.ts +1 -1
  6. package/src/components/NoClusterAccessBanner.tsx +68 -0
  7. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  8. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  9. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  10. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  11. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  12. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  13. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  14. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  15. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  16. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  17. package/src/components/cnpg/CNPGView.tsx +156 -0
  18. package/src/components/cnpg/paths.ts +13 -0
  19. package/src/components/cnpg/routes.test.ts +60 -0
  20. package/src/components/cnpg/routes.ts +112 -0
  21. package/src/components/cnpg/shared.tsx +290 -0
  22. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  23. package/src/components/helm/ChartBrowser.tsx +6 -7
  24. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  25. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  26. package/src/components/helm/InstallWizard.tsx +5 -2
  27. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  28. package/src/components/helm/ValuesViewer.tsx +1 -1
  29. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  30. package/src/components/resources/ResourcesView.tsx +6 -38
  31. package/src/components/timeline/TimelineList.test.ts +47 -29
  32. package/src/components/timeline/TimelineList.tsx +57 -11
  33. package/src/components/timeline/TimelineView.tsx +9 -2
  34. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  35. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  36. package/src/components/whats-new/WhatsNew.tsx +146 -86
  37. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  38. package/src/components/whats-new/releaseNotes.ts +155 -24
  39. package/src/components/workload/WorkloadView.tsx +30 -4
  40. package/src/contexts/CapabilitiesContext.tsx +9 -1
  41. package/src/hooks/useResourceCounts.ts +49 -0
  42. 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.2",
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.
@@ -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>
@@ -2010,6 +2032,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2010
2032
  </PaneLoader>
2011
2033
  )}
2012
2034
 
2035
+ {contentReady && <NoClusterAccessBanner />}
2013
2036
  {/* Main content - only show when connected and authenticated */}
2014
2037
  {/* inert while a fullscreen detail overlay covers the views — keeps the
2015
2038
  retained background list out of the focus order + a11y tree (the visual
@@ -2302,6 +2325,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2302
2325
  <CapacityView onOpenResource={navigateToResource} />
2303
2326
  )}
2304
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
+
2305
2338
  {/* Takeover splash. When the host claims the current view via
2306
2339
  fleetTakeoverHref, the redirect effect above is mid-flight — render a
2307
2340
  brief splash instead of the inline view (which would flash + fire its
@@ -2369,7 +2402,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2369
2402
  onClose={closeDrawer}
2370
2403
  onNavigate={(res) => navigateToResource(res)}
2371
2404
  canCollapseToDrawer={!isMobile}
2372
- 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
+ }
2373
2414
  // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2374
2415
  // collapses) over whatever view is underneath — list, topology graph,
2375
2416
  // GitOps, Applications — which stays mounted. Carry the YAML tab when
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,
@@ -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…" };
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.` };
2332
2343
  }
2333
- if (!canAtLeast("member")) {
2344
+ if (canHelmWrite) return { allowed: true };
2345
+ if (authEnabled) {
2334
2346
  return {
2335
2347
  allowed: false,
2336
- reason: `Your Radar Cloud role (${role ?? "unknown"}) cannot run Helm operations. Ask a member or owner.`,
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.",
2337
2351
  };
2338
2352
  }
2339
- if (!helmWrite) {
2340
- return {
2341
- allowed: false,
2342
- reason:
2343
- "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
2344
- };
2345
- }
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
@@ -5457,14 +5467,10 @@ export function useHelmRelease(
5457
5467
  }
5458
5468
 
5459
5469
  // Get manifest for a Helm release (optionally at a specific revision).
5460
- // `enabled` lets callers skip the query when the user's Cloud role
5461
- // would 403 the read — saves a round-trip and avoids a transient
5462
- // "error" state that the role-gated empty panel doesn't need.
5463
5470
  export function useHelmManifest(
5464
5471
  namespace: string,
5465
5472
  name: string,
5466
5473
  revision?: number,
5467
- enabled = true,
5468
5474
  ) {
5469
5475
  const params = revision ? `?revision=${revision}` : "";
5470
5476
  return useQuery<string>({
@@ -5479,12 +5485,12 @@ export function useHelmManifest(
5479
5485
  }
5480
5486
  return response.text();
5481
5487
  },
5482
- enabled: Boolean(namespace && name && enabled),
5488
+ enabled: Boolean(namespace && name),
5483
5489
  staleTime: 60000, // 1 minute
5484
5490
  });
5485
5491
  }
5486
5492
 
5487
- // Get values for a Helm release. `enabled` see useHelmManifest.
5493
+ // Get values for a Helm release.
5488
5494
  export function useHelmValues(
5489
5495
  namespace: string,
5490
5496
  name: string,
@@ -5505,7 +5511,7 @@ export function useHelmValues(
5505
5511
  });
5506
5512
  }
5507
5513
 
5508
- // Get diff between two revisions. `enabled` see useHelmManifest.
5514
+ // Get diff between two revisions.
5509
5515
  export function useHelmManifestDiff(
5510
5516
  namespace: string,
5511
5517
  name: string,
@@ -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
+ }
@@ -423,7 +423,7 @@ export async function fetchRetainedDelta(
423
423
  // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
424
424
  // a resource managed by another — owned, or one of the churn kinds. Keep the
425
425
  // two predicates in lockstep.
426
- function isManagedTimelineEvent(e: TimelineEvent): boolean {
426
+ export function isManagedTimelineEvent(e: TimelineEvent): boolean {
427
427
  return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
428
428
  }
429
429
 
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ShieldAlert } from 'lucide-react'
3
+ import { AlertBanner, Badge, Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui'
4
+ import { useAuthMe, useNamespaceAccess } from '../api/client'
5
+
6
+ // Every read is filtered to the user's namespaces, so someone bound to none
7
+ // sees empty lists in every view and reads it as an empty cluster. Stated in
8
+ // terms of the user's own access, not how the cluster is meant to be set up:
9
+ // Radar can't know whether an org intends hub roles or IdP groups here.
10
+ export function NoClusterAccessBanner() {
11
+ const { data: auth } = useAuthMe()
12
+ // Mounted once content is ready (and again after a context switch), so the
13
+ // cluster is connected by the time this asks.
14
+ const { data } = useNamespaceAccess(Boolean(auth?.authEnabled))
15
+ // Keep the last definite answer through a check that couldn't decide, so a
16
+ // transient discovery failure doesn't hide the banner from a user without access.
17
+ const [me, setMe] = useState<typeof data>()
18
+ useEffect(() => {
19
+ if (data && typeof data.noNamespaceAccess === 'boolean') setMe(data)
20
+ }, [data])
21
+ const [groupsOpen, setGroupsOpen] = useState(false)
22
+ const groupsDisclosure = useDisclosure(groupsOpen)
23
+
24
+ // The remembered answer only counts for the person signed in now.
25
+ if (!me?.noNamespaceAccess || me.username !== auth?.username) return null
26
+
27
+ const groups = me.groups ?? []
28
+ return (
29
+ <div className="px-4 pt-3">
30
+ <AlertBanner
31
+ variant="warning"
32
+ icon={ShieldAlert}
33
+ title="You can't read any namespace in this cluster"
34
+ message={
35
+ <>
36
+ Radar is connected, but your Kubernetes permissions don't allow reading any namespace, so
37
+ every view looks empty. Signed in as <span className="font-mono">{me.username}</span>.
38
+ </>
39
+ }
40
+ >
41
+ {groups.length > 0 && (
42
+ // ph-no-capture: group IDs name the customer's directory structure.
43
+ // Radar Cloud embeds this view with session replay on, and its text
44
+ // mask only catches credential-shaped values. A no-op without PostHog.
45
+ <div className="ph-no-capture mt-2">
46
+ <button
47
+ {...groupsDisclosure.buttonProps}
48
+ onClick={() => setGroupsOpen((v) => !v)}
49
+ className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
50
+ >
51
+ <CollapseChevron open={groupsOpen} className="w-3.5 h-3.5" />
52
+ Your groups ({groups.length})
53
+ </button>
54
+ <Collapse open={groupsOpen} id={groupsDisclosure.panelId}>
55
+ <div className="mt-2 flex max-h-32 max-w-4xl flex-wrap gap-1.5 overflow-y-auto">
56
+ {groups.map((g) => (
57
+ <Badge key={g} tone="structural" size="sm" className="font-mono">
58
+ {g}
59
+ </Badge>
60
+ ))}
61
+ </div>
62
+ </Collapse>
63
+ </div>
64
+ )}
65
+ </AlertBanner>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react'
2
+ import { PaneLoader, TimelineList, formatAge, type NavigateToResource } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGClusterActivity } from '../../api/cnpg'
4
+ import { Notice } from '../capacity/shared'
5
+ import { Segments } from './shared'
6
+
7
+ const RANGES = [
8
+ { id: '6', label: '6 h' },
9
+ { id: '24', label: '24 h' },
10
+ { id: '168', label: '7 d' },
11
+ ] as const
12
+
13
+ /**
14
+ * Kubernetes events and changes for the Cluster, its instance Pods and the
15
+ * CNPG objects attributed to it — including Backups and declarations deleted
16
+ * since. Attribution of child objects relies on a label Radar records at
17
+ * ingestion, so history older than that is marked incomplete.
18
+ */
19
+ export function CNPGClusterActivity({ namespace, name, onNavigate }: { namespace: string; name: string; onNavigate?: NavigateToResource }) {
20
+ const [hours, setHours] = useState<string>('24')
21
+ const q = useCNPGClusterActivity(namespace, name, Number(hours))
22
+
23
+ return (
24
+ <div className="flex h-full min-h-0 flex-col gap-3 p-4">
25
+ <div className="flex flex-wrap items-center gap-3">
26
+ <Segments label="Range" value={hours} onChange={setHours} options={RANGES.map((r) => ({ id: r.id, label: r.label }))} />
27
+ <span className="text-xs text-theme-text-tertiary">
28
+ Kubernetes events and changes for the Cluster, its instances, Backups, Poolers and declarations.
29
+ </span>
30
+ </div>
31
+ <div className="text-xs text-theme-text-tertiary">
32
+ {q.data?.attributionSince
33
+ ? `The earliest recorded event linking a Backup, Pooler or declaration to this cluster is ${formatAge(q.data.attributionSince)} old. Deleted child objects from before Radar recorded that link are not shown.`
34
+ : 'Radar has not recorded any Backup, Pooler or declaration events linked to this cluster yet, so deleted child objects may be missing.'}
35
+ {' '}Events for kinds you cannot list are omitted.
36
+ </div>
37
+ {q.data?.truncated && <Notice>Showing the most recent events only; narrow the range to see all of them.</Notice>}
38
+ {q.error && !q.data ? (
39
+ <Notice>Activity could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
40
+ ) : !q.data ? (
41
+ <PaneLoader label="Loading activity…" className="h-40" />
42
+ ) : (
43
+ <div className="min-h-0 flex-1">
44
+ <TimelineList events={q.data.events} isLoading={q.isFetching && !q.data} onResourceClick={onNavigate} compact hideRangeSelector />
45
+ </div>
46
+ )}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,62 @@
1
+ import { useCallback } from 'react'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { WorkloadLogsViewer, type WorkloadLogsFetchParams, type WorkloadLogsResult } from '@skyhook-io/k8s-ui'
4
+ import { fetchJSON } from '../../api/client'
5
+ import { getApiBase, getCredentialsMode } from '../../api/config'
6
+ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
7
+ import { useTheme } from '../../context/ThemeContext'
8
+
9
+ function logsPath(namespace: string, name: string) {
10
+ return `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/logs`
11
+ }
12
+
13
+ function query(params: WorkloadLogsFetchParams, tailDefault?: number) {
14
+ const q = new URLSearchParams()
15
+ if (params.container) q.set('container', params.container)
16
+ const tail = params.tailLines ?? tailDefault
17
+ if (tail) q.set('tailLines', String(tail))
18
+ if (params.sinceSeconds) q.set('sinceSeconds', String(params.sinceSeconds))
19
+ const s = q.toString()
20
+ return s ? `?${s}` : ''
21
+ }
22
+
23
+ /**
24
+ * Logs merged from every instance Pod of a CloudNativePG Cluster. `?pod=`
25
+ * preselects one instance (the fleet's "Logs" action and "Open instance logs").
26
+ */
27
+ export function CNPGClusterLogs({ namespace, name }: { namespace: string; name: string }) {
28
+ const [searchParams] = useSearchParams()
29
+ const pod = searchParams.get('pod')
30
+ const desktopDownload = useDesktopDownload()
31
+ const { theme } = useTheme()
32
+
33
+ const fetchAll = useCallback(
34
+ (params: WorkloadLogsFetchParams) =>
35
+ fetchJSON<WorkloadLogsResult>(`${logsPath(namespace, name)}${query(params)}`, { signal: params.signal }),
36
+ [namespace, name],
37
+ )
38
+ const createStream = useCallback(
39
+ (params: WorkloadLogsFetchParams) =>
40
+ new EventSource(`${getApiBase()}${logsPath(namespace, name)}/stream${query(params, 50)}`, {
41
+ withCredentials: getCredentialsMode() === 'include',
42
+ }),
43
+ [namespace, name],
44
+ )
45
+
46
+ return (
47
+ <div className="h-full">
48
+ <WorkloadLogsViewer
49
+ key={pod ?? ''}
50
+ name={name}
51
+ fetchAll={fetchAll}
52
+ createStream={createStream}
53
+ overrideDownload={desktopDownload}
54
+ forceDark={theme === 'dark' ? true : undefined}
55
+ // Light logs on a dark app are never wanted: dark theme pins the palette,
56
+ // light theme only sets where it starts and leaves the toggle available.
57
+ defaultDark={false}
58
+ initialPods={pod ? [pod] : undefined}
59
+ />
60
+ </div>
61
+ )
62
+ }