diff --git a/src/components/status/InstallGuide.tsx b/src/components/status/InstallGuide.tsx new file mode 100644 index 0000000..87d5eb0 --- /dev/null +++ b/src/components/status/InstallGuide.tsx @@ -0,0 +1,937 @@ +import { Icon } from '@iconify/react'; +import { K8s, Router } from '@kinvolk/headlamp-plugin/lib'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import { useSnackbar } from 'notistack'; +import { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { AuthDisabledButton } from '../common/AuthDisabledButton'; +import { CnpgLogo } from '../common/CnpgLogo'; + +const { createRouteURL } = Router; + +export const CERT_MANAGER_CRD = 'certificates.cert-manager.io'; +const CERT_MANAGER_LABEL = 'app.kubernetes.io/name=cert-manager'; + +export const BARMAN_PLUGIN_ID = 'barman-cloud.cloudnative-pg.io'; +export const KLIO_PLUGIN_ID = 'klio.cnpg.io'; + +// Deep links into the Headlamp App Catalog's chart-detail route +// (/helm/:repoName/charts/:chartName), which resolves the official, CNCF-published charts via +// ArtifactHub. When the App Catalog route isn't registered (e.g. Headlamp running without the +// app-catalog plugin), fall back to opening the same ArtifactHub chart page in a new tab. +const CHARTS = { + cnpg: { + repoName: 'cloudnative-pg', + chartName: 'cloudnative-pg', + fallback: 'https://artifacthub.io/packages/helm/cloudnative-pg/cloudnative-pg', + }, + certManager: { + repoName: 'cert-manager', + chartName: 'cert-manager', + fallback: 'https://artifacthub.io/packages/helm/cert-manager/cert-manager', + }, + barman: { + repoName: 'cloudnative-pg', + chartName: 'plugin-barman-cloud', + fallback: 'https://artifacthub.io/packages/helm/cloudnative-pg/plugin-barman-cloud', + }, +} as const; + +type ChartRef = (typeof CHARTS)[keyof typeof CHARTS]; + +export const DOC_URLS = { + cnpg: 'https://cloudnative-pg.io/documentation/', + certManager: 'https://cert-manager.io/docs/', + barman: 'https://cloudnative-pg.github.io/plugin-barman-cloud/', + klio: 'https://cloudnative-pg.io/klio/', +} as const; + +/** Pure check, kept outside the hook so it stays unit-testable without a cluster. */ +export function isCertManagerInstalled( + crdNames: string[], + certManagerDeployments: number +): boolean { + return crdNames.some(name => name === CERT_MANAGER_CRD) && certManagerDeployments > 0; +} + +export function useCertManagerStatus(crds: { getName(): string }[] | null): { + installed: boolean; + loading: boolean; +} { + // The official Jetstack chart labels its Deployments with app.kubernetes.io/name=cert-manager. + const [deployments] = K8s.ResourceClasses.Deployment.useList({ + labelSelector: CERT_MANAGER_LABEL, + }); + if (crds === null || deployments === null) { + return { installed: false, loading: true }; + } + return { + installed: isCertManagerInstalled( + crds.map(c => c.getName()), + deployments.length + ), + loading: false, + }; +} + +/** + * Opens an official chart's page in the Headlamp App Catalog via the router so the cluster + * prefix is preserved. The catalog is a separate feature — its route only exists when the + * app-catalog plugin is installed — so when no route resolves we fall back to opening the + * same ArtifactHub chart page in a new tab. + */ +function useOpenAppCatalog() { + const history = useHistory(); + return (chart: ChartRef) => { + const chartUrl = createRouteURL('/helm/:repoName/charts/:chartName', { + repoName: chart.repoName, + chartName: chart.chartName, + }); + if (chartUrl) { + history.push(chartUrl); + } else { + window.open(chart.fallback, '_blank', 'noopener,noreferrer'); + } + }; +} + +function StatusChip({ installed, checking }: { installed: boolean; checking: boolean }) { + if (checking) { + return ; + } + return installed ? ( + + ) : ( + ({ + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : '#eef1f6', + color: theme.palette.text.secondary, + fontWeight: 500, + })} + /> + ); +} + +function CheckList({ items }: { items: string[] }) { + return ( + + {items.map(item => ( + + (theme.palette.mode === 'dark' ? '#66bb6a' : '#2e7d32'), + }} + > + + + {item} + + ))} + + ); +} + +function NamespaceCreateButton({ name }: { name: string }) { + const { enqueueSnackbar } = useSnackbar(); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(false); + const NamespaceClass = K8s.ResourceClasses.Namespace; + + if (created) { + return ; + } + + return ( + + + + ); +} + +function NamespaceExistsNote({ name }: { name: string }) { + return ( + + (theme.palette.mode === 'dark' ? '#66bb6a' : '#2e7d32'), + }} + > + + + + Namespace "{name}" exists + + + ); +} + +/** Lets the user type a custom namespace name and create it directly in Headlamp. */ +function CustomNamespaceCreate() { + const { enqueueSnackbar } = useSnackbar(); + const [name, setName] = useState(''); + const [creating, setCreating] = useState(false); + const NamespaceClass = K8s.ResourceClasses.Namespace; + const trimmed = name.trim().toLowerCase(); + const valid = trimmed.length > 0 && NamespaceClass.isValidNamespaceFormat(trimmed); + + return ( + + setName(e.target.value)} + error={name.length > 0 && !valid} + helperText={ + name.length > 0 && !valid ? 'Lowercase letters, numbers and dashes.' : undefined + } + sx={{ flexGrow: 1 }} + /> + + + + + ); +} + +/** Copyable Helm values snippet, e.g. settings the chart requires to work with CNPG. */ +function RequiredValuesNote({ values }: { values: string }) { + const { enqueueSnackbar } = useSnackbar(); + + return ( + + + Required Helm values + + + Paste into the Values field when installing from the App Catalog. + + ({ + display: 'flex', + alignItems: 'flex-start', + gap: 1, + p: 1, + borderRadius: 1, + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : '#eef1f6', + })} + > + + {values} + + + { + navigator.clipboard + .writeText(values) + .then(() => enqueueSnackbar('Helm values copied', { variant: 'success' })) + .catch((err: unknown) => + enqueueSnackbar( + err instanceof Error ? err.message : 'Failed to copy Helm values', + { variant: 'error' } + ) + ); + }} + > + + + + + + ); +} + +function PrereqList({ + items, + namespace, +}: { + items: string[]; + /** Suggested namespace: offers one-click creation in Headlamp (no kubectl needed), or a + * confirmation when it already exists. `exists: null` while the namespace list loads. */ + namespace?: { name: string; exists: boolean | null }; +}) { + return ( + + + + + + + Prerequisites + + + {items.map(item => ( + + {item} + + ))} + + {namespace && namespace.exists === null && ( + + Checking namespace… + + )} + {namespace && namespace.exists === true && } + {namespace && namespace.exists === false && ( + + + + or + + + + )} + + + ); +} + +function CardHeader({ + icon, + name, + subtitle, + installed, + checking, +}: { + icon: React.ReactNode; + name: string; + subtitle: string; + installed: boolean; + checking: boolean; +}) { + return ( + + {icon} + + + {name} + + + {subtitle} + + + + + ); +} + +function CircleIcon({ icon }: { icon: string }) { + return ( + + theme.palette.mode === 'dark' ? 'rgba(25,118,210,0.25)' : '#e3edfb', + color: theme => (theme.palette.mode === 'dark' ? '#90caf9' : '#1565c0'), + }} + > + + + ); +} + +function LearnMoreRow({ + docsUrl, + extra, +}: { + docsUrl: string; + extra?: { label: string; href: string }; +}) { + return ( + + + Learn more + + {extra && ( + + {extra.label} + + )} + + ); +} + +export function ComponentCards({ + cnpgInstalled, + certManagerInstalled, + barmanInstalled, + klioInstalled, + loading, +}: { + cnpgInstalled: boolean; + certManagerInstalled: boolean; + barmanInstalled: boolean; + klioInstalled: boolean; + loading: boolean; +}) { + const openAppCatalog = useOpenAppCatalog(); + // Barman Cloud requires both the operator and cert-manager (its chart issues TLS + // certificates), so its install action stays disabled until those are satisfied. + const barmanBlocked = !cnpgInstalled || !certManagerInstalled; + const [namespaces] = K8s.ResourceClasses.Namespace.useList(); + const namespaceExists = (name: string): boolean | null => + namespaces === null ? null : namespaces.some(ns => ns.getName() === name); + + // Installed components are hidden here — they already have their status in the + // "Installed components" section above. Nothing left to show when all are installed. + if (cnpgInstalled && certManagerInstalled && barmanInstalled && klioInstalled) { + return null; + } + + return ( + + + Components + + + {cnpgInstalled && certManagerInstalled + ? 'Install the optional backup plugins in just a few steps.' + : 'Install the operator and optional backup plugins in just a few steps.'} + + + {/* CloudNativePG operator */} + {!cnpgInstalled && ( + + + } + name="CloudNativePG" + subtitle="Operator" + installed={cnpgInstalled} + checking={loading} + /> + + Installs the CloudNativePG operator to manage PostgreSQL clusters. + + ({ + backgroundColor: + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : '#f5f8fc', + borderRadius: 1, + p: 1.5, + mt: 1.5, + })} + > + + What you'll get + + + + + + {cnpgInstalled ? ( + + ) : ( + + )} + + + + + )} + + {/* cert-manager */} + {!certManagerInstalled && ( + + + } + name="cert-manager" + subtitle="TLS certificate management" + installed={certManagerInstalled} + checking={loading} + /> + + Required by CloudNativePG and some plugins to manage TLS certificates. + + ({ + backgroundColor: + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : '#f5f8fc', + borderRadius: 1, + p: 1.5, + mt: 1.5, + })} + > + + What you'll get + + + + + + + {certManagerInstalled ? ( + + ) : ( + + + + )} + + + + + )} + + {/* Barman Cloud plugin */} + {!barmanInstalled && ( + + + } + name="Barman Cloud" + subtitle="Backup and recovery plugin" + installed={barmanInstalled} + checking={loading} + /> + + Integrates PostgreSQL backups with object storage using Barman Cloud. + + ({ + backgroundColor: + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : '#f5f8fc', + borderRadius: 1, + p: 1.5, + mt: 1.5, + })} + > + + What you'll get + + + + + + {barmanInstalled ? ( + + ) : ( + + {/* Tooltip needs a non-disabled wrapper to fire while the button is disabled. */} + + + + + )} + + + + + )} + + {/* Klio plugin (coming soon) */} + {!klioInstalled && ( + + + } + name="Klio" + subtitle="Multi-tiered backup and recovery" + installed={klioInstalled} + checking={loading} + /> + + Klio provides advanced backup and recovery workflows with multi-tiered storage. + + ({ + backgroundColor: + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : '#f5f8fc', + borderRadius: 1, + p: 1.5, + mt: 1.5, + })} + > + + What you'll get + + + + + + {klioInstalled ? ( + + ) : ( + + )} + + + + + )} + + + ); +} + +function WorkflowStep({ + number, + icon, + title, + description, + action, + actionLabel, + onAction, +}: { + number: number; + icon: string; + title: string; + description: string; + /** Custom action node (takes precedence over actionLabel/onAction). */ + action?: React.ReactNode; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + + + {number} + + + + + + + {title} + + + {description} + + {action ?? + (actionLabel && ( + + ))} + + ); +} + +export function InstallationWorkflow() { + const openAppCatalog = useOpenAppCatalog(); + + return ( + ({ + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(25,118,210,0.08)' : '#f2f7fd', + borderRadius: 2, + p: 2.5, + })} + > + + Installation workflow + + + Follow the steps below to get your environment ready. Some components can be installed + directly from Headlamp's App Catalog. + + + + + + + openAppCatalog(CHARTS.cnpg)} + /> + + + + openAppCatalog(CHARTS.certManager)} + /> + + + + openAppCatalog(CHARTS.barman)} + /> + + + + + + + ); +} diff --git a/src/components/status/OperatorStatus.tsx b/src/components/status/OperatorStatus.tsx index f2c9e22..ea34bea 100644 --- a/src/components/status/OperatorStatus.tsx +++ b/src/components/status/OperatorStatus.tsx @@ -24,6 +24,13 @@ import { CnpgLogo } from '../common/CnpgLogo'; import { Pod, PodStatusLabel } from '../common/podActions'; import { ViewLogsButton } from '../common/podLogs'; import { OpenTerminalButton } from '../common/podTerminal'; +import { + BARMAN_PLUGIN_ID, + ComponentCards, + InstallationWorkflow, + KLIO_PLUGIN_ID, + useCertManagerStatus, +} from './InstallGuide'; type Crd = InstanceType; type Deployment = InstanceType; @@ -357,9 +364,9 @@ function HealthBanner({ const operatorPods = pods ?? []; if (operatorPods.length === 0) { return ( - - Operator is not installed - No pod matching label {OPERATOR_LABEL} was found in any namespace. + + Not installed + No CloudNativePG components are installed in this cluster yet. ); } @@ -581,6 +588,7 @@ function InstalledComponents({ services: Service[] | null; onHealth?: (uid: string, health: PluginHealth) => void; }) { + const operatorInstalled = (pods ?? []).length > 0; return ( <> @@ -590,17 +598,11 @@ function InstalledComponents({ - + {operatorInstalled && } {(services ?? []).map(service => ( ))} - {services !== null && services.length === 0 && ( - - No Service carrying a {PLUGIN_LABEL} label was found — no CNPG-i plugins (e.g. Barman - Cloud) appear to be installed. - - )} ); } @@ -631,6 +633,20 @@ export function OperatorStatus() { const loading = crds === null || operatorPods === null || deployments === null || services === null; + const { installed: certManagerInstalled, loading: certManagerLoading } = + useCertManagerStatus(crds); + const cnpgInstalled = (operatorPods ?? []).length > 0; + const pluginIds = new Set( + (services ?? []).map(s => s.metadata.labels?.[PLUGIN_LABEL] ?? s.getName()) + ); + const barmanInstalled = pluginIds.has(BARMAN_PLUGIN_ID); + const klioInstalled = pluginIds.has(KLIO_PLUGIN_ID); + const nothingInstalled = + !loading && !certManagerLoading && !cnpgInstalled && (services ?? []).length === 0; + // The workflow's guided setup (operator + cert-manager) is done once both are present — + // remaining plugins are covered by the Components section below. + const coreInstalled = cnpgInstalled && certManagerInstalled; + // Same page padding as the cluster details page, so the title doesn't hug the viewport edge. return ( @@ -652,7 +668,7 @@ export function OperatorStatus() { CloudNativePG - Operator Status + Operator & Plugins Status @@ -666,20 +682,36 @@ export function OperatorStatus() { loading={loading} /> - - + + + + + )} + {!loading && !nothingInstalled && ( + - - - - } + ); diff --git a/src/index.tsx b/src/index.tsx index da63299..c03e121 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -64,7 +64,7 @@ registerSidebarEntry({ name: 'cnpg-status', url: '/cnpg/status', parent: 'cnpg-root', - label: 'Status', + label: 'Operator Status', }); registerSidebarEntry({