@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.
Files changed (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. 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
+ }