@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,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
|
+
}
|
|
@@ -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
|
+
}
|