@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,234 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Badge, getCNPGImageCatalogEntries, isApiGroup, PaneLoader, Tooltip } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGOperator, type CNPGOperatorComponent, type CNPGOperatorConfig } from '../../api/cnpg'
|
|
4
|
+
import { Notice } from '../capacity/shared'
|
|
5
|
+
import {
|
|
6
|
+
CNPGWorkspaceHeader,
|
|
7
|
+
CoverageNotice,
|
|
8
|
+
coverageEmpty,
|
|
9
|
+
worstCoverage,
|
|
10
|
+
Mono,
|
|
11
|
+
ScreenBody,
|
|
12
|
+
SectionTable,
|
|
13
|
+
Sub,
|
|
14
|
+
cnpgResource,
|
|
15
|
+
type CNPGScreenProps,
|
|
16
|
+
} from './shared'
|
|
17
|
+
|
|
18
|
+
interface CatalogRow {
|
|
19
|
+
key: string
|
|
20
|
+
kind: 'ImageCatalog' | 'ClusterImageCatalog'
|
|
21
|
+
namespace: string
|
|
22
|
+
name: string
|
|
23
|
+
images: { major: number; image: string }[]
|
|
24
|
+
users: { namespace: string; name: string; major?: number }[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readiness(c: CNPGOperatorComponent) {
|
|
28
|
+
if (c.readyReplicas === null || c.replicas === null) return <span className="text-theme-text-tertiary">Unknown</span>
|
|
29
|
+
if (c.replicas === 0) return <Badge severity="warning" size="sm">Scaled to 0</Badge>
|
|
30
|
+
const ok = c.readyReplicas >= c.replicas
|
|
31
|
+
return <Badge severity={ok ? 'success' : c.readyReplicas === 0 ? 'error' : 'warning'} size="sm">{c.readyReplicas}/{c.replicas} ready</Badge>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CNPGOperator({ data, fleet, onInspect, inspected }: CNPGScreenProps) {
|
|
35
|
+
const operator = useCNPGOperator()
|
|
36
|
+
|
|
37
|
+
const catalogs = useMemo<CatalogRow[]>(() => {
|
|
38
|
+
const out: CatalogRow[] = []
|
|
39
|
+
const clusters = fleet.rows
|
|
40
|
+
for (const [key, kind] of [['imageCatalogs', 'ImageCatalog'], ['clusterImageCatalogs', 'ClusterImageCatalog']] as const) {
|
|
41
|
+
for (const cat of data.objects[key] ?? []) {
|
|
42
|
+
if (!isApiGroup(cat.apiVersion, 'postgresql.cnpg.io')) continue
|
|
43
|
+
const ns = cat.metadata?.namespace ?? ''
|
|
44
|
+
const name = cat.metadata?.name ?? ''
|
|
45
|
+
const users = clusters
|
|
46
|
+
.filter((r) => {
|
|
47
|
+
const ref = r.cluster?.spec?.imageCatalogRef
|
|
48
|
+
if (!ref || ref.name !== name) return false
|
|
49
|
+
const refKind = ref.kind || 'ImageCatalog'
|
|
50
|
+
if (refKind !== kind) return false
|
|
51
|
+
return kind === 'ClusterImageCatalog' || r.namespace === ns
|
|
52
|
+
})
|
|
53
|
+
.map((r) => ({ namespace: r.namespace, name: r.name, major: r.cluster?.spec?.imageCatalogRef?.major }))
|
|
54
|
+
out.push({ key: `${kind}/${ns}/${name}`, kind, namespace: ns, name, images: getCNPGImageCatalogEntries(cat), users })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}, [data.objects, fleet.rows])
|
|
59
|
+
|
|
60
|
+
const direct = fleet.rows.filter((r) => !r.cluster?.spec?.imageCatalogRef)
|
|
61
|
+
const op = operator.data
|
|
62
|
+
const coverageGaps = op
|
|
63
|
+
? (['deployments', 'services'] as const).filter((k) => op.coverage[k]?.state !== 'full')
|
|
64
|
+
: []
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
68
|
+
<CNPGWorkspaceHeader
|
|
69
|
+
title="Operator"
|
|
70
|
+
subtitle="Operator and plugin workloads, their versions, image catalogs and operator configuration."
|
|
71
|
+
/>
|
|
72
|
+
<ScreenBody>
|
|
73
|
+
<CoverageNotice fleet={fleet} data={data} />
|
|
74
|
+
{operator.isLoading && !op ? (
|
|
75
|
+
<PaneLoader label="Loading operator…" className="h-32" />
|
|
76
|
+
) : !op ? (
|
|
77
|
+
<Notice>Operator details could not be loaded{operator.error instanceof Error ? `: ${operator.error.message}` : '.'}</Notice>
|
|
78
|
+
) : (
|
|
79
|
+
<>
|
|
80
|
+
{coverageGaps.length > 0 && (
|
|
81
|
+
<Notice>
|
|
82
|
+
Some workloads are not readable ({coverageGaps.map((k) => `${k}: ${op.coverage[k].state}`).join(', ')}), so an operator or plugin running in those namespaces may be missing below.
|
|
83
|
+
</Notice>
|
|
84
|
+
)}
|
|
85
|
+
<SectionTable
|
|
86
|
+
title="Operator and plugins"
|
|
87
|
+
columns={[
|
|
88
|
+
{
|
|
89
|
+
header: 'Component',
|
|
90
|
+
width: '30%',
|
|
91
|
+
cell: (c: CNPGOperatorComponent) => (
|
|
92
|
+
<>
|
|
93
|
+
<div className="font-medium">{c.role === 'operator' ? 'CloudNativePG operator' : c.pluginName ?? 'Plugin'}</div>
|
|
94
|
+
<Sub>{c.role === 'operator' ? 'controller manager' : 'CNPG-I plugin'}</Sub>
|
|
95
|
+
</>
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
header: 'Version',
|
|
100
|
+
width: '14%',
|
|
101
|
+
cell: (c) => (
|
|
102
|
+
<Tooltip content={c.image || 'No image recorded'}>
|
|
103
|
+
{c.version ? <Mono>{c.version}</Mono> : <span className="text-theme-text-tertiary">Unknown</span>}
|
|
104
|
+
</Tooltip>
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
{ header: 'Ready', width: '14%', cell: readiness },
|
|
108
|
+
{
|
|
109
|
+
header: 'Workload',
|
|
110
|
+
width: '42%',
|
|
111
|
+
cell: (c) =>
|
|
112
|
+
c.deployment ? (
|
|
113
|
+
<>Deployment <Mono>{c.deployment}</Mono><Sub>{c.namespace}</Sub></>
|
|
114
|
+
) : (
|
|
115
|
+
<>
|
|
116
|
+
<span className="text-theme-text-tertiary">No Deployment matches its Service</span>
|
|
117
|
+
<Sub>{c.namespace}</Sub>
|
|
118
|
+
</>
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
]}
|
|
122
|
+
rows={op.components}
|
|
123
|
+
rowKey={(c) => `${c.namespace}/${c.deployment}/${c.pluginName ?? c.role}`}
|
|
124
|
+
rowResource={(c) => (c.deployment ? { kind: 'deployments', group: 'apps', namespace: c.namespace, name: c.deployment } : null)}
|
|
125
|
+
onInspect={onInspect}
|
|
126
|
+
inspected={inspected}
|
|
127
|
+
empty="No operator or plugin Deployments found in the namespaces you can read."
|
|
128
|
+
/>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<SectionTable
|
|
133
|
+
title="Image catalogs"
|
|
134
|
+
columns={[
|
|
135
|
+
{ header: 'Catalog', width: '22%', cell: (c: CatalogRow) => <>{c.name}<Sub>{c.kind}</Sub></> },
|
|
136
|
+
{ header: 'Scope', width: '14%', cell: (c) => (c.kind === 'ClusterImageCatalog' ? 'Cluster-wide' : `Namespace ${c.namespace}`) },
|
|
137
|
+
{
|
|
138
|
+
header: 'Images',
|
|
139
|
+
width: '40%',
|
|
140
|
+
cell: (c) =>
|
|
141
|
+
c.images.length === 0 ? (
|
|
142
|
+
<span className="text-theme-text-tertiary">None</span>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="space-y-0.5">
|
|
145
|
+
{c.images.map((i) => (
|
|
146
|
+
<div key={i.major} className="flex gap-2">
|
|
147
|
+
<span className="w-6 shrink-0 font-mono text-theme-text-tertiary">{i.major}</span>
|
|
148
|
+
<Mono>{i.image}</Mono>
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
header: 'Used by',
|
|
156
|
+
width: '24%',
|
|
157
|
+
cell: (c) =>
|
|
158
|
+
c.users.length === 0 ? (
|
|
159
|
+
<span className="text-theme-text-tertiary">No visible cluster</span>
|
|
160
|
+
) : (
|
|
161
|
+
c.users.map((u) => `${u.name}${u.major !== undefined ? ` (${u.major})` : ''}`).join(', ')
|
|
162
|
+
),
|
|
163
|
+
},
|
|
164
|
+
]}
|
|
165
|
+
rows={catalogs}
|
|
166
|
+
rowKey={(c) => c.key}
|
|
167
|
+
rowResource={(c) => cnpgResource(c.kind === 'ImageCatalog' ? 'imagecatalogs' : 'clusterimagecatalogs', c.namespace, c.name)}
|
|
168
|
+
onInspect={onInspect}
|
|
169
|
+
inspected={inspected}
|
|
170
|
+
empty={coverageEmpty(worstCoverage(data.coverage.imageCatalogs, data.coverage.clusterImageCatalogs), 'image catalogs')}
|
|
171
|
+
footer={
|
|
172
|
+
<>
|
|
173
|
+
Used-by lists only clusters you can see; the catalog detail asks the server for every user.
|
|
174
|
+
{direct.length > 0 && <> Not using a catalog (direct <span className="font-mono">imageName</span>): {direct.map((r) => r.name).join(', ')}.</>}
|
|
175
|
+
</>
|
|
176
|
+
}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
{op && op.config.length > 0 && (
|
|
180
|
+
<section>
|
|
181
|
+
<h2 className="mb-2 text-sm font-semibold text-theme-text-primary">Operator configuration</h2>
|
|
182
|
+
<div className="space-y-3">
|
|
183
|
+
{op.config.map((c) => (
|
|
184
|
+
<ConfigBlock key={`${c.kind}/${c.namespace}/${c.name}`} config={c} onInspect={onInspect} />
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
</section>
|
|
188
|
+
)}
|
|
189
|
+
</ScreenBody>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function ConfigBlock({ config, onInspect }: { config: CNPGOperatorConfig; onInspect: CNPGScreenProps['onInspect'] }) {
|
|
195
|
+
const open = () => onInspect({ kind: config.kind === 'ConfigMap' ? 'configmaps' : 'secrets', group: '', namespace: config.namespace, name: config.name })
|
|
196
|
+
const title = (
|
|
197
|
+
<div className="flex flex-wrap items-baseline gap-x-2 border-b border-theme-border px-4 py-2.5">
|
|
198
|
+
<span className="text-xs text-theme-text-tertiary">{config.kind}</span>
|
|
199
|
+
<button type="button" onClick={open} className="font-mono text-sm text-accent-text hover:underline">{config.name}</button>
|
|
200
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
201
|
+
{config.namespace} · {config.purpose === 'monitoring' ? 'monitoring queries' : 'operator settings'}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
let body
|
|
206
|
+
if (config.kind === 'Secret') {
|
|
207
|
+
body = <div className="px-4 py-2.5 text-sm text-theme-text-secondary">Referenced by the operator. Secret contents are not shown here.</div>
|
|
208
|
+
} else if (config.exists === false) {
|
|
209
|
+
body = <div className="px-4 py-2.5 text-sm text-theme-text-secondary">Referenced but does not exist; the operator runs with its defaults.</div>
|
|
210
|
+
} else if (!config.readable) {
|
|
211
|
+
body = <div className="px-4 py-2.5 text-sm text-theme-text-tertiary">{config.reason ?? 'Not readable with your access.'}</div>
|
|
212
|
+
} else {
|
|
213
|
+
const entries = Object.entries(config.data ?? {})
|
|
214
|
+
body =
|
|
215
|
+
entries.length === 0 ? (
|
|
216
|
+
<div className="px-4 py-2.5 text-sm text-theme-text-tertiary">No keys set.</div>
|
|
217
|
+
) : (
|
|
218
|
+
<dl className="grid grid-cols-[minmax(0,14rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5 px-4 py-2.5 text-sm">
|
|
219
|
+
{entries.map(([k, v]) => (
|
|
220
|
+
<div key={k} className="contents">
|
|
221
|
+
<dt className="min-w-0 font-mono text-theme-text-secondary"><Tooltip content={k} wrapperClassName="block min-w-0"><span className="block truncate">{k}</span></Tooltip></dt>
|
|
222
|
+
<dd className="break-all font-mono text-theme-text-primary">{v.length > 400 ? `${v.slice(0, 400)}…` : v}</dd>
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
</dl>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return (
|
|
229
|
+
<div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
230
|
+
{title}
|
|
231
|
+
{body}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { ArrowRight, Database, FileText, Search } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
CNPG_PROBLEM_CATEGORIES,
|
|
7
|
+
FactValue,
|
|
8
|
+
StatusDot,
|
|
9
|
+
Tooltip,
|
|
10
|
+
toneTextClass,
|
|
11
|
+
type CNPGFleetRow,
|
|
12
|
+
type CNPGProblemCategory,
|
|
13
|
+
} from '@skyhook-io/k8s-ui'
|
|
14
|
+
import type { SelectedResource } from '../../types'
|
|
15
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
16
|
+
import { EmptyState, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared'
|
|
17
|
+
import { CNPGWorkspaceHeader, CoverageNotice, FilterChips, type CNPGScreenProps } from './shared'
|
|
18
|
+
import { cnpgClusterFullPath, currentPageLabel } from './paths'
|
|
19
|
+
import { sameResource } from './routes'
|
|
20
|
+
|
|
21
|
+
type Filter = 'attention' | 'all'
|
|
22
|
+
|
|
23
|
+
function InstancePills({ row }: { row: CNPGFleetRow }) {
|
|
24
|
+
if (row.pods.length === 0) return null
|
|
25
|
+
return (
|
|
26
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
27
|
+
{row.pods.map((p) => {
|
|
28
|
+
const tone = p.ready === true ? 'healthy' : p.ready === false ? 'unhealthy' : 'unknown'
|
|
29
|
+
return (
|
|
30
|
+
<Tooltip key={p.name} content={`${p.name} · ${p.role} · ${p.ready === true ? 'ready' : p.ready === false ? 'not ready' : 'readiness unknown'}`}>
|
|
31
|
+
<span className="inline-flex items-center gap-1 rounded border border-theme-border bg-theme-base px-1 text-[10.5px] font-mono text-theme-text-secondary">
|
|
32
|
+
<StatusDot tone={tone} size="xs" />
|
|
33
|
+
{p.role === 'primary' ? 'P' : p.role === 'replica' ? 'R' : '?'}
|
|
34
|
+
</span>
|
|
35
|
+
</Tooltip>
|
|
36
|
+
)
|
|
37
|
+
})}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function AttentionCell({ row }: { row: CNPGFleetRow }) {
|
|
43
|
+
const top = row.problems.find((p) => p.severity !== 'posture') ?? row.problems[0]
|
|
44
|
+
if (!top) return <span className="text-theme-text-tertiary">—</span>
|
|
45
|
+
const tone = top.severity === 'critical' ? 'unhealthy' : top.severity === 'warning' ? 'degraded' : 'neutral'
|
|
46
|
+
const more = row.problems.length - 1
|
|
47
|
+
return (
|
|
48
|
+
<div className="min-w-0">
|
|
49
|
+
<Tooltip content={top.title} wrapperClassName="block">
|
|
50
|
+
<div className={clsx('line-clamp-2 break-words', toneTextClass(tone))}>{top.title}</div>
|
|
51
|
+
</Tooltip>
|
|
52
|
+
{more > 0 && <div className="text-xs text-theme-text-tertiary">+{more} more</div>}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CNPGOverview({
|
|
58
|
+
data,
|
|
59
|
+
fleet,
|
|
60
|
+
namespaces,
|
|
61
|
+
searchParams,
|
|
62
|
+
onSetParams,
|
|
63
|
+
onInspect,
|
|
64
|
+
inspected,
|
|
65
|
+
onClearNamespaces,
|
|
66
|
+
}: CNPGScreenProps) {
|
|
67
|
+
const navigate = useNavigate()
|
|
68
|
+
const { connection } = useConnection()
|
|
69
|
+
const q = searchParams.get('q') ?? ''
|
|
70
|
+
const cat = (searchParams.get('cat') as CNPGProblemCategory | null) ?? null
|
|
71
|
+
const rawFilter = searchParams.get('filter') as Filter | null
|
|
72
|
+
const filter: Filter = rawFilter ?? (fleet.attentionCount > 0 ? 'attention' : 'all')
|
|
73
|
+
|
|
74
|
+
const rows = useMemo(() => {
|
|
75
|
+
let list = fleet.rows
|
|
76
|
+
if (filter === 'attention') list = list.filter((r) => r.attention)
|
|
77
|
+
if (cat) list = list.filter((r) => r.categories.has(cat))
|
|
78
|
+
if (q) {
|
|
79
|
+
const needle = q.toLowerCase()
|
|
80
|
+
list = list.filter((r) => r.name.toLowerCase().includes(needle) || r.namespace.toLowerCase().includes(needle))
|
|
81
|
+
}
|
|
82
|
+
return list
|
|
83
|
+
}, [fleet, filter, cat, q])
|
|
84
|
+
|
|
85
|
+
const clustersCov = data.coverage.clusters
|
|
86
|
+
const total = fleet.rows.length
|
|
87
|
+
const context = connection.context || data.context
|
|
88
|
+
|
|
89
|
+
if (total === 0) {
|
|
90
|
+
const state = clustersCov?.state ?? 'notInstalled'
|
|
91
|
+
const empty =
|
|
92
|
+
state === 'denied'
|
|
93
|
+
? { title: 'No access to PostgreSQL clusters', detail: 'Your identity cannot list CloudNativePG Clusters. Other CloudNativePG kinds may still be browsable under Resource kinds.' }
|
|
94
|
+
: state === 'syncing'
|
|
95
|
+
? { title: 'Loading PostgreSQL clusters', detail: 'Radar is still syncing CloudNativePG Clusters from the API server.' }
|
|
96
|
+
: state === 'error'
|
|
97
|
+
? { title: 'PostgreSQL clusters could not be read', detail: 'Reading CloudNativePG Clusters failed; see the Radar server log.' }
|
|
98
|
+
: state === 'partial'
|
|
99
|
+
? { title: 'No visible PostgreSQL clusters', detail: 'None in the namespaces you can read. Clusters in namespaces you cannot list are not shown.' }
|
|
100
|
+
: namespaces.length > 0
|
|
101
|
+
? { title: `No PostgreSQL clusters in ${context}`, detail: `None in namespace ${namespaces.join(', ')}. Clear the namespace filter to see the whole cluster.` }
|
|
102
|
+
: { title: `No PostgreSQL clusters in ${context}`, detail: 'The CloudNativePG CRDs are installed. Clusters, backups and declarations appear here once they exist.' }
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
105
|
+
<CNPGWorkspaceHeader title="Overview" />
|
|
106
|
+
<EmptyState
|
|
107
|
+
icon={Database}
|
|
108
|
+
title={empty.title}
|
|
109
|
+
detail={empty.detail}
|
|
110
|
+
action={
|
|
111
|
+
namespaces.length > 0 ? (
|
|
112
|
+
<button type="button" onClick={onClearNamespaces} className="mt-3 text-sm font-medium text-accent-text hover:underline">
|
|
113
|
+
Clear namespace filter
|
|
114
|
+
</button>
|
|
115
|
+
) : undefined
|
|
116
|
+
}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chips: { label: string; onClear: () => void }[] = []
|
|
123
|
+
if (cat) chips.push({ label: `Problem: ${CNPG_PROBLEM_CATEGORIES.find((c) => c.id === cat)?.label ?? cat}`, onClear: () => onSetParams({ cat: null }) })
|
|
124
|
+
if (q) chips.push({ label: `Search: ${q}`, onClear: () => onSetParams({ q: null }) })
|
|
125
|
+
if (namespaces.length > 0) chips.push({ label: `Namespace: ${namespaces.join(', ')}`, onClear: onClearNamespaces })
|
|
126
|
+
|
|
127
|
+
const segment = (id: Filter, label: string, n: number) => {
|
|
128
|
+
const on = filter === id
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
key={id}
|
|
132
|
+
type="button"
|
|
133
|
+
role="tab"
|
|
134
|
+
aria-selected={on}
|
|
135
|
+
onClick={() => onSetParams({ filter: id })}
|
|
136
|
+
className={clsx(
|
|
137
|
+
'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
|
138
|
+
on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{label}
|
|
142
|
+
<span className="font-mono text-xs text-theme-text-tertiary">{n}</span>
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lowerBound = fleet.incompleteKinds.length > 0
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
151
|
+
<CNPGWorkspaceHeader
|
|
152
|
+
title="Overview"
|
|
153
|
+
subtitle={
|
|
154
|
+
<>
|
|
155
|
+
{total} PostgreSQL {total === 1 ? 'cluster' : 'clusters'} · {fleet.attentionCount}
|
|
156
|
+
{lowerBound ? '+' : ''} need attention
|
|
157
|
+
<span className="text-theme-text-tertiary"> · {context}</span>
|
|
158
|
+
</>
|
|
159
|
+
}
|
|
160
|
+
/>
|
|
161
|
+
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
162
|
+
<div className="space-y-3 px-5 pb-6 pt-3 xl:px-7">
|
|
163
|
+
<CoverageNotice fleet={fleet} data={data} />
|
|
164
|
+
|
|
165
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
166
|
+
<div role="tablist" aria-label="Clusters" className="inline-flex rounded-lg bg-theme-elevated p-0.5">
|
|
167
|
+
{segment('attention', 'Needs attention', fleet.attentionCount)}
|
|
168
|
+
{segment('all', 'All clusters', total)}
|
|
169
|
+
</div>
|
|
170
|
+
{CNPG_PROBLEM_CATEGORIES.filter((c) => fleet.categoryCounts[c.id] > 0).map((c) => {
|
|
171
|
+
const on = cat === c.id
|
|
172
|
+
return (
|
|
173
|
+
<button
|
|
174
|
+
key={c.id}
|
|
175
|
+
type="button"
|
|
176
|
+
aria-pressed={on}
|
|
177
|
+
onClick={() => onSetParams({ cat: on ? null : c.id })}
|
|
178
|
+
className={clsx(
|
|
179
|
+
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs transition-colors',
|
|
180
|
+
on ? 'border-accent bg-accent-muted text-accent-text' : 'border-theme-border text-theme-text-secondary hover:bg-theme-hover',
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{c.label}
|
|
184
|
+
<span className="font-mono">{fleet.categoryCounts[c.id]}</span>
|
|
185
|
+
</button>
|
|
186
|
+
)
|
|
187
|
+
})}
|
|
188
|
+
<div className="ml-auto flex h-8 w-56 items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-2.5">
|
|
189
|
+
<Search className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
190
|
+
<input
|
|
191
|
+
value={q}
|
|
192
|
+
onChange={(e) => onSetParams({ q: e.target.value })}
|
|
193
|
+
placeholder="Filter clusters…"
|
|
194
|
+
aria-label="Filter clusters"
|
|
195
|
+
className="min-w-0 flex-1 bg-transparent text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<FilterChips chips={chips} />
|
|
201
|
+
|
|
202
|
+
<div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
203
|
+
<div className={TABLE_WRAP}>
|
|
204
|
+
<table className="w-full min-w-[980px] table-fixed">
|
|
205
|
+
<colgroup>
|
|
206
|
+
<col className="w-[22%]" />
|
|
207
|
+
<col className="w-[7%]" />
|
|
208
|
+
<col className="w-[15%]" />
|
|
209
|
+
<col className="w-[15%]" />
|
|
210
|
+
<col className="w-[12%]" />
|
|
211
|
+
<col className="w-[5%]" />
|
|
212
|
+
<col className="w-[14%]" />
|
|
213
|
+
<col className="w-[10%]" />
|
|
214
|
+
</colgroup>
|
|
215
|
+
<thead className={TABLE_HEAD}>
|
|
216
|
+
<tr>
|
|
217
|
+
<th className={TH}>Cluster</th>
|
|
218
|
+
<th className={TH}>Ready</th>
|
|
219
|
+
<th className={TH}>Replication</th>
|
|
220
|
+
<th className={TH}>Protection</th>
|
|
221
|
+
<th className={TH}>Declarations</th>
|
|
222
|
+
<th className={TH}>PG</th>
|
|
223
|
+
<th className={TH}>Needs attention</th>
|
|
224
|
+
<th className={TH}><span className="sr-only">Actions</span></th>
|
|
225
|
+
</tr>
|
|
226
|
+
</thead>
|
|
227
|
+
<tbody className={TBODY}>
|
|
228
|
+
{rows.map((row) => {
|
|
229
|
+
const ref: SelectedResource = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: row.namespace, name: row.name }
|
|
230
|
+
const active = sameResource(inspected, ref)
|
|
231
|
+
return (
|
|
232
|
+
<tr
|
|
233
|
+
key={row.key}
|
|
234
|
+
onClick={() => onInspect(ref)}
|
|
235
|
+
className={clsx('cursor-pointer', ROW_HOVER, active && 'selection')}
|
|
236
|
+
aria-selected={active}
|
|
237
|
+
>
|
|
238
|
+
<td className={TD}>
|
|
239
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
240
|
+
<StatusDot tone={row.attention ? (row.problems.some((p) => p.severity === 'critical') ? 'unhealthy' : 'degraded') : row.controllerStatus.level} />
|
|
241
|
+
<Tooltip content={row.name} wrapperClassName="min-w-0"><span className="block truncate font-medium">{row.name}</span></Tooltip>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="truncate pl-4 text-xs text-theme-text-tertiary">{row.namespace}</div>
|
|
244
|
+
<div className="pl-4"><InstancePills row={row} /></div>
|
|
245
|
+
</td>
|
|
246
|
+
<td className={clsx(TD, 'font-mono')}>
|
|
247
|
+
{row.instances.ready ?? '–'}/{row.instances.desired ?? '–'}
|
|
248
|
+
</td>
|
|
249
|
+
<td className={TD}><FactValue fact={row.replication} /></td>
|
|
250
|
+
<td className={TD}><FactValue fact={row.protection.summary} /></td>
|
|
251
|
+
<td className={TD}><FactValue fact={row.declarations.summary} /></td>
|
|
252
|
+
<td className={clsx(TD, 'font-mono')}>{row.pgVersion ?? '—'}</td>
|
|
253
|
+
<td className={TD}><AttentionCell row={row} /></td>
|
|
254
|
+
<td className={clsx(TD, 'text-right')}>
|
|
255
|
+
<div className="flex items-center justify-end gap-1">
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
aria-label={`Logs from every instance of ${row.name}`}
|
|
259
|
+
onClick={(e) => {
|
|
260
|
+
e.stopPropagation()
|
|
261
|
+
const podProblem = row.problems.find((p) => p.subject.kind === 'Pod')
|
|
262
|
+
const path = cnpgClusterFullPath(row.namespace, row.name, connection.context || undefined, 'logs')
|
|
263
|
+
navigate(podProblem ? `${path}&pod=${encodeURIComponent(podProblem.subject.name)}` : path, {
|
|
264
|
+
state: { returnLabel: currentPageLabel(), returnCtx: connection.context },
|
|
265
|
+
})
|
|
266
|
+
}}
|
|
267
|
+
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md px-2 py-1 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
268
|
+
>
|
|
269
|
+
<FileText className="h-3 w-3" /> Logs
|
|
270
|
+
</button>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
aria-label={`Open ${row.name}`}
|
|
274
|
+
onClick={(e) => {
|
|
275
|
+
e.stopPropagation()
|
|
276
|
+
navigate(cnpgClusterFullPath(row.namespace, row.name, connection.context || undefined), {
|
|
277
|
+
state: { returnLabel: currentPageLabel(), returnCtx: connection.context },
|
|
278
|
+
})
|
|
279
|
+
}}
|
|
280
|
+
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
|
|
281
|
+
>
|
|
282
|
+
Open <ArrowRight className="h-3 w-3" />
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
</td>
|
|
286
|
+
</tr>
|
|
287
|
+
)
|
|
288
|
+
})}
|
|
289
|
+
</tbody>
|
|
290
|
+
</table>
|
|
291
|
+
</div>
|
|
292
|
+
{rows.length === 0 && (
|
|
293
|
+
<div className="px-4 py-6 text-sm text-theme-text-tertiary">
|
|
294
|
+
{filter === 'attention' && !cat && !q
|
|
295
|
+
? 'No clusters need attention.'
|
|
296
|
+
: 'No clusters match these filters.'}{' '}
|
|
297
|
+
<button type="button" onClick={() => onSetParams({ filter: 'all', cat: null, q: null })} className="font-medium text-accent-text hover:underline">
|
|
298
|
+
Show all clusters
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
getCNPGPoolerMode,
|
|
5
|
+
getCNPGPoolerStatus,
|
|
6
|
+
getCNPGPoolerType,
|
|
7
|
+
isApiGroup,
|
|
8
|
+
type HealthLevel,
|
|
9
|
+
} from '@skyhook-io/k8s-ui'
|
|
10
|
+
import {
|
|
11
|
+
CNPGWorkspaceHeader,
|
|
12
|
+
CoverageNotice,
|
|
13
|
+
FilterChips,
|
|
14
|
+
Mono,
|
|
15
|
+
ScreenBody,
|
|
16
|
+
SectionTable,
|
|
17
|
+
Sub,
|
|
18
|
+
cnpgResource,
|
|
19
|
+
coverageEmpty,
|
|
20
|
+
namespaceChip,
|
|
21
|
+
type CNPGScreenProps,
|
|
22
|
+
} from './shared'
|
|
23
|
+
|
|
24
|
+
const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' | 'neutral'> = {
|
|
25
|
+
healthy: 'success',
|
|
26
|
+
degraded: 'warning',
|
|
27
|
+
alert: 'alert',
|
|
28
|
+
unhealthy: 'error',
|
|
29
|
+
unknown: 'neutral',
|
|
30
|
+
neutral: 'neutral',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CNPGPooling({ data, fleet, namespaces, searchParams, onSetParams, onInspect, inspected, onClearNamespaces }: CNPGScreenProps) {
|
|
34
|
+
const clusterFilter = searchParams.get('cluster')
|
|
35
|
+
const poolers = useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
(data.objects.poolers ?? [])
|
|
38
|
+
.filter((p) => isApiGroup(p.apiVersion, 'postgresql.cnpg.io'))
|
|
39
|
+
.filter((p) => !clusterFilter || `${p.metadata?.namespace}/${p.spec?.cluster?.name}` === clusterFilter),
|
|
40
|
+
[data.objects.poolers, clusterFilter],
|
|
41
|
+
)
|
|
42
|
+
const chips = [
|
|
43
|
+
...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
|
|
44
|
+
...namespaceChip(namespaces, onClearNamespaces),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
49
|
+
<CNPGWorkspaceHeader
|
|
50
|
+
title="Pooling"
|
|
51
|
+
subtitle="PgBouncer Poolers, the clusters they front, and their readiness."
|
|
52
|
+
/>
|
|
53
|
+
<ScreenBody>
|
|
54
|
+
<CoverageNotice fleet={fleet} data={data} />
|
|
55
|
+
<FilterChips chips={chips} />
|
|
56
|
+
<SectionTable
|
|
57
|
+
title="Poolers"
|
|
58
|
+
columns={[
|
|
59
|
+
{ header: 'Pooler', width: '22%', cell: (p: any) => <>{p.metadata?.name}<Sub>{p.metadata?.namespace}</Sub></> },
|
|
60
|
+
{
|
|
61
|
+
header: 'Target cluster',
|
|
62
|
+
width: '18%',
|
|
63
|
+
cell: (p) => {
|
|
64
|
+
const name = p.spec?.cluster?.name
|
|
65
|
+
const visible = fleet.rows.some((r) => r.namespace === p.metadata?.namespace && r.name === name)
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
{name ?? '—'}
|
|
69
|
+
{name && !visible && <Sub>not visible in this scope</Sub>}
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{ header: 'Type', width: '8%', cell: (p) => <Mono>{getCNPGPoolerType(p)}</Mono> },
|
|
75
|
+
{ header: 'Mode', width: '12%', cell: (p) => getCNPGPoolerMode(p) },
|
|
76
|
+
{
|
|
77
|
+
header: 'Instances',
|
|
78
|
+
width: '10%',
|
|
79
|
+
cell: (p) => (
|
|
80
|
+
<span className="font-mono">
|
|
81
|
+
{typeof p.status?.instances === 'number' ? p.status.instances : '–'}/{typeof p.spec?.instances === 'number' ? p.spec.instances : '–'}
|
|
82
|
+
</span>
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
header: 'Status',
|
|
87
|
+
width: '14%',
|
|
88
|
+
cell: (p) => {
|
|
89
|
+
const st = getCNPGPoolerStatus(p)
|
|
90
|
+
return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
header: 'Connection pressure',
|
|
95
|
+
width: '16%',
|
|
96
|
+
cell: () => (
|
|
97
|
+
<>
|
|
98
|
+
<span className="text-theme-text-tertiary">Not measured</span>
|
|
99
|
+
<Sub>Needs PgBouncer metrics</Sub>
|
|
100
|
+
</>
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
]}
|
|
104
|
+
rows={poolers}
|
|
105
|
+
rowKey={(p) => `${p.metadata?.namespace}/${p.metadata?.name}`}
|
|
106
|
+
rowResource={(p) => cnpgResource('poolers', p.metadata?.namespace, p.metadata?.name)}
|
|
107
|
+
onInspect={onInspect}
|
|
108
|
+
inspected={inspected}
|
|
109
|
+
minWidth={880}
|
|
110
|
+
empty={coverageEmpty(data.coverage.poolers, 'Poolers')}
|
|
111
|
+
footer="Instances are the Pooler’s own ready count. Client waits and server-pool saturation come from PgBouncer metrics, which Radar does not read yet."
|
|
112
|
+
/>
|
|
113
|
+
</ScreenBody>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|