@skyhook-io/radar-app 1.16.2 → 1.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +46 -5
  3. package/src/api/client.ts +53 -47
  4. package/src/api/cnpg.ts +95 -0
  5. package/src/api/timelineSource.ts +1 -1
  6. package/src/components/NoClusterAccessBanner.tsx +68 -0
  7. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  8. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  9. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  10. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  11. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  12. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  13. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  14. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  15. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  16. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  17. package/src/components/cnpg/CNPGView.tsx +156 -0
  18. package/src/components/cnpg/paths.ts +13 -0
  19. package/src/components/cnpg/routes.test.ts +60 -0
  20. package/src/components/cnpg/routes.ts +112 -0
  21. package/src/components/cnpg/shared.tsx +290 -0
  22. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  23. package/src/components/helm/ChartBrowser.tsx +6 -7
  24. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  25. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  26. package/src/components/helm/InstallWizard.tsx +5 -2
  27. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  28. package/src/components/helm/ValuesViewer.tsx +1 -1
  29. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  30. package/src/components/resources/ResourcesView.tsx +6 -38
  31. package/src/components/timeline/TimelineList.test.ts +47 -29
  32. package/src/components/timeline/TimelineList.tsx +57 -11
  33. package/src/components/timeline/TimelineView.tsx +9 -2
  34. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  35. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  36. package/src/components/whats-new/WhatsNew.tsx +146 -86
  37. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  38. package/src/components/whats-new/releaseNotes.ts +155 -24
  39. package/src/components/workload/WorkloadView.tsx +30 -4
  40. package/src/contexts/CapabilitiesContext.tsx +9 -1
  41. package/src/hooks/useResourceCounts.ts +49 -0
  42. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -0,0 +1,311 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { AlertTriangle } from 'lucide-react'
4
+ import { Badge, cnpgGitOpsSource, isApiGroup, toneTextClass, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
5
+ import type { SelectedResource } from '../../types'
6
+ import {
7
+ CNPGWorkspaceHeader,
8
+ CoverageNotice,
9
+ FilterChips,
10
+ ScreenBody,
11
+ Segments,
12
+ Sub,
13
+ clusterResource,
14
+ cnpgResource,
15
+ coverageEmpty,
16
+ worstCoverage,
17
+ namespaceChip,
18
+ type CNPGScreenProps,
19
+ } from './shared'
20
+ import { sameResource } from './routes'
21
+
22
+ type State = 'applied' | 'failed' | 'pending'
23
+
24
+ interface DeclItem {
25
+ key: string
26
+ kind: 'Database' | 'Publication' | 'Subscription' | 'Managed role'
27
+ pgName: string
28
+ indent: boolean
29
+ state: State
30
+ meta?: string
31
+ error?: string
32
+ source?: string
33
+ resource: SelectedResource
34
+ isField: boolean
35
+ }
36
+
37
+ interface DeclGroup {
38
+ namespace: string
39
+ cluster: string
40
+ row: CNPGFleetRow | null
41
+ items: DeclItem[]
42
+ }
43
+
44
+ function stateOf(obj: any): State {
45
+ const applied = obj?.status?.applied
46
+ if (applied === true) return 'applied'
47
+ if (applied === false) return 'failed'
48
+ return 'pending'
49
+ }
50
+
51
+ function gitopsSource(obj: any): string | undefined {
52
+ const src = cnpgGitOpsSource(obj)
53
+ if (!src) return undefined
54
+ return `${src.tool === 'argocd' ? 'Argo CD' : 'Flux'} ${src.name}`
55
+ }
56
+
57
+ const STATE_BADGE: Record<State, { severity: 'success' | 'warning' | 'neutral'; text: string }> = {
58
+ applied: { severity: 'success', text: 'Applied' },
59
+ failed: { severity: 'warning', text: 'Not applied' },
60
+ pending: { severity: 'neutral', text: 'Pending' },
61
+ }
62
+
63
+ function roleState(cluster: any, role: string): { state: State; error?: string } {
64
+ const status = cluster?.status?.managedRolesStatus
65
+ const errs = status?.cannotReconcile?.[role]
66
+ if (Array.isArray(errs) && errs.length > 0) return { state: 'failed', error: errs.join('; ') }
67
+ const by = status?.byStatus ?? {}
68
+ if ((by.reconciled ?? []).includes(role)) return { state: 'applied' }
69
+ return { state: 'pending' }
70
+ }
71
+
72
+ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetParams, onInspect, inspected, onClearNamespaces }: CNPGScreenProps) {
73
+ const clusterFilter = searchParams.get('cluster')
74
+ const show = (searchParams.get('show') as 'failed' | 'pending' | null) ?? null
75
+
76
+ const groups = useMemo<DeclGroup[]>(() => {
77
+ const byCluster = new Map<string, DeclGroup>()
78
+ const group = (ns: string, cluster: string) => {
79
+ const k = `${ns}/${cluster}`
80
+ let g = byCluster.get(k)
81
+ if (!g) {
82
+ g = { namespace: ns, cluster, row: fleet.rows.find((r) => r.namespace === ns && r.name === cluster) ?? null, items: [] }
83
+ byCluster.set(k, g)
84
+ }
85
+ return g
86
+ }
87
+ const valid = (o: any) => isApiGroup(o?.apiVersion, 'postgresql.cnpg.io')
88
+ const dbs = (data.objects.databases ?? []).filter(valid)
89
+ const pubs = (data.objects.publications ?? []).filter(valid)
90
+ const subs = (data.objects.subscriptions ?? []).filter(valid)
91
+ const placed = new Set<any>()
92
+ for (const d of dbs) {
93
+ const ns = d.metadata?.namespace ?? ''
94
+ const cluster = d.spec?.cluster?.name ?? '(no cluster)'
95
+ const g = group(ns, cluster)
96
+ const st = stateOf(d)
97
+ g.items.push({
98
+ key: `db/${ns}/${d.metadata?.name}`,
99
+ kind: 'Database',
100
+ pgName: d.spec?.name ?? d.metadata?.name,
101
+ indent: false,
102
+ state: st,
103
+ meta: d.spec?.owner ? `owner ${d.spec.owner}` : undefined,
104
+ error: st === 'failed' ? d.status?.message : undefined,
105
+ source: gitopsSource(d),
106
+ resource: cnpgResource('databases', ns, d.metadata?.name),
107
+ isField: false,
108
+ })
109
+ for (const [list, kind] of [[pubs, 'Publication'], [subs, 'Subscription']] as const) {
110
+ for (const p of list) {
111
+ if (p.metadata?.namespace !== ns || p.spec?.cluster?.name !== d.spec?.cluster?.name || p.spec?.dbname !== d.spec?.name) continue
112
+ placed.add(p)
113
+ const pst = stateOf(p)
114
+ g.items.push({
115
+ key: `${kind}/${ns}/${p.metadata?.name}`,
116
+ kind,
117
+ pgName: p.spec?.name ?? p.metadata?.name,
118
+ indent: true,
119
+ state: pst,
120
+ meta:
121
+ kind === 'Publication'
122
+ ? p.spec?.target?.allTables ? 'all tables' : 'selected objects'
123
+ : `from ${p.spec?.publicationName ?? '?'} on ${p.spec?.externalClusterName ?? '?'}`,
124
+ error: pst === 'failed' ? p.status?.message : undefined,
125
+ source: gitopsSource(p),
126
+ resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
127
+ isField: false,
128
+ })
129
+ }
130
+ }
131
+ }
132
+ for (const [list, kind] of [[pubs, 'Publication'], [subs, 'Subscription']] as const) {
133
+ for (const p of list) {
134
+ if (placed.has(p)) continue
135
+ const ns = p.metadata?.namespace ?? ''
136
+ const g = group(ns, p.spec?.cluster?.name ?? '(no cluster)')
137
+ const pst = stateOf(p)
138
+ g.items.push({
139
+ key: `${kind}/${ns}/${p.metadata?.name}`,
140
+ kind,
141
+ pgName: p.spec?.name ?? p.metadata?.name,
142
+ indent: false,
143
+ state: pst,
144
+ meta: p.spec?.dbname ? `database ${p.spec.dbname}` : undefined,
145
+ error: pst === 'failed' ? p.status?.message : undefined,
146
+ source: gitopsSource(p),
147
+ resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
148
+ isField: false,
149
+ })
150
+ }
151
+ }
152
+ for (const row of fleet.rows) {
153
+ const roles: any[] = Array.isArray(row.cluster?.spec?.managed?.roles) ? row.cluster.spec.managed.roles : []
154
+ if (roles.length === 0) continue
155
+ const g = group(row.namespace, row.name)
156
+ for (const r of roles) {
157
+ if (!r?.name) continue
158
+ const rs = roleState(row.cluster, r.name)
159
+ g.items.push({
160
+ key: `role/${row.namespace}/${row.name}/${r.name}`,
161
+ kind: 'Managed role',
162
+ pgName: r.name,
163
+ indent: false,
164
+ state: rs.state,
165
+ meta: r.ensure === 'absent' ? 'ensure absent' : 'spec.managed.roles',
166
+ error: rs.error,
167
+ source: `Cluster ${row.name} spec`,
168
+ resource: clusterResource(row.namespace, row.name),
169
+ isField: true,
170
+ })
171
+ }
172
+ }
173
+ return [...byCluster.values()]
174
+ .filter((g) => !clusterFilter || `${g.namespace}/${g.cluster}` === clusterFilter)
175
+ .map((g) => ({ ...g, items: show ? g.items.filter((i) => i.state === show) : g.items }))
176
+ .filter((g) => g.items.length > 0)
177
+ .sort((a, b) => {
178
+ const fa = a.items.some((i) => i.state === 'failed') ? 0 : 1
179
+ const fb = b.items.some((i) => i.state === 'failed') ? 0 : 1
180
+ return fa - fb || a.namespace.localeCompare(b.namespace) || a.cluster.localeCompare(b.cluster)
181
+ })
182
+ }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, fleet.rows, clusterFilter, show])
183
+
184
+ const totals = useMemo(() => {
185
+ let failed = 0
186
+ let pending = 0
187
+ for (const r of fleet.rows) {
188
+ failed += r.declarations.failed
189
+ pending += r.declarations.pending
190
+ }
191
+ return { failed, pending }
192
+ }, [fleet.rows])
193
+ const declCoverage = worstCoverage(data.coverage.databases, data.coverage.publications, data.coverage.subscriptions)
194
+
195
+ const chips = [
196
+ ...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
197
+ ...namespaceChip(namespaces, onClearNamespaces),
198
+ ]
199
+
200
+ return (
201
+ <div className="flex min-h-0 flex-1 flex-col">
202
+ <CNPGWorkspaceHeader
203
+ title="Declarations"
204
+ subtitle="Databases, managed roles, publications and subscriptions, by PostgreSQL cluster. Declared is not the same as reconciled."
205
+ />
206
+ <ScreenBody>
207
+ <CoverageNotice fleet={fleet} data={data} />
208
+ <div className="flex flex-wrap items-center gap-2">
209
+ <Segments
210
+ label="Declarations"
211
+ value={show ?? 'all'}
212
+ onChange={(id) => onSetParams({ show: id === 'all' ? null : id })}
213
+ options={[
214
+ { id: 'all', label: 'All declarations' },
215
+ { id: 'failed', label: 'Not applied', count: totals.failed },
216
+ { id: 'pending', label: 'Pending', count: totals.pending },
217
+ ]}
218
+ />
219
+ </div>
220
+ <FilterChips chips={chips} />
221
+
222
+ {groups.length === 0 ? (
223
+ <div className="rounded-xl border border-theme-border bg-theme-surface px-4 py-5 text-sm text-theme-text-tertiary">
224
+ {show === 'failed'
225
+ ? 'No declaration in this scope is reported as not applied.'
226
+ : show === 'pending'
227
+ ? 'No declaration in this scope is waiting for the operator.'
228
+ : coverageEmpty(declCoverage, 'declarations')}
229
+ {show && declCoverage?.state !== 'full' ? ' Some declarations are not readable with your access.' : ''}
230
+ </div>
231
+ ) : (
232
+ groups.map((g) => {
233
+ const failed = g.items.filter((i) => i.state === 'failed').length
234
+ return (
235
+ <section key={`${g.namespace}/${g.cluster}`} className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
236
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-theme-border px-4 py-2.5">
237
+ {g.row ? (
238
+ <button type="button" onClick={() => onInspect(clusterResource(g.namespace, g.cluster))} className="text-sm font-semibold text-accent-text hover:underline">
239
+ {g.cluster}
240
+ </button>
241
+ ) : (
242
+ <span className="text-sm font-semibold text-theme-text-primary">
243
+ {g.cluster}
244
+ </span>
245
+ )}
246
+ <span className="text-xs text-theme-text-tertiary">{g.namespace}</span>
247
+ <span className={clsx('text-xs', failed > 0 ? toneTextClass('degraded') : 'text-theme-text-tertiary')}>
248
+ {g.items.length} {g.items.length === 1 ? 'declaration' : 'declarations'}
249
+ {failed > 0 ? ` · ${failed} not reconciled` : ''}
250
+ {!g.row ? ' · target cluster not visible' : ''}
251
+ </span>
252
+ </div>
253
+ <div className="table-divide-subtle">
254
+ {g.items.map((i) => (
255
+ <DeclarationRow
256
+ key={i.key}
257
+ item={i}
258
+ active={!i.isField && sameResource(inspected, i.resource)}
259
+ onInspect={() => onInspect(i.resource)}
260
+ />
261
+ ))}
262
+ </div>
263
+ </section>
264
+ )
265
+ })
266
+ )}
267
+ </ScreenBody>
268
+ </div>
269
+ )
270
+ }
271
+
272
+ function DeclarationRow({ item, active, onInspect }: { item: DeclItem; active: boolean; onInspect: () => void }) {
273
+ const badge = STATE_BADGE[item.state]
274
+ let detail: ReactNode = null
275
+ if (item.error) {
276
+ detail = (
277
+ <div className={clsx('mt-1 flex items-start gap-1.5 text-xs', toneTextClass('degraded'))}>
278
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
279
+ <span className="break-words">Controller error: {item.error}</span>
280
+ </div>
281
+ )
282
+ }
283
+ return (
284
+ <div
285
+ role="button"
286
+ tabIndex={0}
287
+ onClick={onInspect}
288
+ onKeyDown={(e) => { if (e.key === 'Enter') onInspect() }}
289
+ className={clsx(
290
+ 'grid cursor-pointer grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-x-4 px-4 py-2.5 text-sm transition-colors hover:bg-theme-hover/50',
291
+ active && 'selection',
292
+ )}
293
+ >
294
+ <div className={clsx('min-w-0', item.indent && 'pl-6')}>
295
+ <div className="flex flex-wrap items-center gap-2">
296
+ <span className="text-xs text-theme-text-tertiary">{item.kind}</span>
297
+ <span className="font-mono font-medium text-theme-text-primary">{item.pgName}</span>
298
+ {item.meta && <span className="text-xs text-theme-text-tertiary">{item.meta}</span>}
299
+ </div>
300
+ {detail}
301
+ </div>
302
+ <div>
303
+ <Badge severity={badge.severity} size="sm">{badge.text}</Badge>
304
+ {item.isField && <Sub>field of the Cluster</Sub>}
305
+ </div>
306
+ <div className="min-w-0 text-xs text-theme-text-secondary break-words">
307
+ {item.source ?? <span className="text-theme-text-tertiary">GitOps source not recorded</span>}
308
+ </div>
309
+ </div>
310
+ )
311
+ }
@@ -0,0 +1,228 @@
1
+ import { useCallback, useEffect, useMemo } from 'react'
2
+ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
3
+ import { Activity, ArrowLeft, Database, ShieldCheck, Unplug } from 'lucide-react'
4
+ import type { WorkloadExtraTab } from '@skyhook-io/k8s-ui'
5
+ import type { SelectedResource } from '../../types'
6
+ import { useConnection } from '../../context/ConnectionContext'
7
+ import { useContexts } from '../../api/client'
8
+ import { useContextSwitchFlow } from '../useContextSwitchFlow'
9
+ import { WorkloadView } from '../workload/WorkloadView'
10
+ import { EmptyState } from '../capacity/shared'
11
+ import { CNPGClusterActivity } from './CNPGClusterActivity'
12
+ import { CNPGProtection } from './CNPGProtection'
13
+ import { CNPGScreenGate } from './shared'
14
+ import { CNPG_DETAIL_KINDS, CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, cnpgScreenPath, type CNPGDetailTarget } from './routes'
15
+ import { currentPageLabel } from './paths'
16
+ import { useCNPGFleet } from './useCNPGSidebarWorkspace'
17
+
18
+ interface ReturnState {
19
+ returnLabel?: string
20
+ returnCtx?: string
21
+ }
22
+
23
+ function ClusterProtectionTab({ namespace, name, onInspect }: { namespace: string; name: string; onInspect: (r: SelectedResource) => void }) {
24
+ const { query, fleet } = useCNPGFleet([namespace])
25
+ const [searchParams] = useSearchParams()
26
+ return (
27
+ <CNPGScreenGate query={query} fleet={fleet}>
28
+ {(data, readyFleet) => (
29
+ <CNPGProtection
30
+ data={data}
31
+ fleet={readyFleet}
32
+ namespaces={[namespace]}
33
+ searchParams={searchParams}
34
+ onSetParams={() => {}}
35
+ onInspect={onInspect}
36
+ inspected={null}
37
+ onClearNamespaces={() => {}}
38
+ scopeCluster={{ namespace, name }}
39
+ />
40
+ )}
41
+ </CNPGScreenGate>
42
+ )
43
+ }
44
+
45
+ /**
46
+ * The full detail of a CloudNativePG object, framed by the workspace: the
47
+ * Resources sidebar keeps the workspace destination highlighted, a return
48
+ * control goes back to the task the user came from, and the crumb names the
49
+ * object's place. The object's own sections come from Radar's detail view.
50
+ *
51
+ * `ctx` in the URL pins the Kubernetes context; when the active context is a
52
+ * different one, the page says so instead of loading a same-named object.
53
+ */
54
+ export function CNPGDetailPage({
55
+ target,
56
+ namespaces,
57
+ onOpenResource,
58
+ }: {
59
+ target: CNPGDetailTarget
60
+ namespaces: string[]
61
+ onOpenResource: (resource: SelectedResource) => void
62
+ }) {
63
+ const location = useLocation()
64
+ const navigate = useNavigate()
65
+ const [searchParams, setSearchParams] = useSearchParams()
66
+ const { connection } = useConnection()
67
+ const activeContext = connection.context
68
+ const pinnedContext = searchParams.get('ctx')
69
+
70
+ // A link opened without a context belongs to the one active now; pin it so a
71
+ // later context switch shows "not in this context" instead of a same-named
72
+ // object from the other cluster.
73
+ useEffect(() => {
74
+ if (pinnedContext || !activeContext) return
75
+ const params = new URLSearchParams(searchParams)
76
+ params.set('ctx', activeContext)
77
+ setSearchParams(params, { replace: true, state: location.state })
78
+ }, [pinnedContext, activeContext]) // eslint-disable-line react-hooks/exhaustive-deps
79
+ const returnState = (location.state ?? {}) as ReturnState
80
+ const spec = CNPG_DETAIL_KINDS[target.plural]
81
+ const home = CNPG_SCREENS.find((s) => s.id === spec.home)!
82
+ const returnLabel = returnState.returnLabel && (!returnState.returnCtx || returnState.returnCtx === activeContext) ? returnState.returnLabel : null
83
+
84
+ const openRelated = useCallback(
85
+ (res: SelectedResource) => {
86
+ const plural = cnpgDetailKindFor(res.kind, res.group)
87
+ if (plural) {
88
+ navigate(cnpgDetailPath({ plural, namespace: res.namespace, name: res.name }, activeContext), {
89
+ state: { returnLabel: currentPageLabel(), returnCtx: activeContext } satisfies ReturnState,
90
+ })
91
+ } else {
92
+ onOpenResource(res)
93
+ }
94
+ },
95
+ [navigate, activeContext, onOpenResource],
96
+ )
97
+
98
+ const extraTabs = useMemo<WorkloadExtraTab[] | undefined>(() => {
99
+ if (target.plural !== 'clusters') return undefined
100
+ return [
101
+ {
102
+ id: 'protection',
103
+ label: 'Protection',
104
+ icon: <ShieldCheck className="h-4 w-4" />,
105
+ after: 'spec',
106
+ render: () => <ClusterProtectionTab namespace={target.namespace} name={target.name} onInspect={onOpenResource} />,
107
+ },
108
+ {
109
+ id: 'activity',
110
+ label: 'Activity',
111
+ icon: <Activity className="h-4 w-4" />,
112
+ replaces: 'timeline',
113
+ render: () => (
114
+ <CNPGClusterActivity namespace={target.namespace} name={target.name} onNavigate={openRelated} />
115
+ ),
116
+ },
117
+ ]
118
+ }, [target.plural, target.namespace, target.name, onOpenResource, openRelated])
119
+
120
+ if (pinnedContext && activeContext && pinnedContext !== activeContext) {
121
+ return <NotInContext target={target} pinnedContext={pinnedContext} activeContext={activeContext} homeLabel={home.label} homePath={home.path} />
122
+ }
123
+
124
+ const outsideFilter = namespaces.length > 0 && !!target.namespace && !namespaces.includes(target.namespace)
125
+
126
+ const breadcrumb = (
127
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
128
+ {returnLabel && (
129
+ <>
130
+ <button
131
+ type="button"
132
+ onClick={() => navigate(-1)}
133
+ aria-label={`Return to ${returnLabel}`}
134
+ className="inline-flex items-center gap-1 rounded-md py-0.5 pr-2 text-theme-text-secondary hover:text-theme-text-primary"
135
+ >
136
+ <ArrowLeft className="h-3.5 w-3.5" />
137
+ {returnLabel}
138
+ </button>
139
+ <span className="h-4 w-px bg-theme-border" aria-hidden />
140
+ </>
141
+ )}
142
+ <nav aria-label="Location" className="flex min-w-0 items-center gap-1.5 text-theme-text-tertiary">
143
+ <Database className="h-3.5 w-3.5" />
144
+ <button type="button" onClick={() => navigate(cnpgScreenPath('overview'))} className="hover:text-theme-text-primary hover:underline">
145
+ CloudNativePG
146
+ </button>
147
+ {home.id !== 'overview' && (
148
+ <>
149
+ <span>/</span>
150
+ <button type="button" onClick={() => navigate(home.path)} className="hover:text-theme-text-primary hover:underline">
151
+ {home.label}
152
+ </button>
153
+ </>
154
+ )}
155
+ <span>/</span>
156
+ <span className="truncate text-theme-text-secondary">{target.name}</span>
157
+ </nav>
158
+ {outsideFilter && (
159
+ <span className="text-xs text-theme-text-tertiary">
160
+ Namespace {target.namespace} is outside your namespace filter; this object stays open.
161
+ </span>
162
+ )}
163
+ </div>
164
+ )
165
+
166
+ return (
167
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
168
+ <WorkloadView
169
+ key={`${target.plural}/${target.namespace}/${target.name}`}
170
+ kind={target.plural}
171
+ namespace={target.namespace}
172
+ name={target.name}
173
+ group={target.group}
174
+ expanded
175
+ onBack={() => (returnLabel ? navigate(-1) : navigate(home.path))}
176
+ hideBackButton
177
+ breadcrumb={breadcrumb}
178
+ onNavigateToResource={openRelated}
179
+ extraTabs={extraTabs}
180
+ />
181
+ </div>
182
+ )
183
+ }
184
+
185
+ function NotInContext({
186
+ target,
187
+ pinnedContext,
188
+ activeContext,
189
+ homeLabel,
190
+ homePath,
191
+ }: {
192
+ target: CNPGDetailTarget
193
+ pinnedContext: string
194
+ activeContext: string
195
+ homeLabel: string
196
+ homePath: string
197
+ }) {
198
+ const navigate = useNavigate()
199
+ const { data: contexts } = useContexts()
200
+ const { requestSwitch, confirmDialog } = useContextSwitchFlow()
201
+ const pinned = contexts?.find((c) => c.name === pinnedContext)
202
+ return (
203
+ <>
204
+ <EmptyState
205
+ icon={Unplug}
206
+ title={`${target.name} is not in ${activeContext}`}
207
+ detail={`This link points at ${CNPG_DETAIL_KINDS[target.plural].kind} ${target.name} in context ${pinnedContext}. Radar does not open a same-named object from another cluster.`}
208
+ action={
209
+ <div className="mt-4 flex flex-wrap justify-center gap-2">
210
+ {pinned && (
211
+ <button type="button" onClick={() => requestSwitch(pinned)} className="btn-brand px-3 py-1.5 text-sm font-medium">
212
+ Switch back to {pinnedContext}
213
+ </button>
214
+ )}
215
+ <button
216
+ type="button"
217
+ onClick={() => navigate(homePath)}
218
+ className="rounded-lg border border-theme-border bg-theme-surface px-3 py-1.5 text-sm text-theme-text-primary hover:bg-theme-hover"
219
+ >
220
+ Go to {homeLabel === 'Overview' ? 'CloudNativePG Overview' : homeLabel}
221
+ </button>
222
+ </div>
223
+ }
224
+ />
225
+ {confirmDialog}
226
+ </>
227
+ )
228
+ }
@@ -0,0 +1,37 @@
1
+ import { useLocation, useSearchParams } from 'react-router-dom'
2
+ import { ArrowLeft } from 'lucide-react'
3
+ import { CNPG_KIND_BY_KEY } from '@skyhook-io/k8s-ui'
4
+ import type { SelectedResource } from '../../types'
5
+ import { decodeDrawerTrail, encodeDrawerTrail, sameResource } from './routes'
6
+
7
+ const KIND_BY_PLURAL: Record<string, string> = Object.fromEntries(
8
+ Object.values(CNPG_KIND_BY_KEY).map((k) => [k.plural, k.kind]),
9
+ )
10
+
11
+ /**
12
+ * On a CloudNativePG workspace screen the drawer URL carries the chain of
13
+ * objects opened from inside the drawer. This is the step back to the previous
14
+ * one, shown for whatever kind is open (a Pod or Secret reached from a CNPG
15
+ * object included).
16
+ */
17
+ export function CNPGDrawerTrailBack({ resource }: { resource: SelectedResource }) {
18
+ const location = useLocation()
19
+ const [searchParams, setSearchParams] = useSearchParams()
20
+ if (!location.pathname.startsWith('/cnpg')) return null
21
+ const trail = decodeDrawerTrail(searchParams.get('drawer'))
22
+ if (trail.length < 2 || !sameResource(trail[trail.length - 1], resource)) return null
23
+ const prev = trail[trail.length - 2]
24
+ const back = () => {
25
+ const params = new URLSearchParams(searchParams)
26
+ params.set('drawer', encodeDrawerTrail(trail.slice(0, -1)))
27
+ setSearchParams(params, { replace: true, state: location.state })
28
+ }
29
+ return (
30
+ <div className="shrink-0 border-b border-theme-border px-4 py-2">
31
+ <button type="button" onClick={back} className="inline-flex items-center gap-1 text-xs font-medium text-accent-text hover:underline">
32
+ <ArrowLeft className="h-3.5 w-3.5" />
33
+ {KIND_BY_PLURAL[prev.kind] ?? prev.kind} {prev.name}
34
+ </button>
35
+ </div>
36
+ )
37
+ }