@skyhook-io/radar-app 1.16.2 → 1.17.0

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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -0,0 +1,320 @@
1
+ import { useMemo } from 'react'
2
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
3
+ import {
4
+ Badge,
5
+ FactValue,
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 formatRelativeAgeTime(ts, '—')
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
+ }