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