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 ? (
+ }
+ >
+ Installed
+
+ ) : (
+ }
+ endIcon={}
+ onClick={() => openAppCatalog(CHARTS.cnpg)}
+ >
+ Install CloudNativePG
+
+ )}
+
+
+
+
+ )}
+
+ {/* 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 ? (
+ }
+ >
+ Installed
+
+ ) : (
+
+ }
+ onClick={() => openAppCatalog(CHARTS.certManager)}
+ >
+ Install via App Catalog
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* 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 ? (
+ }
+ >
+ Installed
+
+ ) : (
+
+ {/* Tooltip needs a non-disabled wrapper to fire while the button is disabled. */}
+
+ }
+ onClick={() => openAppCatalog(CHARTS.barman)}
+ >
+ Install via App Catalog
+
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* 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 ? (
+ }
+ >
+ Installed
+
+ ) : (
+ }
+ >
+ Coming soon
+
+ )}
+
+
+
+
+ )}
+
+
+ );
+}
+
+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 && (
+ }
+ onClick={onAction}
+ >
+ {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({