@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.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- 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/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- 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/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -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.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- 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
|
+
}
|