@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
|
@@ -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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|