@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
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cnpgDetailKindFor, cnpgDetailPath, decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes'
3
+
4
+ describe('CNPG routes', () => {
5
+ it('parses workspace screens and falls back to Overview for unknown or unavailable ones', () => {
6
+ expect(parseCNPGRoute('/cnpg').screen).toBe('overview')
7
+ expect(parseCNPGRoute('/cnpg/').screen).toBe('overview')
8
+ expect(parseCNPGRoute('/cnpg/nope').screen).toBe('overview')
9
+ expect(parseCNPGRoute('/cnpg/protection').screen).toBe('protection')
10
+ expect(parseCNPGRoute('/cnpg/operator').screen).toBe('operator')
11
+ })
12
+
13
+ it('round-trips a drawer trail and keeps the API group', () => {
14
+ const trail = [
15
+ { kind: 'backups', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-billing-20260928020000' },
16
+ { kind: 'objectstores', group: 'barmancloud.cnpg.io', namespace: 'payments', name: 's3-billing' },
17
+ { kind: 'clusterimagecatalogs', group: 'postgresql.cnpg.io', namespace: '', name: 'postgresql-standard' },
18
+ ]
19
+ const encoded = encodeDrawerTrail(trail)
20
+ expect(decodeDrawerTrail(encoded)).toEqual(trail)
21
+ })
22
+
23
+ it('drops malformed entries instead of opening a guessed object', () => {
24
+ expect(decodeDrawerTrail('clusters:postgresql.cnpg.io:payments:pg-orders~garbage')).toEqual([
25
+ { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-orders' },
26
+ ])
27
+ expect(decodeDrawerTrail(null)).toEqual([])
28
+ })
29
+
30
+ it('distinguishes same-named kinds from different groups', () => {
31
+ const cnpg = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'a', name: 'x' }
32
+ const capi = { kind: 'clusters', group: 'cluster.x-k8s.io', namespace: 'a', name: 'x' }
33
+ expect(sameResource(cnpg, capi)).toBe(false)
34
+ expect(sameResource(cnpg, { ...cnpg })).toBe(true)
35
+ })
36
+
37
+ it('parses full-detail routes for every CNPG kind and the cluster-scoped placeholder', () => {
38
+ expect(parseCNPGRoute('/cnpg/clusters/payments/pg-orders')).toEqual({
39
+ screen: 'overview',
40
+ detail: { plural: 'clusters', group: 'postgresql.cnpg.io', namespace: 'payments', name: 'pg-orders' },
41
+ })
42
+ expect(parseCNPGRoute('/cnpg/objectstores/payments/s3-billing').screen).toBe('protection')
43
+ expect(parseCNPGRoute('/cnpg/objectstores/payments/s3-billing').detail?.group).toBe('barmancloud.cnpg.io')
44
+ expect(parseCNPGRoute('/cnpg/clusterimagecatalogs/_/postgresql-standard').detail?.namespace).toBe('')
45
+ expect(parseCNPGRoute('/cnpg/clusters/payments').detail).toBeUndefined()
46
+ })
47
+
48
+ it('builds detail paths carrying the context', () => {
49
+ expect(cnpgDetailPath({ plural: 'clusters', namespace: 'payments', name: 'pg-orders' }, 'prod', 'logs')).toBe(
50
+ '/cnpg/clusters/payments/pg-orders?ctx=prod&tab=logs',
51
+ )
52
+ expect(cnpgDetailPath({ plural: 'clusterimagecatalogs', namespace: '', name: 'std' })).toBe('/cnpg/clusterimagecatalogs/_/std')
53
+ })
54
+
55
+ it('only claims CNPG kinds from the CNPG groups', () => {
56
+ expect(cnpgDetailKindFor('clusters', 'postgresql.cnpg.io')).toBe('clusters')
57
+ expect(cnpgDetailKindFor('clusters', 'cluster.x-k8s.io')).toBeNull()
58
+ expect(cnpgDetailKindFor('backups', 'velero.io')).toBeNull()
59
+ })
60
+ })
@@ -0,0 +1,112 @@
1
+ import type { SelectedResource } from '../../types'
2
+
3
+ export type CNPGScreen = 'overview' | 'protection' | 'declarations' | 'pooling' | 'operator'
4
+
5
+ export const CNPG_SCREENS: { id: CNPGScreen; label: string; path: string }[] = [
6
+ { id: 'overview', label: 'Overview', path: '/cnpg' },
7
+ { id: 'protection', label: 'Protection', path: '/cnpg/protection' },
8
+ { id: 'declarations', label: 'Declarations', path: '/cnpg/declarations' },
9
+ { id: 'pooling', label: 'Pooling', path: '/cnpg/pooling' },
10
+ { id: 'operator', label: 'Operator', path: '/cnpg/operator' },
11
+ ]
12
+
13
+ export interface CNPGDetailTarget {
14
+ plural: string
15
+ group: string
16
+ namespace: string
17
+ name: string
18
+ }
19
+
20
+ export interface CNPGRoute {
21
+ screen: CNPGScreen
22
+ detail?: CNPGDetailTarget
23
+ }
24
+
25
+ // The CNPG kinds that have a CNPG-framed full detail, with the workspace
26
+ // destination each one lives under.
27
+ export const CNPG_DETAIL_KINDS: Record<string, { group: string; kind: string; home: CNPGScreen; clusterScoped?: boolean }> = {
28
+ clusters: { group: 'postgresql.cnpg.io', kind: 'Cluster', home: 'overview' },
29
+ backups: { group: 'postgresql.cnpg.io', kind: 'Backup', home: 'protection' },
30
+ scheduledbackups: { group: 'postgresql.cnpg.io', kind: 'ScheduledBackup', home: 'protection' },
31
+ objectstores: { group: 'barmancloud.cnpg.io', kind: 'ObjectStore', home: 'protection' },
32
+ databases: { group: 'postgresql.cnpg.io', kind: 'Database', home: 'declarations' },
33
+ publications: { group: 'postgresql.cnpg.io', kind: 'Publication', home: 'declarations' },
34
+ subscriptions: { group: 'postgresql.cnpg.io', kind: 'Subscription', home: 'declarations' },
35
+ poolers: { group: 'postgresql.cnpg.io', kind: 'Pooler', home: 'pooling' },
36
+ imagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ImageCatalog', home: 'operator' },
37
+ clusterimagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ClusterImageCatalog', home: 'operator', clusterScoped: true },
38
+ }
39
+
40
+ export function cnpgDetailKindFor(plural: string, group: string | undefined): string | null {
41
+ const p = plural.toLowerCase()
42
+ const spec = CNPG_DETAIL_KINDS[p]
43
+ return spec && spec.group === (group ?? '') ? p : null
44
+ }
45
+
46
+ export function parseCNPGRoute(pathname: string): CNPGRoute {
47
+ const seg = pathname.replace(/^\/+/, '').split('/').map((s) => {
48
+ try {
49
+ return decodeURIComponent(s)
50
+ } catch {
51
+ return s
52
+ }
53
+ })
54
+ if (seg[0] !== 'cnpg') return { screen: 'overview' }
55
+ const s = seg[1] ?? ''
56
+ const detailSpec = CNPG_DETAIL_KINDS[s]
57
+ if (detailSpec && seg[2] && seg[3]) {
58
+ return {
59
+ screen: detailSpec.home,
60
+ detail: { plural: s, group: detailSpec.group, namespace: seg[2] === '_' ? '' : seg[2], name: seg[3] },
61
+ }
62
+ }
63
+ const match = CNPG_SCREENS.find((x) => x.id === s)
64
+ if (match) return { screen: match.id }
65
+ return { screen: 'overview' }
66
+ }
67
+
68
+ /** Full detail path; `ctx` pins the Kubernetes context the object belongs to. */
69
+ export function cnpgDetailPath(target: Omit<CNPGDetailTarget, 'group'>, ctx?: string, tab?: string): string {
70
+ const params = new URLSearchParams()
71
+ if (ctx) params.set('ctx', ctx)
72
+ if (tab) params.set('tab', tab)
73
+ const qs = params.toString()
74
+ return `/cnpg/${target.plural}/${encodeURIComponent(target.namespace || '_')}/${encodeURIComponent(target.name)}${qs ? `?${qs}` : ''}`
75
+ }
76
+
77
+ export function cnpgScreenPath(screen: CNPGScreen): string {
78
+ return CNPG_SCREENS.find((s) => s.id === screen)?.path ?? '/cnpg'
79
+ }
80
+
81
+ // Drawer identity in the URL: kind:group:namespace:name, chained with "~" for
82
+ // the in-drawer trail (last entry is the one shown). Kubernetes names and API
83
+ // groups cannot contain ":" or "~", and the group is mandatory — CNPG's Cluster
84
+ // and Backup collide with CAPI, KubeBlocks and Velero kinds.
85
+ export function encodeDrawerRef(r: SelectedResource): string {
86
+ return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':')
87
+ }
88
+
89
+ export function decodeDrawerRef(s: string): SelectedResource | null {
90
+ const parts = s.split(':')
91
+ if (parts.length !== 4 || !parts[0] || !parts[3]) return null
92
+ return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] }
93
+ }
94
+
95
+ export function decodeDrawerTrail(param: string | null): SelectedResource[] {
96
+ if (!param) return []
97
+ return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null)
98
+ }
99
+
100
+ export function encodeDrawerTrail(trail: SelectedResource[]): string {
101
+ return trail.map(encodeDrawerRef).join('~')
102
+ }
103
+
104
+ export function sameResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean {
105
+ if (!a || !b) return false
106
+ return (
107
+ a.kind.toLowerCase() === b.kind.toLowerCase() &&
108
+ (a.group ?? '') === (b.group ?? '') &&
109
+ (a.namespace ?? '') === (b.namespace ?? '') &&
110
+ a.name === b.name
111
+ )
112
+ }
@@ -0,0 +1,290 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { UseQueryResult } from '@tanstack/react-query'
3
+ import { clsx } from 'clsx'
4
+ import { Database, X } from 'lucide-react'
5
+ import {
6
+ CNPG_KIND_BY_KEY,
7
+ PaneLoader,
8
+ type CNPGFleet,
9
+ type CNPGKindCoverage,
10
+ type CNPGWorkspaceResponse,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import type { SelectedResource } from '../../types'
13
+ import { useConnection } from '../../context/ConnectionContext'
14
+ import { EmptyState, Notice, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared'
15
+ import { sameResource } from './routes'
16
+
17
+ export interface CNPGScreenProps {
18
+ data: CNPGWorkspaceResponse
19
+ fleet: CNPGFleet
20
+ namespaces: string[]
21
+ searchParams: URLSearchParams
22
+ onSetParams: (update: Record<string, string | null>) => void
23
+ onInspect: (resource: SelectedResource) => void
24
+ inspected: SelectedResource | null
25
+ onClearNamespaces: () => void
26
+ }
27
+
28
+ const COVERAGE_LABEL: Record<string, string> = {
29
+ denied: 'no access',
30
+ partial: 'no access in some namespaces',
31
+ syncing: 'still loading',
32
+ error: 'could not be read',
33
+ }
34
+
35
+ export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
36
+ return (
37
+ <div className="px-5 pt-4 xl:px-7">
38
+ <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
39
+ <Database className="h-3.5 w-3.5" />
40
+ <span>CloudNativePG</span>
41
+ </div>
42
+ <div className="mt-1 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
43
+ <div className="min-w-0">
44
+ <h1 className="text-lg font-semibold text-theme-text-primary">{title}</h1>
45
+ {subtitle && <div className="mt-0.5 text-sm text-theme-text-secondary">{subtitle}</div>}
46
+ </div>
47
+ {actions}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function CoverageNotice({ fleet, data }: { fleet: CNPGFleet; data: CNPGWorkspaceResponse }) {
54
+ if (fleet.incompleteKinds.length === 0) return null
55
+ const parts = fleet.incompleteKinds.map((k) => {
56
+ const cov = data.coverage[k]
57
+ const label = COVERAGE_LABEL[cov?.state ?? ''] ?? cov?.state
58
+ return `${CNPG_KIND_BY_KEY[k].kind} (${label})`
59
+ })
60
+ return (
61
+ <Notice>
62
+ Some CloudNativePG data is not readable: {parts.join(', ')}. Facts built on it read “No access” or “unknown” rather than none, and counts are lower bounds.
63
+ </Notice>
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Loading, error and not-installed states every workspace screen shares.
69
+ * Renders the screen only once there is workspace data to render.
70
+ */
71
+ export function CNPGScreenGate({
72
+ query,
73
+ fleet,
74
+ children,
75
+ }: {
76
+ query: UseQueryResult<CNPGWorkspaceResponse>
77
+ fleet: CNPGFleet | null
78
+ children: (data: CNPGWorkspaceResponse, fleet: CNPGFleet) => ReactNode
79
+ }) {
80
+ const { connection } = useConnection()
81
+ const data = query.data
82
+ if (!data && query.isLoading) return <PaneLoader label="Loading CloudNativePG…" className="flex-1" />
83
+ if (!data) {
84
+ return (
85
+ <EmptyState
86
+ icon={Database}
87
+ title="CloudNativePG workspace unavailable"
88
+ detail={query.error instanceof Error ? query.error.message : 'The workspace could not be loaded.'}
89
+ />
90
+ )
91
+ }
92
+ if (!data.installed || !fleet) {
93
+ return (
94
+ <EmptyState
95
+ icon={Database}
96
+ title="CloudNativePG is not installed"
97
+ detail={`No postgresql.cnpg.io resources are served in ${connection.context || 'this cluster'}.`}
98
+ />
99
+ )
100
+ }
101
+ return <>{children(data, fleet)}</>
102
+ }
103
+
104
+ export function ScreenBody({ children }: { children: ReactNode }) {
105
+ return (
106
+ <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
107
+ <div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
113
+ if (chips.length === 0) return null
114
+ return (
115
+ <div className="flex flex-wrap gap-1.5">
116
+ {chips.map((c) => (
117
+ <span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
118
+ {c.label}
119
+ <button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
120
+ <X className="h-3 w-3" />
121
+ </button>
122
+ </span>
123
+ ))}
124
+ </div>
125
+ )
126
+ }
127
+
128
+ export function namespaceChip(namespaces: string[], onClear: () => void) {
129
+ return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
130
+ }
131
+
132
+ export function Segments<T extends string>({
133
+ value,
134
+ options,
135
+ onChange,
136
+ label,
137
+ }: {
138
+ value: T
139
+ options: { id: T; label: string; count?: number }[]
140
+ onChange: (id: T) => void
141
+ label: string
142
+ }) {
143
+ return (
144
+ <div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
145
+ {options.map((o) => {
146
+ const on = o.id === value
147
+ return (
148
+ <button
149
+ key={o.id}
150
+ type="button"
151
+ role="tab"
152
+ aria-selected={on}
153
+ onClick={() => onChange(o.id)}
154
+ className={clsx(
155
+ 'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
156
+ on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
157
+ )}
158
+ >
159
+ {o.label}
160
+ {o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ export interface TableColumn<T> {
169
+ header: ReactNode
170
+ width?: string
171
+ cell: (row: T) => ReactNode
172
+ className?: string
173
+ }
174
+
175
+ /** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
176
+ export function SectionTable<T>({
177
+ title,
178
+ subtitle,
179
+ columns,
180
+ rows,
181
+ rowKey,
182
+ rowResource,
183
+ onInspect,
184
+ inspected,
185
+ empty,
186
+ minWidth = 760,
187
+ footer,
188
+ }: {
189
+ title: ReactNode
190
+ subtitle?: ReactNode
191
+ columns: TableColumn<T>[]
192
+ rows: T[]
193
+ rowKey: (row: T) => string
194
+ rowResource?: (row: T) => SelectedResource | null
195
+ onInspect?: (resource: SelectedResource) => void
196
+ inspected?: SelectedResource | null
197
+ empty: ReactNode
198
+ minWidth?: number
199
+ footer?: ReactNode
200
+ }) {
201
+ return (
202
+ <section>
203
+ <div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
204
+ <h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
205
+ {subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
206
+ </div>
207
+ <div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
208
+ {rows.length === 0 ? (
209
+ <div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
210
+ ) : (
211
+ <div className={TABLE_WRAP}>
212
+ <table className="w-full table-fixed" style={{ minWidth }}>
213
+ <colgroup>
214
+ {columns.map((c, i) => (
215
+ <col key={i} style={c.width ? { width: c.width } : undefined} />
216
+ ))}
217
+ </colgroup>
218
+ <thead className={TABLE_HEAD}>
219
+ <tr>
220
+ {columns.map((c, i) => (
221
+ <th key={i} className={TH}>{c.header}</th>
222
+ ))}
223
+ </tr>
224
+ </thead>
225
+ <tbody className={TBODY}>
226
+ {rows.map((row) => {
227
+ const res = rowResource?.(row) ?? null
228
+ const active = !!res && sameResource(inspected, res)
229
+ return (
230
+ <tr
231
+ key={rowKey(row)}
232
+ onClick={res && onInspect ? () => onInspect(res) : undefined}
233
+ className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
234
+ aria-selected={res ? active : undefined}
235
+ >
236
+ {columns.map((c, i) => (
237
+ <td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
238
+ ))}
239
+ </tr>
240
+ )
241
+ })}
242
+ </tbody>
243
+ </table>
244
+ </div>
245
+ )}
246
+ </div>
247
+ {footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
248
+ </section>
249
+ )
250
+ }
251
+
252
+ /** Empty-state text for a collection, derived from how much of it was readable. */
253
+ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string): string {
254
+ switch (cov?.state) {
255
+ case 'full':
256
+ return `No ${noun} in this scope.`
257
+ case 'partial':
258
+ return `No ${noun} visible. Some namespaces are not readable with your access.`
259
+ case 'denied':
260
+ return `No access to ${noun}.`
261
+ case 'syncing':
262
+ return `${noun[0].toUpperCase()}${noun.slice(1)} are still loading.`
263
+ case 'error':
264
+ return `${noun[0].toUpperCase()}${noun.slice(1)} could not be read.`
265
+ default:
266
+ return `This kind is not installed.`
267
+ }
268
+ }
269
+
270
+ /** The less complete of two coverages, for collections built from several kinds. */
271
+ export function worstCoverage(...covs: (CNPGKindCoverage | undefined)[]): CNPGKindCoverage | undefined {
272
+ const rank: Record<string, number> = { error: 0, denied: 1, syncing: 2, partial: 3, full: 4, notInstalled: 5 }
273
+ return covs.filter(Boolean).sort((a, b) => (rank[a!.state] ?? 9) - (rank[b!.state] ?? 9))[0]
274
+ }
275
+
276
+ export function Mono({ children }: { children: ReactNode }) {
277
+ return <span className="font-mono text-[12.5px] break-all">{children}</span>
278
+ }
279
+
280
+ export function Sub({ children }: { children: ReactNode }) {
281
+ return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
282
+ }
283
+
284
+ export function clusterResource(namespace: string, name: string): SelectedResource {
285
+ return { kind: 'clusters', group: 'postgresql.cnpg.io', namespace, name }
286
+ }
287
+
288
+ export function cnpgResource(plural: string, namespace: string, name: string, group = 'postgresql.cnpg.io'): SelectedResource {
289
+ return { kind: plural, group, namespace, name }
290
+ }
@@ -0,0 +1,88 @@
1
+ import { useMemo } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { Database, FileCheck2, Settings2, ShieldCheck, Waypoints } from 'lucide-react'
4
+ import { buildCNPGFleet, type CNPGFleet, type SidebarCategoryWorkspace } from '@skyhook-io/k8s-ui'
5
+ import type { APIResource } from '../../types'
6
+ import { useCNPGWorkspace } from '../../api/cnpg'
7
+ import { CNPG_SCREENS, type CNPGScreen } from './routes'
8
+
9
+ export const CNPG_SIDEBAR_CATEGORY = 'CloudNativePG'
10
+
11
+ const ICONS: Record<CNPGScreen, SidebarCategoryWorkspace['destinations'][number]['icon']> = {
12
+ overview: Database,
13
+ protection: ShieldCheck,
14
+ declarations: FileCheck2,
15
+ pooling: Waypoints,
16
+ operator: Settings2,
17
+ }
18
+
19
+ export function cnpgDiscovered(apiResources: APIResource[] | undefined): boolean {
20
+ return !!apiResources?.some((r) => r.group === 'postgresql.cnpg.io')
21
+ }
22
+
23
+ export function useCNPGFleet(namespaces: string[], enabled = true) {
24
+ const query = useCNPGWorkspace(namespaces, { enabled })
25
+ const fleet = useMemo<CNPGFleet | null>(() => (query.data?.installed ? buildCNPGFleet(query.data) : null), [query.data])
26
+ return { query, fleet }
27
+ }
28
+
29
+ function destinationCount(screen: CNPGScreen, fleet: CNPGFleet | null): { count?: number | null; title?: string } {
30
+ if (!fleet) return {}
31
+ const lowerBound = fleet.incompleteKinds.length > 0 ? ' Some CloudNativePG data is not readable, so this is a lower bound.' : ''
32
+ switch (screen) {
33
+ case 'overview':
34
+ return { count: fleet.attentionCount, title: `${fleet.attentionCount} clusters need attention.${lowerBound}` }
35
+ case 'protection':
36
+ return { count: fleet.categoryCounts.protection, title: `${fleet.categoryCounts.protection} clusters with failing backups or WAL archiving.${lowerBound}` }
37
+ case 'declarations':
38
+ return { count: fleet.categoryCounts.declarations, title: `${fleet.categoryCounts.declarations} clusters with declarations that are not reconciled.${lowerBound}` }
39
+ case 'pooling':
40
+ return { count: fleet.categoryCounts.pooling, title: `${fleet.categoryCounts.pooling} clusters with Pooler problems.${lowerBound}` }
41
+ default:
42
+ return {}
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The CloudNativePG workspace entries for the Resources sidebar. Returns
48
+ * undefined when CNPG is not discovered, so clusters without the operator see
49
+ * an unchanged sidebar.
50
+ */
51
+ export function useCNPGSidebarWorkspace({
52
+ apiResources,
53
+ namespaces,
54
+ active,
55
+ }: {
56
+ apiResources: APIResource[] | undefined
57
+ namespaces: string[]
58
+ active?: { screen: CNPGScreen; child?: { label: string; title?: string } }
59
+ }): Record<string, SidebarCategoryWorkspace> | undefined {
60
+ const navigate = useNavigate()
61
+ const discovered = cnpgDiscovered(apiResources)
62
+ const { fleet } = useCNPGFleet(namespaces, discovered)
63
+ const nsKey = namespaces.join(',')
64
+
65
+ return useMemo(() => {
66
+ if (!discovered) return undefined
67
+ const destinations = CNPG_SCREENS.map((s) => {
68
+ const { count, title } = destinationCount(s.id, fleet)
69
+ return {
70
+ id: s.id,
71
+ label: s.label,
72
+ icon: ICONS[s.id],
73
+ count,
74
+ countTitle: title,
75
+ active: active?.screen === s.id,
76
+ child: active?.screen === s.id ? active.child : undefined,
77
+ onSelect: () => navigate(s.path),
78
+ }
79
+ })
80
+ return {
81
+ [CNPG_SIDEBAR_CATEGORY]: {
82
+ destinations,
83
+ defaultKindsCollapsed: !!active,
84
+ scopeNote: nsKey ? `Counts for namespace ${nsKey.split(',').join(', ')}` : undefined,
85
+ },
86
+ }
87
+ }, [discovered, fleet, active?.screen, active?.child?.label, active?.child?.title, navigate, nsKey]) // eslint-disable-line react-hooks/exhaustive-deps
88
+ }
@@ -3,7 +3,7 @@ import { Search, RefreshCw, Package, Database, AlertCircle, ExternalLink, Chevro
3
3
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
5
5
  import { useHelmRepositories, useSearchCharts, useUpdateRepository, useUpdateRepositorySilent, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
6
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
6
+ import { useCanHelmWrite, useIsAuthEnabled } from '../../contexts/CapabilitiesContext'
7
7
  import type { ChartInfo, HelmRepository, ArtifactHubChart, ChartSource } from '../../types'
8
8
  import { formatAge } from './helm-utils'
9
9
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
@@ -25,12 +25,11 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
25
25
  const [showVerifiedOnly, setShowVerifiedOnly] = useState(false)
26
26
  const [artifactHubSort, setArtifactHubSort] = useState<ArtifactHubSortOption>('relevance')
27
27
 
28
- // Repo refresh is gated only by `requireHelmWrite` on the backend
29
- // (handleUpdateRepository deliberately skips requireCloudRole — it
30
- // mutates pod-local chart cache, not cluster state). So the frontend gate
31
- // here must NOT include the Cloud role check, or Cloud viewers with
32
- // rbac.helm=true would be blocked from a refresh the backend allows.
33
- const canHelmWrite = useCanHelmWrite()
28
+ // Refreshing repositories only touches Radar's own chart cache. With auth on,
29
+ // any signed-in user may do it; without auth it follows rbac.helm.
30
+ const helmWrite = useCanHelmWrite()
31
+ const authEnabled = useIsAuthEnabled()
32
+ const canHelmWrite = helmWrite || authEnabled
34
33
  const helmWriteReason = canHelmWrite ? '' : 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.'
35
34
 
36
35
  // Local repo hooks