@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.
- package/package.json +5 -5
- package/src/App.tsx +46 -5
- package/src/api/client.ts +53 -47
- package/src/api/cnpg.ts +95 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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={(
|
|
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 {
|
|
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.
|
|
2286
|
-
*
|
|
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:
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
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
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
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
|
|
2322
|
-
const
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2327
|
-
|
|
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 (
|
|
2344
|
+
if (canHelmWrite) return { allowed: true };
|
|
2345
|
+
if (authEnabled) {
|
|
2334
2346
|
return {
|
|
2335
2347
|
allowed: false,
|
|
2336
|
-
reason:
|
|
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
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
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
|
|
5488
|
+
enabled: Boolean(namespace && name),
|
|
5483
5489
|
staleTime: 60000, // 1 minute
|
|
5484
5490
|
});
|
|
5485
5491
|
}
|
|
5486
5492
|
|
|
5487
|
-
// Get values for a Helm release.
|
|
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.
|
|
5514
|
+
// Get diff between two revisions.
|
|
5509
5515
|
export function useHelmManifestDiff(
|
|
5510
5516
|
namespace: string,
|
|
5511
5517
|
name: string,
|
package/src/api/cnpg.ts
ADDED
|
@@ -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
|
+
}
|