@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,320 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
FactValue,
|
|
5
|
+
formatAge,
|
|
6
|
+
formatDuration,
|
|
7
|
+
getCNPGBackupStatus,
|
|
8
|
+
getCNPGClusterBarmanPlugin,
|
|
9
|
+
getCNPGObjectStoreDestination,
|
|
10
|
+
getCNPGScheduledBackupStatus,
|
|
11
|
+
inferredObjectStoreHealth,
|
|
12
|
+
isApiGroup,
|
|
13
|
+
toneTextClass,
|
|
14
|
+
usersOfObjectStore,
|
|
15
|
+
type CNPGFleetRow,
|
|
16
|
+
type HealthLevel,
|
|
17
|
+
} from '@skyhook-io/k8s-ui'
|
|
18
|
+
import {
|
|
19
|
+
CNPGWorkspaceHeader,
|
|
20
|
+
CoverageNotice,
|
|
21
|
+
FilterChips,
|
|
22
|
+
Mono,
|
|
23
|
+
ScreenBody,
|
|
24
|
+
SectionTable,
|
|
25
|
+
Sub,
|
|
26
|
+
clusterResource,
|
|
27
|
+
coverageEmpty,
|
|
28
|
+
cnpgResource,
|
|
29
|
+
namespaceChip,
|
|
30
|
+
type CNPGScreenProps,
|
|
31
|
+
} from './shared'
|
|
32
|
+
|
|
33
|
+
const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' | 'neutral'> = {
|
|
34
|
+
healthy: 'success',
|
|
35
|
+
degraded: 'warning',
|
|
36
|
+
alert: 'alert',
|
|
37
|
+
unhealthy: 'error',
|
|
38
|
+
unknown: 'neutral',
|
|
39
|
+
neutral: 'neutral',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
function backupTime(b: any): string | undefined {
|
|
45
|
+
return b?.status?.stoppedAt || b?.status?.startedAt || b?.metadata?.creationTimestamp
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function backupStart(b: any): string | undefined {
|
|
49
|
+
return b?.status?.startedAt || b?.metadata?.creationTimestamp
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ageText(ts?: string): string {
|
|
53
|
+
return ts ? `${formatAge(ts)} ago` : '—'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StoreRow {
|
|
57
|
+
key: string
|
|
58
|
+
namespace: string
|
|
59
|
+
name: string
|
|
60
|
+
destination: string
|
|
61
|
+
users: CNPGFleetRow[]
|
|
62
|
+
health: { text: string; tone: HealthLevel; evidence: string }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The same inference the ObjectStore summary shows, so the two never disagree:
|
|
66
|
+
// only the recovery windows of clusters that use the store now count.
|
|
67
|
+
function storeHealth(store: any, users: CNPGFleetRow[]): StoreRow['health'] {
|
|
68
|
+
const { summary, evidence } = inferredObjectStoreHealth(store, usersOfObjectStore(store, users.map((u) => u.cluster)))
|
|
69
|
+
const names = (list: typeof evidence) => list.map((e) => e.cluster.name).join(', ')
|
|
70
|
+
if (evidence.length === 0) return { text: 'Unknown', tone: 'unknown', evidence: summary.text }
|
|
71
|
+
if (summary.tone === 'unhealthy') {
|
|
72
|
+
const archiving = evidence.filter((e) => e.archiving.tone === 'unhealthy')
|
|
73
|
+
const backups = evidence.filter((e) => e.window?.failingSinceLastSuccess)
|
|
74
|
+
const parts = [
|
|
75
|
+
archiving.length > 0 ? `WAL archiving failing on ${names(archiving)}` : null,
|
|
76
|
+
backups.length > 0 ? `a backup failed after the last success for ${names(backups)}` : null,
|
|
77
|
+
].filter(Boolean)
|
|
78
|
+
return { text: summary.text, tone: summary.tone, evidence: `Inferred: ${parts.join('; ')}` }
|
|
79
|
+
}
|
|
80
|
+
const archiving = evidence.filter((e) => e.archiving.tone === 'healthy')
|
|
81
|
+
if (summary.tone === 'healthy') {
|
|
82
|
+
return { text: summary.text, tone: summary.tone, evidence: `Inferred from WAL archiving on ${names(archiving)}` }
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
text: summary.text,
|
|
86
|
+
tone: summary.tone,
|
|
87
|
+
evidence: archiving.length > 0
|
|
88
|
+
? `Archiving on ${names(archiving)}; no archiving result from the others`
|
|
89
|
+
: 'Its clusters report no archiving result yet',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CNPGProtection({
|
|
94
|
+
data,
|
|
95
|
+
fleet,
|
|
96
|
+
namespaces,
|
|
97
|
+
searchParams,
|
|
98
|
+
onSetParams,
|
|
99
|
+
onInspect,
|
|
100
|
+
inspected,
|
|
101
|
+
onClearNamespaces,
|
|
102
|
+
scopeCluster,
|
|
103
|
+
}: CNPGScreenProps & { scopeCluster?: { namespace: string; name: string } }) {
|
|
104
|
+
const clusterFilter = scopeCluster ? `${scopeCluster.namespace}/${scopeCluster.name}` : searchParams.get('cluster')
|
|
105
|
+
const rows = useMemo(
|
|
106
|
+
() => fleet.rows.filter((r) => !clusterFilter || `${r.namespace}/${r.name}` === clusterFilter),
|
|
107
|
+
[fleet.rows, clusterFilter],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const failed = useMemo(() => {
|
|
111
|
+
const now = Date.now()
|
|
112
|
+
return (data.objects.backups ?? [])
|
|
113
|
+
.filter((b) => isApiGroup(b.apiVersion, 'postgresql.cnpg.io'))
|
|
114
|
+
.filter((b) => {
|
|
115
|
+
const level = getCNPGBackupStatus(b).level
|
|
116
|
+
if (level !== 'unhealthy' && level !== 'alert') return false
|
|
117
|
+
const t = Date.parse(backupTime(b) ?? '')
|
|
118
|
+
return Number.isFinite(t) && now - t <= WEEK_MS
|
|
119
|
+
})
|
|
120
|
+
.filter((b) => !clusterFilter || `${b.metadata?.namespace}/${b.spec?.cluster?.name}` === clusterFilter)
|
|
121
|
+
.sort((a, b) => Date.parse(backupTime(b) ?? '') - Date.parse(backupTime(a) ?? ''))
|
|
122
|
+
}, [data.objects.backups, clusterFilter])
|
|
123
|
+
|
|
124
|
+
const stores = useMemo<StoreRow[]>(() => {
|
|
125
|
+
return (data.objects.objectStores ?? []).map((s) => {
|
|
126
|
+
const ns = s.metadata?.namespace ?? ''
|
|
127
|
+
const name = s.metadata?.name ?? ''
|
|
128
|
+
const users = fleet.rows.filter(
|
|
129
|
+
(r) => r.namespace === ns && getCNPGClusterBarmanPlugin(r.cluster)?.barmanObjectName === name,
|
|
130
|
+
)
|
|
131
|
+
return { key: `${ns}/${name}`, namespace: ns, name, destination: getCNPGObjectStoreDestination(s), users, health: storeHealth(s, users) }
|
|
132
|
+
}).filter((s) => !clusterFilter || s.users.some((u) => `${u.namespace}/${u.name}` === clusterFilter))
|
|
133
|
+
}, [data.objects.objectStores, fleet.rows, clusterFilter])
|
|
134
|
+
|
|
135
|
+
const schedules = useMemo(
|
|
136
|
+
() =>
|
|
137
|
+
(data.objects.scheduledBackups ?? []).filter(
|
|
138
|
+
(s) => !clusterFilter || `${s.metadata?.namespace}/${s.spec?.cluster?.name}` === clusterFilter,
|
|
139
|
+
),
|
|
140
|
+
[data.objects.scheduledBackups, clusterFilter],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const chips = scopeCluster ? [] : [
|
|
144
|
+
...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
|
|
145
|
+
...namespaceChip(namespaces, onClearNamespaces),
|
|
146
|
+
]
|
|
147
|
+
const backupsReadable = data.coverage.backups?.state === 'full'
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
151
|
+
{!scopeCluster && (
|
|
152
|
+
<CNPGWorkspaceHeader
|
|
153
|
+
title="Protection"
|
|
154
|
+
subtitle="Backup outcomes, schedules, destinations and recovery evidence for every cluster. Configured, backed up and restore-tested are separate facts."
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
<ScreenBody>
|
|
158
|
+
<CoverageNotice fleet={fleet} data={data} />
|
|
159
|
+
<FilterChips chips={chips} />
|
|
160
|
+
|
|
161
|
+
<SectionTable
|
|
162
|
+
title={scopeCluster ? 'Recovery evidence' : 'Recovery evidence by cluster'}
|
|
163
|
+
columns={[
|
|
164
|
+
{
|
|
165
|
+
header: 'Cluster',
|
|
166
|
+
width: '14%',
|
|
167
|
+
cell: (r: CNPGFleetRow) => (
|
|
168
|
+
<>
|
|
169
|
+
<div className="truncate font-medium">{r.name}</div>
|
|
170
|
+
<Sub>{r.namespace}</Sub>
|
|
171
|
+
</>
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{ header: 'Schedule', width: '13%', cell: (r) => <FactValue fact={r.protection.schedule} /> },
|
|
175
|
+
{
|
|
176
|
+
header: 'Last successful backup',
|
|
177
|
+
width: '16%',
|
|
178
|
+
cell: (r) => (
|
|
179
|
+
<>
|
|
180
|
+
<FactValue fact={r.protection.lastSuccessfulBackup} />
|
|
181
|
+
{r.protection.lastSuccessfulBackup.source && <Sub>{r.protection.lastSuccessfulBackup.source}</Sub>}
|
|
182
|
+
</>
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{ header: 'WAL archiving', width: '16%', cell: (r) => <FactValue fact={r.protection.walArchiving} className="line-clamp-2 break-words" /> },
|
|
186
|
+
{
|
|
187
|
+
header: 'Recovery window',
|
|
188
|
+
width: '13%',
|
|
189
|
+
cell: (r) =>
|
|
190
|
+
r.protection.recoveryWindow.from ? (
|
|
191
|
+
<>
|
|
192
|
+
<span className={toneTextClass(r.protection.recoveryWindow.tone)}>
|
|
193
|
+
from {ageText(r.protection.recoveryWindow.from)}
|
|
194
|
+
</span>
|
|
195
|
+
<Sub>
|
|
196
|
+
{r.protection.recoveryWindow.tone === 'degraded' ? 'not advancing: archiving failing' : 'to the newest archived WAL'}
|
|
197
|
+
</Sub>
|
|
198
|
+
</>
|
|
199
|
+
) : (
|
|
200
|
+
<FactValue fact={r.protection.recoveryWindow} />
|
|
201
|
+
),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
header: 'Restore validation',
|
|
205
|
+
width: '13%',
|
|
206
|
+
cell: (r) => (
|
|
207
|
+
<FactValue fact={r.protection.restoreValidation} />
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
header: 'Destination',
|
|
212
|
+
width: '15%',
|
|
213
|
+
cell: (r) => <FactValue fact={r.protection.destination} className={r.protection.destination.method === 'barmanObjectStore' ? 'break-all font-mono text-[12.5px]' : 'break-words'} />,
|
|
214
|
+
},
|
|
215
|
+
]}
|
|
216
|
+
rows={rows}
|
|
217
|
+
rowKey={(r) => r.key}
|
|
218
|
+
rowResource={(r) => clusterResource(r.namespace, r.name)}
|
|
219
|
+
onInspect={onInspect}
|
|
220
|
+
inspected={inspected}
|
|
221
|
+
minWidth={1000}
|
|
222
|
+
empty={coverageEmpty(data.coverage.clusters, 'PostgreSQL clusters')}
|
|
223
|
+
footer="Kubernetes records no restore tests, so restore validation is never shown as passed. Recovery windows come from ObjectStore status."
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
<SectionTable
|
|
227
|
+
title="Failed backups"
|
|
228
|
+
subtitle="last 7 days"
|
|
229
|
+
columns={[
|
|
230
|
+
{ header: 'Backup', width: '28%', cell: (b: any) => <Mono>{b.metadata?.name}</Mono> },
|
|
231
|
+
{ header: 'Cluster', width: '16%', cell: (b) => <>{b.spec?.cluster?.name ?? '—'}<Sub>{b.metadata?.namespace}</Sub></> },
|
|
232
|
+
{ header: 'Started', width: '12%', cell: (b) => ageText(backupStart(b)) },
|
|
233
|
+
{
|
|
234
|
+
header: 'Error',
|
|
235
|
+
width: '44%',
|
|
236
|
+
cell: (b) => <span className={toneTextClass('unhealthy')}>{b.status?.error || getCNPGBackupStatus(b).text}</span>,
|
|
237
|
+
},
|
|
238
|
+
]}
|
|
239
|
+
rows={failed}
|
|
240
|
+
rowKey={(b) => `${b.metadata?.namespace}/${b.metadata?.name}`}
|
|
241
|
+
rowResource={(b) => cnpgResource('backups', b.metadata?.namespace, b.metadata?.name)}
|
|
242
|
+
onInspect={onInspect}
|
|
243
|
+
inspected={inspected}
|
|
244
|
+
empty={backupsReadable && data.coverage.backups?.state === 'full' ? 'No failed backups in the last 7 days.' : coverageEmpty(data.coverage.backups, 'failed backups')}
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
<SectionTable
|
|
248
|
+
title="Destinations"
|
|
249
|
+
subtitle="ObjectStores (barman-cloud plugin)"
|
|
250
|
+
columns={[
|
|
251
|
+
{ header: 'ObjectStore', width: '18%', cell: (s: StoreRow) => <>{s.name}<Sub>{s.namespace}</Sub></> },
|
|
252
|
+
{ header: 'Destination', width: '30%', cell: (s) => <Mono>{s.destination}</Mono> },
|
|
253
|
+
{ header: 'Used by', width: '20%', cell: (s) => (s.users.length ? s.users.map((u) => u.name).join(', ') : <span className="text-theme-text-tertiary">None visible</span>) },
|
|
254
|
+
{
|
|
255
|
+
header: 'Upload health (inferred)',
|
|
256
|
+
width: '32%',
|
|
257
|
+
cell: (s) => (
|
|
258
|
+
<>
|
|
259
|
+
<Badge severity={SEVERITY[s.health.tone]} size="sm">{s.health.text}</Badge>
|
|
260
|
+
<Sub>{s.health.evidence}</Sub>
|
|
261
|
+
</>
|
|
262
|
+
),
|
|
263
|
+
},
|
|
264
|
+
]}
|
|
265
|
+
rows={stores}
|
|
266
|
+
rowKey={(s) => s.key}
|
|
267
|
+
rowResource={(s) => cnpgResource('objectstores', s.namespace, s.name, 'barmancloud.cnpg.io')}
|
|
268
|
+
onInspect={onInspect}
|
|
269
|
+
inspected={inspected}
|
|
270
|
+
empty={data.coverage.objectStores?.state === 'notInstalled' ? 'The barman-cloud plugin’s ObjectStore kind is not installed.' : coverageEmpty(data.coverage.objectStores, 'ObjectStores')}
|
|
271
|
+
footer="ObjectStore has no health status of its own; upload health is inferred from its clusters’ WAL archiving and backup results."
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
<SectionTable
|
|
275
|
+
title="Schedules"
|
|
276
|
+
columns={[
|
|
277
|
+
{ header: 'ScheduledBackup', width: '24%', cell: (s: any) => <>{s.metadata?.name}<Sub>{s.metadata?.namespace}</Sub></> },
|
|
278
|
+
{ header: 'Cluster', width: '16%', cell: (s) => s.spec?.cluster?.name ?? '—' },
|
|
279
|
+
{
|
|
280
|
+
header: 'Schedule',
|
|
281
|
+
width: '24%',
|
|
282
|
+
cell: (s) => (
|
|
283
|
+
<>
|
|
284
|
+
<Mono>{s.spec?.schedule ?? '—'}</Mono>
|
|
285
|
+
<Sub>CNPG cron, seconds first</Sub>
|
|
286
|
+
</>
|
|
287
|
+
),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
header: 'Status',
|
|
291
|
+
width: '12%',
|
|
292
|
+
cell: (s) => {
|
|
293
|
+
const st = getCNPGScheduledBackupStatus(s)
|
|
294
|
+
return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
{ header: 'Last run', width: '12%', cell: (s) => ageText(s.status?.lastScheduleTime) },
|
|
298
|
+
{
|
|
299
|
+
header: 'Next run',
|
|
300
|
+
width: '12%',
|
|
301
|
+
cell: (s) => {
|
|
302
|
+
const next = s.status?.nextScheduleTime
|
|
303
|
+
if (!next) return '—'
|
|
304
|
+
const ms = Date.parse(next) - Date.now()
|
|
305
|
+
return ms >= 0 ? `in ${formatDuration(ms)}` : `${formatDuration(-ms)} overdue`
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
]}
|
|
309
|
+
rows={schedules}
|
|
310
|
+
rowKey={(s) => `${s.metadata?.namespace}/${s.metadata?.name}`}
|
|
311
|
+
rowResource={(s) => cnpgResource('scheduledbackups', s.metadata?.namespace, s.metadata?.name)}
|
|
312
|
+
onInspect={onInspect}
|
|
313
|
+
inspected={inspected}
|
|
314
|
+
empty={coverageEmpty(data.coverage.scheduledBackups, 'ScheduledBackups')}
|
|
315
|
+
footer={data.backupsOmitted > 0 ? `${data.backupsOmitted} settled backups older than 7 days are not listed.` : undefined}
|
|
316
|
+
/>
|
|
317
|
+
</ScreenBody>
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
CNPG_BARMAN_OBJECTSTORE_GROUP,
|
|
5
|
+
CNPG_GROUP,
|
|
6
|
+
CNPGBackupSummary,
|
|
7
|
+
CNPGClusterSummary,
|
|
8
|
+
CNPGDatabaseSummary,
|
|
9
|
+
CNPGImageCatalogSummary,
|
|
10
|
+
CNPGObjectStoreSummary,
|
|
11
|
+
CNPGPoolerSummary,
|
|
12
|
+
CNPGPublicationSummary,
|
|
13
|
+
CNPGScheduledBackupSummary,
|
|
14
|
+
CNPGSubscriptionSummary,
|
|
15
|
+
PaneLoader,
|
|
16
|
+
isApiGroup,
|
|
17
|
+
refToSelectedResource,
|
|
18
|
+
type CNPGNavigate,
|
|
19
|
+
type CNPGRef,
|
|
20
|
+
type CNPGWorkspaceResponse,
|
|
21
|
+
type NavigateToResource,
|
|
22
|
+
} from '@skyhook-io/k8s-ui'
|
|
23
|
+
import { useCNPGFleet } from './useCNPGSidebarWorkspace'
|
|
24
|
+
import { cnpgClusterFullPath, currentPageLabel } from './paths'
|
|
25
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
26
|
+
|
|
27
|
+
interface SummaryContext {
|
|
28
|
+
apiKind: string
|
|
29
|
+
namespace: string
|
|
30
|
+
name: string
|
|
31
|
+
group?: string
|
|
32
|
+
resource: any
|
|
33
|
+
context: 'drawer' | 'expanded'
|
|
34
|
+
onNavigate?: NavigateToResource
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ClusterSummaryHost({ namespace, name, context, onNavigate }: SummaryContext) {
|
|
38
|
+
const navigate = useNavigate()
|
|
39
|
+
const { connection } = useConnection()
|
|
40
|
+
// The workspace is read for the object's own namespace: an explicitly opened
|
|
41
|
+
// Cluster shows its facts whatever the namespace filter is.
|
|
42
|
+
const { query, fleet } = useCNPGFleet([namespace])
|
|
43
|
+
const row = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)
|
|
44
|
+
if (!row) {
|
|
45
|
+
if (query.isLoading) return <PaneLoader label="Loading summary…" className="h-40" />
|
|
46
|
+
return (
|
|
47
|
+
<div className="px-4 py-4 text-sm text-theme-text-secondary">
|
|
48
|
+
{query.error instanceof Error
|
|
49
|
+
? `The CloudNativePG summary could not be loaded: ${query.error.message}`
|
|
50
|
+
: 'This Cluster is not in the CloudNativePG workspace for your identity.'}{' '}
|
|
51
|
+
Spec & status still shows everything the object reports.
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const go = onNavigate ? (ref: CNPGRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
56
|
+
return (
|
|
57
|
+
<CNPGClusterSummary
|
|
58
|
+
row={row}
|
|
59
|
+
onNavigate={go}
|
|
60
|
+
actions={
|
|
61
|
+
context === 'drawer'
|
|
62
|
+
? [
|
|
63
|
+
{
|
|
64
|
+
label: 'Open cluster',
|
|
65
|
+
primary: true,
|
|
66
|
+
onClick: () =>
|
|
67
|
+
navigate(cnpgClusterFullPath(namespace, name, connection.context || undefined), {
|
|
68
|
+
state: { returnLabel: currentPageLabel(), returnCtx: connection.context },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: 'Logs',
|
|
73
|
+
onClick: () =>
|
|
74
|
+
navigate(cnpgClusterFullPath(namespace, name, connection.context || undefined, 'logs'), {
|
|
75
|
+
state: { returnLabel: currentPageLabel(), returnCtx: connection.context },
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
label: 'Protection',
|
|
80
|
+
onClick: () =>
|
|
81
|
+
navigate(cnpgClusterFullPath(namespace, name, connection.context || undefined, 'protection'), {
|
|
82
|
+
state: { returnLabel: currentPageLabel(), returnCtx: connection.context },
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
: undefined
|
|
87
|
+
}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ObjectSummary = (props: { resource: any; workspace: CNPGWorkspaceResponse | null; onNavigate?: CNPGNavigate }) => ReactNode
|
|
93
|
+
|
|
94
|
+
const OBJECT_SUMMARIES: Record<string, ObjectSummary> = {
|
|
95
|
+
Backup: CNPGBackupSummary,
|
|
96
|
+
ScheduledBackup: CNPGScheduledBackupSummary,
|
|
97
|
+
Pooler: CNPGPoolerSummary,
|
|
98
|
+
Database: CNPGDatabaseSummary,
|
|
99
|
+
Publication: CNPGPublicationSummary,
|
|
100
|
+
Subscription: CNPGSubscriptionSummary,
|
|
101
|
+
ImageCatalog: CNPGImageCatalogSummary,
|
|
102
|
+
ClusterImageCatalog: CNPGImageCatalogSummary,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ObjectSummaryHost({ ctx, Summary }: { ctx: SummaryContext; Summary: ObjectSummary }) {
|
|
106
|
+
// A ClusterImageCatalog is referenced from any namespace, so its users are
|
|
107
|
+
// read across every namespace the caller can see.
|
|
108
|
+
const clusterScoped = ctx.resource?.kind === 'ClusterImageCatalog'
|
|
109
|
+
const { query } = useCNPGFleet(clusterScoped ? [] : [ctx.namespace])
|
|
110
|
+
if (query.isLoading) return <PaneLoader label="Loading summary…" className="h-40" />
|
|
111
|
+
const workspace = query.data?.installed ? query.data : null
|
|
112
|
+
const go = ctx.onNavigate ? (ref: CNPGRef) => ctx.onNavigate?.(refToSelectedResource(ref)) : undefined
|
|
113
|
+
return <Summary resource={ctx.resource} workspace={workspace} onNavigate={go} />
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The object's own apiVersion decides: Velero also ships a Backup kind.
|
|
117
|
+
function groupOf(ctx: SummaryContext): string | undefined {
|
|
118
|
+
const apiVersion = ctx.resource?.apiVersion
|
|
119
|
+
if (typeof apiVersion !== 'string') return ctx.group
|
|
120
|
+
if (isApiGroup(apiVersion, CNPG_GROUP)) return CNPG_GROUP
|
|
121
|
+
if (isApiGroup(apiVersion, CNPG_BARMAN_OBJECTSTORE_GROUP)) return CNPG_BARMAN_OBJECTSTORE_GROUP
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderSummaryFor(ctx: SummaryContext): ReactNode {
|
|
126
|
+
const group = groupOf(ctx)
|
|
127
|
+
const kind = ctx.resource?.kind
|
|
128
|
+
if (group === CNPG_BARMAN_OBJECTSTORE_GROUP && kind === 'ObjectStore') {
|
|
129
|
+
return <ObjectSummaryHost ctx={ctx} Summary={CNPGObjectStoreSummary} />
|
|
130
|
+
}
|
|
131
|
+
if (group !== CNPG_GROUP) return null
|
|
132
|
+
if (kind === 'Cluster') return <ClusterSummaryHost {...ctx} />
|
|
133
|
+
const Summary = OBJECT_SUMMARIES[kind]
|
|
134
|
+
return Summary ? <ObjectSummaryHost ctx={ctx} Summary={Summary} /> : null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The composed Overview for CloudNativePG kinds. Returns null for kinds
|
|
139
|
+
* without one, which keeps the default Overview.
|
|
140
|
+
*/
|
|
141
|
+
export function renderCNPGSummary(ctx: SummaryContext): ReactNode {
|
|
142
|
+
return renderSummaryFor(ctx)
|
|
143
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { ResourcesSidebar, type SelectedKindInfo } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import type { SelectedResource } from '../../types'
|
|
5
|
+
import { useAPIResources } from '../../api/apiResources'
|
|
6
|
+
import { usePinnedKinds } from '../../hooks/useFavorites'
|
|
7
|
+
import { useResourceCounts } from '../../hooks/useResourceCounts'
|
|
8
|
+
import { CNPGOverview } from './CNPGOverview'
|
|
9
|
+
import { CNPGProtection } from './CNPGProtection'
|
|
10
|
+
import { CNPGDeclarations } from './CNPGDeclarations'
|
|
11
|
+
import { CNPGPooling } from './CNPGPooling'
|
|
12
|
+
import { CNPGOperator } from './CNPGOperator'
|
|
13
|
+
import { CNPGScreenGate } from './shared'
|
|
14
|
+
import { CNPGDetailPage } from './CNPGDetailPage'
|
|
15
|
+
import { decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes'
|
|
16
|
+
import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace'
|
|
17
|
+
|
|
18
|
+
interface CNPGViewProps {
|
|
19
|
+
namespaces: string[]
|
|
20
|
+
selectedResource: SelectedResource | null
|
|
21
|
+
onOpenResource: (resource: SelectedResource) => void
|
|
22
|
+
onCloseResource: () => void
|
|
23
|
+
onClearNamespaces: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The CloudNativePG workspace. Lives inside Resources (the rail keeps
|
|
28
|
+
* Resources highlighted) and renders the same Resources sidebar, with the
|
|
29
|
+
* workspace destinations above the exact CNPG kinds.
|
|
30
|
+
*
|
|
31
|
+
* The drawer is the app's single resource drawer. `?drawer=` backs it so a
|
|
32
|
+
* refresh, a shared link and browser Back restore the inspected object.
|
|
33
|
+
*/
|
|
34
|
+
export function CNPGView({ namespaces, selectedResource, onOpenResource, onCloseResource, onClearNamespaces }: CNPGViewProps) {
|
|
35
|
+
const location = useLocation()
|
|
36
|
+
const navigate = useNavigate()
|
|
37
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
38
|
+
const route = parseCNPGRoute(location.pathname)
|
|
39
|
+
const { data: apiResources } = useAPIResources()
|
|
40
|
+
const { data: counts } = useResourceCounts(namespaces)
|
|
41
|
+
const { pinned, togglePin, isPinned } = usePinnedKinds()
|
|
42
|
+
const sidebarWorkspace = useCNPGSidebarWorkspace({
|
|
43
|
+
apiResources,
|
|
44
|
+
namespaces,
|
|
45
|
+
active: { screen: route.screen, child: route.detail ? { label: route.detail.name, title: `${route.detail.plural} ${route.detail.namespace}/${route.detail.name}` } : undefined },
|
|
46
|
+
})
|
|
47
|
+
const { query, fleet } = useCNPGFleet(namespaces)
|
|
48
|
+
|
|
49
|
+
const drawerParam = searchParams.get('drawer')
|
|
50
|
+
const trail = useMemo(() => decodeDrawerTrail(drawerParam), [drawerParam])
|
|
51
|
+
const drawerTarget = trail.length > 0 ? trail[trail.length - 1] : null
|
|
52
|
+
|
|
53
|
+
// Two-way sync between ?drawer= and the app drawer. Whichever side changed
|
|
54
|
+
// since the last sync wins, so URL navigation (Back, a pasted link) opens the
|
|
55
|
+
// drawer and drawer navigation (close, a link inside it) rewrites the URL.
|
|
56
|
+
const lastSynced = useRef<string | null>(null)
|
|
57
|
+
const selectedKey = selectedResource ? encodeDrawerTrail([selectedResource]) : ''
|
|
58
|
+
const targetKey = drawerTarget ? encodeDrawerTrail([drawerTarget]) : ''
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (targetKey !== (lastSynced.current ?? '')) {
|
|
61
|
+
lastSynced.current = targetKey
|
|
62
|
+
if (drawerTarget && !sameResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget)
|
|
63
|
+
else if (!drawerTarget && selectedResource) onCloseResource()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (selectedKey !== targetKey) {
|
|
67
|
+
lastSynced.current = selectedKey
|
|
68
|
+
const params = new URLSearchParams(searchParams)
|
|
69
|
+
if (!selectedResource) {
|
|
70
|
+
params.delete('drawer')
|
|
71
|
+
} else {
|
|
72
|
+
const idx = trail.findIndex((r) => sameResource(r, selectedResource))
|
|
73
|
+
const next = idx >= 0 ? trail.slice(0, idx + 1) : [...trail, selectedResource]
|
|
74
|
+
params.set('drawer', encodeDrawerTrail(next))
|
|
75
|
+
}
|
|
76
|
+
setSearchParams(params, { replace: true, state: location.state })
|
|
77
|
+
}
|
|
78
|
+
}, [targetKey, selectedKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
79
|
+
|
|
80
|
+
const inspect = useCallback(
|
|
81
|
+
(resource: SelectedResource) => {
|
|
82
|
+
const params = new URLSearchParams(searchParams)
|
|
83
|
+
params.set('drawer', encodeDrawerTrail([resource]))
|
|
84
|
+
setSearchParams(params, { replace: true, state: location.state })
|
|
85
|
+
},
|
|
86
|
+
[searchParams, setSearchParams, location.state],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const setParams = useCallback(
|
|
90
|
+
(update: Record<string, string | null>) => {
|
|
91
|
+
const params = new URLSearchParams(searchParams)
|
|
92
|
+
for (const [k, v] of Object.entries(update)) {
|
|
93
|
+
if (v === null || v === '') params.delete(k)
|
|
94
|
+
else params.set(k, v)
|
|
95
|
+
}
|
|
96
|
+
setSearchParams(params, { replace: true, state: location.state })
|
|
97
|
+
},
|
|
98
|
+
[searchParams, setSearchParams, location.state],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const selectKind = useCallback(
|
|
102
|
+
(kind: SelectedKindInfo) => {
|
|
103
|
+
navigate(`/resources/${kind.name}${kind.group ? `?apiGroup=${encodeURIComponent(kind.group)}` : ''}`)
|
|
104
|
+
},
|
|
105
|
+
[navigate],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex h-full min-h-0 w-full">
|
|
110
|
+
<ResourcesSidebar
|
|
111
|
+
selectedKind={null}
|
|
112
|
+
onSelectedKindChange={selectKind}
|
|
113
|
+
apiResources={apiResources}
|
|
114
|
+
resourceCounts={counts?.counts}
|
|
115
|
+
resourceForbidden={counts?.forbidden}
|
|
116
|
+
resourceUnavailable={counts?.unavailable}
|
|
117
|
+
pinned={pinned}
|
|
118
|
+
togglePin={togglePin}
|
|
119
|
+
isPinned={(kind: string, group?: string) => isPinned(kind, group ?? '')}
|
|
120
|
+
categoryWorkspaces={sidebarWorkspace}
|
|
121
|
+
/>
|
|
122
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-theme-base">
|
|
123
|
+
{route.detail ? (
|
|
124
|
+
<CNPGDetailPage target={route.detail} namespaces={namespaces} onOpenResource={onOpenResource} />
|
|
125
|
+
) : (
|
|
126
|
+
<CNPGScreenGate query={query} fleet={fleet}>
|
|
127
|
+
{(data, readyFleet) => {
|
|
128
|
+
const props = {
|
|
129
|
+
data,
|
|
130
|
+
fleet: readyFleet,
|
|
131
|
+
namespaces,
|
|
132
|
+
searchParams,
|
|
133
|
+
onSetParams: setParams,
|
|
134
|
+
onInspect: inspect,
|
|
135
|
+
inspected: drawerTarget,
|
|
136
|
+
onClearNamespaces,
|
|
137
|
+
}
|
|
138
|
+
switch (route.screen) {
|
|
139
|
+
case 'protection':
|
|
140
|
+
return <CNPGProtection {...props} />
|
|
141
|
+
case 'declarations':
|
|
142
|
+
return <CNPGDeclarations {...props} />
|
|
143
|
+
case 'pooling':
|
|
144
|
+
return <CNPGPooling {...props} />
|
|
145
|
+
case 'operator':
|
|
146
|
+
return <CNPGOperator {...props} />
|
|
147
|
+
default:
|
|
148
|
+
return <CNPGOverview {...props} />
|
|
149
|
+
}
|
|
150
|
+
}}
|
|
151
|
+
</CNPGScreenGate>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { cnpgDetailPath } from './routes'
|
|
2
|
+
|
|
3
|
+
export function cnpgClusterFullPath(namespace: string, name: string, ctx?: string, tab?: string): string {
|
|
4
|
+
return cnpgDetailPath({ plural: 'clusters', namespace, name }, ctx, tab)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The label for "← back" on the page a push lands on: the title of the page
|
|
9
|
+
* being left, which Radar keeps in the document title.
|
|
10
|
+
*/
|
|
11
|
+
export function currentPageLabel(): string {
|
|
12
|
+
return document.title.replace(/\s*·\s*Radar$/, '') || 'previous page'
|
|
13
|
+
}
|