@skyhook-io/radar-app 1.16.1 → 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 +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -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/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- 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/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- 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 +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
|
@@ -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
|
+
}
|