feat: initial release of headlamp-rook-ceph-plugin v0.1.0
Headlamp plugin for Rook-Ceph cluster visibility. Pages: - Overview dashboard: CephCluster health, capacity bar, resource counts (block pools, filesystems, object stores, PVs, PVCs), daemon pod health summary, non-Bound PVC alerts - Block Pools: CephBlockPool table with replication, failure domain, mirroring; slide-in detail panel - Pods: all Rook-Ceph daemon pods grouped by role with ready/total counts Native Headlamp integrations: - StorageClass table: Rook Type, Pool, Cluster ID columns - PV table: Rook Type, Pool columns - PVC detail injection: driver, type, pool, volume handle - PV detail injection: CSI volume attributes - Pod detail injection: Ceph daemon role badge - App bar badge: cluster health (HEALTH_OK/WARN/ERR), color-coded API / architecture: - src/api/k8s.ts: types + filters for ceph.rook.io/v1 CRDs; handles both default rook-ceph.* and custom-namespace provisioner strings - src/api/RookCephDataContext.tsx: shared context provider; fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs plus daemon pods via label selectors - 37 unit tests (vitest + @testing-library/react) - TypeScript strict mode, zero any types - CI + release GitHub Actions workflows Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* AppBarClusterBadge — registerAppBarAction cluster health badge.
|
||||
*
|
||||
* Displays "rook-ceph: HEALTH_OK" in the Headlamp top nav bar.
|
||||
* Color-coded: green=HEALTH_OK, orange=HEALTH_WARN, red=HEALTH_ERR.
|
||||
* Returns null if no CephCluster found (no clutter on unmanaged clusters).
|
||||
*
|
||||
* Wrapped in RookCephDataProvider at registration time (index.tsx).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
|
||||
function getHealthColor(health: string | undefined): string {
|
||||
switch (health) {
|
||||
case 'HEALTH_OK': return '#4caf50';
|
||||
case 'HEALTH_WARN': return '#ff9800';
|
||||
case 'HEALTH_ERR': return '#f44336';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppBarClusterBadge() {
|
||||
const { cephClusters, loading } = useRookCephContext();
|
||||
const history = useHistory();
|
||||
|
||||
if (loading || cephClusters.length === 0) return null;
|
||||
|
||||
const primary = cephClusters[0];
|
||||
const health = primary?.status?.ceph?.health;
|
||||
|
||||
const color = getHealthColor(health);
|
||||
const label = health ?? 'Unknown';
|
||||
const ariaLabel = `Rook-Ceph cluster health: ${label}`;
|
||||
|
||||
const handleClick = () => {
|
||||
history.push('/rook-ceph');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
backgroundColor: color,
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<span>rook-ceph: {label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* BlockPoolsPage — lists CephBlockPool resources.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useState } from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { CephBlockPool, formatAge, phaseToStatus } from '../api/k8s';
|
||||
|
||||
function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{pool.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<SectionBox title="Block Pool Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Name', value: pool.metadata.name },
|
||||
{ name: 'Namespace', value: pool.metadata.namespace ?? '—' },
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={phaseToStatus(pool.status?.phase)}>
|
||||
{pool.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ name: 'Age', value: formatAge(pool.metadata.creationTimestamp) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
<SectionBox title="Replication">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Replicas', value: String(pool.spec?.replicated?.size ?? '—') },
|
||||
{
|
||||
name: 'Require Safe Replica Size',
|
||||
value: String(pool.spec?.replicated?.requireSafeReplicaSize ?? '—'),
|
||||
},
|
||||
{ name: 'Failure Domain', value: pool.spec?.failureDomain ?? '—' },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
{pool.spec?.erasureCoded && (
|
||||
<SectionBox title="Erasure Coding">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Data Chunks', value: String(pool.spec.erasureCoded.dataChunks ?? '—') },
|
||||
{ name: 'Coding Chunks', value: String(pool.spec.erasureCoded.codingChunks ?? '—') },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
{pool.status?.info && Object.keys(pool.status.info).length > 0 && (
|
||||
<SectionBox title="Status Info">
|
||||
<NameValueTable
|
||||
rows={Object.entries(pool.status.info).map(([k, v]) => ({ name: k, value: v }))}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlockPoolsPage() {
|
||||
const { blockPools, loading, error } = useRookCephContext();
|
||||
const [selected, setSelected] = useState<CephBlockPool | null>(null);
|
||||
|
||||
if (loading) return <Loader title="Loading block pools..." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Block Pools" />
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{blockPools.length === 0 ? (
|
||||
<SectionBox title="No Block Pools">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
<SectionBox title={`Block Pools (${blockPools.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'Name',
|
||||
getter: (p: CephBlockPool) => (
|
||||
<button
|
||||
onClick={() => setSelected(p)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
{p.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (p: CephBlockPool) => (
|
||||
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Replicas', getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—') },
|
||||
{ label: 'Failure Domain', getter: (p: CephBlockPool) => p.spec?.failureDomain ?? '—' },
|
||||
{ label: 'Mirroring', getter: (p: CephBlockPool) => p.spec?.mirroring?.enabled ? 'Enabled' : 'Disabled' },
|
||||
{ label: 'Age', getter: (p: CephBlockPool) => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={blockPools}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<BlockPoolDetail pool={selected} onClose={() => setSelected(null)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* CephPodDetailSection — injected into Headlamp's Pod detail view.
|
||||
*
|
||||
* Shown only for Rook-Ceph daemon pods (operator, mon, osd, mgr, csi).
|
||||
* Guards on rook-ceph label presence.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatAge, getPodRestarts } from '../api/k8s';
|
||||
|
||||
interface CephPodDetailSectionProps {
|
||||
resource: {
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec?: { nodeName?: string; containers?: Array<{ name: string; image?: string }> };
|
||||
status?: {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: Array<{
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string };
|
||||
terminated?: { reason?: string; exitCode?: number };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
jsonData?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const ROOK_APP_LABELS = new Set([
|
||||
'rook-ceph-operator',
|
||||
'rook-ceph-mon',
|
||||
'rook-ceph-osd',
|
||||
'rook-ceph-mgr',
|
||||
'rook-ceph-mds',
|
||||
'rook-ceph-rgw',
|
||||
'csi-rbdplugin-provisioner',
|
||||
'csi-cephfsplugin-provisioner',
|
||||
'csi-rbdplugin',
|
||||
'csi-cephfsplugin',
|
||||
]);
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
'rook-ceph-operator': 'Operator',
|
||||
'rook-ceph-mon': 'Monitor (MON)',
|
||||
'rook-ceph-osd': 'OSD',
|
||||
'rook-ceph-mgr': 'Manager (MGR)',
|
||||
'rook-ceph-mds': 'MDS (CephFS)',
|
||||
'rook-ceph-rgw': 'RGW (Object Gateway)',
|
||||
'csi-rbdplugin-provisioner': 'CSI RBD Provisioner',
|
||||
'csi-cephfsplugin-provisioner': 'CSI CephFS Provisioner',
|
||||
'csi-rbdplugin': 'CSI RBD Node Plugin',
|
||||
'csi-cephfsplugin': 'CSI CephFS Node Plugin',
|
||||
};
|
||||
|
||||
export default function CephPodDetailSection({ resource }: CephPodDetailSectionProps) {
|
||||
const raw =
|
||||
resource.jsonData && typeof resource.jsonData === 'object'
|
||||
? (resource.jsonData as typeof resource)
|
||||
: resource;
|
||||
|
||||
const labels = raw.metadata?.labels ?? {};
|
||||
const appLabel = labels['app'] ?? '';
|
||||
|
||||
if (!ROOK_APP_LABELS.has(appLabel)) return null;
|
||||
|
||||
const role = ROLE_LABELS[appLabel] ?? appLabel;
|
||||
const phase = raw.status?.phase ?? 'Unknown';
|
||||
const isReady =
|
||||
raw.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
const restarts =
|
||||
raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
|
||||
|
||||
const containerRows = (raw.status?.containerStatuses ?? []).map((cs) => {
|
||||
let stateStr = 'Unknown';
|
||||
if (cs.state?.running) stateStr = 'Running';
|
||||
else if (cs.state?.waiting) stateStr = `Waiting: ${cs.state.waiting.reason ?? ''}`;
|
||||
else if (cs.state?.terminated)
|
||||
stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${cs.state.terminated.exitCode ?? ''})`;
|
||||
|
||||
return {
|
||||
name: cs.name,
|
||||
value: (
|
||||
<StatusLabel status={cs.ready ? 'success' : 'warning'}>
|
||||
{stateStr} | Restarts: {cs.restartCount}
|
||||
</StatusLabel>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionBox title="Rook-Ceph Daemon Info">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Role',
|
||||
value: <StatusLabel status="success">{role}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={isReady ? 'success' : 'error'}>
|
||||
{phase}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ name: 'Node', value: raw.spec?.nodeName ?? '—' },
|
||||
{ name: 'Restarts', value: String(restarts) },
|
||||
{ name: 'Age', value: formatAge(raw.metadata?.creationTimestamp) },
|
||||
...containerRows,
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* ClusterStatusCard — reusable component showing Rook-Ceph cluster health.
|
||||
* Displays CephCluster health, phase, capacity, version, and daemon pod counts.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
PercentageBar,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import type { CephCluster, RookCephPod } from '../api/k8s';
|
||||
import { formatAge, formatBytes, getPodImage, getPodRestarts, healthToStatus, isPodReady, phaseToStatus } from '../api/k8s';
|
||||
|
||||
interface ClusterStatusCardProps {
|
||||
cephClusters: CephCluster[];
|
||||
operatorPods: RookCephPod[];
|
||||
monPods: RookCephPod[];
|
||||
osdPods: RookCephPod[];
|
||||
mgrPods: RookCephPod[];
|
||||
csiRbdPods: RookCephPod[];
|
||||
csiCephfsPods: RookCephPod[];
|
||||
}
|
||||
|
||||
function PodStatusBadge({ pod }: { pod: RookCephPod }) {
|
||||
const ready = isPodReady(pod);
|
||||
const phase = pod.status?.phase ?? 'Unknown';
|
||||
return (
|
||||
<StatusLabel status={ready ? 'success' : 'error'}>
|
||||
{phase}
|
||||
</StatusLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function PodSummaryRow({ pods, label }: { pods: RookCephPod[]; label: string }) {
|
||||
const ready = pods.filter(isPodReady).length;
|
||||
const total = pods.length;
|
||||
const status = total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error';
|
||||
return {
|
||||
name: label,
|
||||
value: (
|
||||
<StatusLabel status={status}>
|
||||
{total === 0 ? 'None found' : `${ready}/${total} ready`}
|
||||
</StatusLabel>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ClusterStatusCard({
|
||||
cephClusters,
|
||||
operatorPods,
|
||||
monPods,
|
||||
osdPods,
|
||||
mgrPods,
|
||||
csiRbdPods,
|
||||
csiCephfsPods,
|
||||
}: ClusterStatusCardProps) {
|
||||
return (
|
||||
<>
|
||||
{cephClusters.map(cluster => {
|
||||
const health = cluster.status?.ceph?.health;
|
||||
const phase = cluster.status?.phase;
|
||||
const capacity = cluster.status?.ceph?.capacity;
|
||||
const version = cluster.status?.version?.version ?? '—';
|
||||
const bytesTotal = capacity?.bytesTotal ?? 0;
|
||||
const bytesUsed = capacity?.bytesUsed ?? 0;
|
||||
const bytesAvail = capacity?.bytesAvailable ?? 0;
|
||||
const usedPct = bytesTotal > 0 ? Math.round((bytesUsed / bytesTotal) * 100) : 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={cluster.metadata.name}>
|
||||
<SectionBox title={`CephCluster: ${cluster.metadata.name}`}>
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Health',
|
||||
value: (
|
||||
<StatusLabel status={healthToStatus(health)}>
|
||||
{health ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={phaseToStatus(phase)}>
|
||||
{phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
...(cluster.status?.message ? [{ name: 'Message', value: cluster.status.message }] : []),
|
||||
{ name: 'Ceph Version', value: version },
|
||||
{ name: 'Namespace', value: cluster.metadata.namespace ?? '—' },
|
||||
{ name: 'Age', value: formatAge(cluster.metadata.creationTimestamp) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{bytesTotal > 0 && (
|
||||
<SectionBox title="Cluster Capacity">
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<PercentageBar
|
||||
data={[
|
||||
{ name: 'Used', value: bytesUsed, fill: usedPct > 80 ? '#f44336' : '#1976d2' },
|
||||
{ name: 'Free', value: bytesAvail, fill: '#e0e0e0' },
|
||||
]}
|
||||
total={bytesTotal}
|
||||
/>
|
||||
</div>
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Total', value: formatBytes(bytesTotal) },
|
||||
{ name: 'Used', value: `${formatBytes(bytesUsed)} (${usedPct}%)` },
|
||||
{ name: 'Available', value: formatBytes(bytesAvail) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<SectionBox title="Daemon Health">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
PodSummaryRow({ pods: operatorPods, label: 'Operator' }),
|
||||
PodSummaryRow({ pods: monPods, label: 'Monitors (MON)' }),
|
||||
PodSummaryRow({ pods: osdPods, label: 'OSDs' }),
|
||||
PodSummaryRow({ pods: mgrPods, label: 'Managers (MGR)' }),
|
||||
PodSummaryRow({ pods: csiRbdPods, label: 'CSI RBD Provisioner' }),
|
||||
PodSummaryRow({ pods: csiCephfsPods, label: 'CSI CephFS Provisioner' }),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PodDetailRows({ pods, label }: { pods: RookCephPod[]; label: string }) {
|
||||
if (pods.length === 0) {
|
||||
return (
|
||||
<SectionBox title={label}>
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title={`${label} (${pods.length})`}>
|
||||
{pods.map(pod => (
|
||||
<NameValueTable
|
||||
key={pod.metadata.name}
|
||||
rows={[
|
||||
{ name: 'Pod', value: pod.metadata.name },
|
||||
{ name: 'Node', value: pod.spec?.nodeName ?? '—' },
|
||||
{ name: 'Status', value: <PodStatusBadge pod={pod} /> },
|
||||
{ name: 'Restarts', value: String(getPodRestarts(pod)) },
|
||||
{ name: 'Image', value: getPodImage(pod) },
|
||||
{ name: 'Age', value: formatAge(pod.metadata.creationTimestamp) },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* OverviewPage — main dashboard for the Rook-Ceph plugin.
|
||||
*
|
||||
* Shows: cluster health, capacity overview, storage resource counts,
|
||||
* daemon pod summary, and non-Bound PVC alerts.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
PercentageBar,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { formatAge, formatBytes, healthToStatus, phaseToStatus, storageClassType } from '../api/k8s';
|
||||
import ClusterStatusCard from './ClusterStatusCard';
|
||||
|
||||
export default function OverviewPage() {
|
||||
const {
|
||||
cephClusters,
|
||||
clusterInstalled,
|
||||
blockPools,
|
||||
filesystems,
|
||||
objectStores,
|
||||
storageClasses,
|
||||
persistentVolumes,
|
||||
persistentVolumeClaims,
|
||||
operatorPods,
|
||||
monPods,
|
||||
osdPods,
|
||||
mgrPods,
|
||||
csiRbdPods,
|
||||
csiCephfsPods,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
} = useRookCephContext();
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Rook-Ceph data..." />;
|
||||
}
|
||||
|
||||
// Storage summary
|
||||
const rbdClasses = storageClasses.filter(sc => storageClassType(sc) === 'rbd');
|
||||
const cephfsClasses = storageClasses.filter(sc => storageClassType(sc) === 'cephfs');
|
||||
|
||||
const totalCapacityBytes = persistentVolumes.reduce((sum, pv) => {
|
||||
const cap = pv.spec.capacity?.storage ?? '0';
|
||||
return sum + parseStorageToBytes(cap);
|
||||
}, 0);
|
||||
|
||||
const pvcStatusCounts = { Bound: 0, Pending: 0, Lost: 0, Other: 0 };
|
||||
for (const pvc of persistentVolumeClaims) {
|
||||
const phase = pvc.status?.phase ?? 'Other';
|
||||
if (phase === 'Bound') pvcStatusCounts.Bound++;
|
||||
else if (phase === 'Pending') pvcStatusCounts.Pending++;
|
||||
else if (phase === 'Lost') pvcStatusCounts.Lost++;
|
||||
else pvcStatusCounts.Other++;
|
||||
}
|
||||
|
||||
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
|
||||
|
||||
// Primary cluster health (first cluster)
|
||||
const primaryCluster = cephClusters[0];
|
||||
const primaryHealth = primaryCluster?.status?.ceph?.health;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<SectionHeader title="Rook-Ceph — Overview" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
aria-label="Refresh Rook-Ceph data"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cluster not detected */}
|
||||
{!clusterInstalled && !loading && (
|
||||
<SectionBox title="Rook-Ceph Not Detected">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">No CephCluster found in namespace rook-ceph</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Install',
|
||||
value: 'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace',
|
||||
},
|
||||
{
|
||||
name: 'Docs',
|
||||
value: 'https://rook.io/docs/rook/latest/Getting-Started/quickstart/',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Quick health summary banner when cluster is installed */}
|
||||
{clusterInstalled && primaryHealth && (
|
||||
<SectionBox title="Cluster Health">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Health',
|
||||
value: (
|
||||
<StatusLabel status={healthToStatus(primaryHealth)}>
|
||||
{primaryHealth}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Clusters',
|
||||
value: String(cephClusters.length),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Storage type distribution */}
|
||||
{storageClasses.length > 0 && (
|
||||
<SectionBox title="Storage Summary">
|
||||
{storageClasses.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
StorageClass Type Distribution
|
||||
</div>
|
||||
<PercentageBar
|
||||
data={[
|
||||
...(rbdClasses.length > 0
|
||||
? [{ name: 'Block (RBD)', value: rbdClasses.length, fill: '#1976d2' }]
|
||||
: []),
|
||||
...(cephfsClasses.length > 0
|
||||
? [{ name: 'Filesystem (CephFS)', value: cephfsClasses.length, fill: '#9c27b0' }]
|
||||
: []),
|
||||
]}
|
||||
total={storageClasses.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Storage Classes', value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)` },
|
||||
{ name: 'Block Pools', value: String(blockPools.length) },
|
||||
{ name: 'Filesystems', value: String(filesystems.length) },
|
||||
{ name: 'Object Stores', value: String(objectStores.length) },
|
||||
{ name: 'Persistent Volumes', value: String(persistentVolumes.length) },
|
||||
{ name: 'Total PV Capacity', value: formatBytes(totalCapacityBytes) },
|
||||
{
|
||||
name: 'PVCs (Bound)',
|
||||
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
|
||||
},
|
||||
...(pvcStatusCounts.Pending > 0
|
||||
? [{ name: 'PVCs (Pending)', value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel> }]
|
||||
: []),
|
||||
...(pvcStatusCounts.Lost > 0
|
||||
? [{ name: 'PVCs (Lost)', value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel> }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Cluster status + capacity + daemon health */}
|
||||
<ClusterStatusCard
|
||||
cephClusters={cephClusters}
|
||||
operatorPods={operatorPods}
|
||||
monPods={monPods}
|
||||
osdPods={osdPods}
|
||||
mgrPods={mgrPods}
|
||||
csiRbdPods={csiRbdPods}
|
||||
csiCephfsPods={csiCephfsPods}
|
||||
/>
|
||||
|
||||
{/* Block pools table */}
|
||||
{blockPools.length > 0 && (
|
||||
<SectionBox title="Block Pools">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (p) => (
|
||||
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Replicas', getter: (p) => String(p.spec?.replicated?.size ?? '—') },
|
||||
{ label: 'Failure Domain', getter: (p) => p.spec?.failureDomain ?? '—' },
|
||||
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={blockPools}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Filesystems table */}
|
||||
{filesystems.length > 0 && (
|
||||
<SectionBox title="Filesystems">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (f) => f.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (f) => (
|
||||
<StatusLabel status={phaseToStatus(f.status?.phase)}>
|
||||
{f.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Active MDS', getter: (f) => String(f.spec?.metadataServer?.activeCount ?? '—') },
|
||||
{ label: 'Age', getter: (f) => formatAge(f.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={filesystems}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Object stores table */}
|
||||
{objectStores.length > 0 && (
|
||||
<SectionBox title="Object Stores">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (o) => o.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (o) => (
|
||||
<StatusLabel status={phaseToStatus(o.status?.phase)}>
|
||||
{o.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Gateway Port', getter: (o) => String(o.spec?.gateway?.port ?? '—') },
|
||||
{ label: 'Instances', getter: (o) => String(o.spec?.gateway?.instances ?? '—') },
|
||||
{ label: 'Age', getter: (o) => formatAge(o.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={objectStores}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Non-bound PVCs warning */}
|
||||
{nonBoundPvcs.length > 0 && (
|
||||
<SectionBox title="Attention: Non-Bound PVCs">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (pvc) => (
|
||||
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
|
||||
{pvc.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={nonBoundPvcs}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function parseStorageToBytes(storage: string): number {
|
||||
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const suffix = match[2] ?? '';
|
||||
const multipliers: Record<string, number> = {
|
||||
'': 1,
|
||||
K: 1e3, Ki: 1024,
|
||||
M: 1e6, Mi: 1024 ** 2,
|
||||
G: 1e9, Gi: 1024 ** 3,
|
||||
T: 1e12, Ti: 1024 ** 4,
|
||||
P: 1e15, Pi: 1024 ** 5,
|
||||
};
|
||||
return value * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* PVCDetailSection — injected into Headlamp's PVC detail view.
|
||||
*
|
||||
* Shown only when the bound PV uses a Rook-Ceph CSI driver.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { findBoundPv, formatStorageType, storageClassType } from '../api/k8s';
|
||||
|
||||
interface PVCDetailSectionProps {
|
||||
resource: {
|
||||
metadata?: { name?: string; namespace?: string };
|
||||
spec?: { volumeName?: string; storageClassName?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
|
||||
const { persistentVolumes, persistentVolumeClaims, loading } = useRookCephContext();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
const pvcName = resource.metadata?.name;
|
||||
const pvcNamespace = resource.metadata?.namespace;
|
||||
const matchedPvc = persistentVolumeClaims.find(
|
||||
pvc => pvc.metadata.name === pvcName && pvc.metadata.namespace === pvcNamespace
|
||||
);
|
||||
|
||||
if (!matchedPvc) return null;
|
||||
|
||||
const boundPv = findBoundPv(matchedPvc, persistentVolumes);
|
||||
if (!boundPv) return null;
|
||||
|
||||
const attrs = boundPv.spec.csi?.volumeAttributes ?? {};
|
||||
|
||||
// Determine storage type from driver name
|
||||
const driver = boundPv.spec.csi?.driver ?? '';
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
|
||||
return (
|
||||
<SectionBox title="Rook-Ceph Storage Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: driver || '—' },
|
||||
{ name: 'Type', value: formatStorageType(type) },
|
||||
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
|
||||
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
|
||||
{ name: 'PV Name', value: boundPv.metadata.name },
|
||||
...Object.entries(attrs)
|
||||
.filter(([k]) => k !== 'pool')
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PVDetailSection — injected into Headlamp's PV detail view.
|
||||
*
|
||||
* Shown only when the PV uses a Rook-Ceph CSI driver.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatStorageType, isRookCephPersistentVolume } from '../api/k8s';
|
||||
|
||||
interface PVDetailSectionProps {
|
||||
resource: {
|
||||
metadata?: { name?: string };
|
||||
spec?: { csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> }; storageClassName?: string };
|
||||
jsonData?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PVDetailSection({ resource }: PVDetailSectionProps) {
|
||||
// Accept both KubeObject instances (jsonData) and plain objects
|
||||
const raw =
|
||||
resource.jsonData && typeof resource.jsonData === 'object'
|
||||
? (resource.jsonData as typeof resource)
|
||||
: resource;
|
||||
|
||||
const spec = raw.spec;
|
||||
const driver = spec?.csi?.driver ?? '';
|
||||
|
||||
if (!isRookCephPersistentVolume({ metadata: raw.metadata ?? { name: '' }, spec: spec ?? {} })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attrs = spec?.csi?.volumeAttributes ?? {};
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
|
||||
return (
|
||||
<SectionBox title="Rook-Ceph Volume Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: driver || '—' },
|
||||
{ name: 'Type', value: formatStorageType(type) },
|
||||
{ name: 'Volume Handle', value: spec?.csi?.volumeHandle ?? '—' },
|
||||
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||
{ name: 'Storage Class', value: spec?.storageClassName ?? '—' },
|
||||
...Object.entries(attrs)
|
||||
.filter(([k]) => k !== 'pool')
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* PodsPage — lists all Rook-Ceph daemon pods grouped by role.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { formatAge, getPodRestarts, isPodReady, RookCephPod } from '../api/k8s';
|
||||
|
||||
function PodTable({ pods, title }: { pods: RookCephPod[]; title: string }) {
|
||||
if (pods.length === 0) return null;
|
||||
return (
|
||||
<SectionBox title={`${title} (${pods.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (p) => (
|
||||
<StatusLabel status={isPodReady(p) ? 'success' : 'error'}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) },
|
||||
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={pods}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PodsPage() {
|
||||
const {
|
||||
operatorPods,
|
||||
monPods,
|
||||
osdPods,
|
||||
mgrPods,
|
||||
csiRbdPods,
|
||||
csiCephfsPods,
|
||||
loading,
|
||||
error,
|
||||
} = useRookCephContext();
|
||||
|
||||
if (loading) return <Loader title="Loading Rook-Ceph pods..." />;
|
||||
|
||||
const allPods = [...operatorPods, ...monPods, ...osdPods, ...mgrPods, ...csiRbdPods, ...csiCephfsPods];
|
||||
const totalReady = allPods.filter(isPodReady).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Rook-Ceph Pods" />
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
<SectionBox title="Summary">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Overall Health',
|
||||
value: (
|
||||
<StatusLabel status={totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'}>
|
||||
{totalReady}/{allPods.length} pods ready
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<PodTable pods={operatorPods} title="Operator" />
|
||||
<PodTable pods={monPods} title="Monitors (MON)" />
|
||||
<PodTable pods={mgrPods} title="Managers (MGR)" />
|
||||
<PodTable pods={osdPods} title="OSDs" />
|
||||
<PodTable pods={csiRbdPods} title="CSI RBD Provisioner" />
|
||||
<PodTable pods={csiCephfsPods} title="CSI CephFS Provisioner" />
|
||||
|
||||
{allPods.length === 0 && (
|
||||
<SectionBox title="No Pods">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No Rook-Ceph pods found in rook-ceph namespace.' }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* StorageClassesPage — lists Rook-Ceph StorageClasses.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useState } from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { formatAge, formatStorageType, RookCephStorageClass, storageClassType } from '../api/k8s';
|
||||
|
||||
function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass; pvCount: number; onClose: () => void }) {
|
||||
const type = storageClassType(sc);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{sc.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<SectionBox title="StorageClass Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Name', value: sc.metadata.name },
|
||||
{ name: 'Provisioner', value: sc.provisioner },
|
||||
{ name: 'Type', value: formatStorageType(type) },
|
||||
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
|
||||
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
|
||||
{ name: 'Volume Expansion', value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed' },
|
||||
{ name: 'Age', value: formatAge(sc.metadata.creationTimestamp) },
|
||||
{ name: 'Bound PVs', value: String(pvCount) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
{sc.parameters && Object.keys(sc.parameters).length > 0 && (
|
||||
<SectionBox title="Parameters">
|
||||
<NameValueTable
|
||||
rows={Object.entries(sc.parameters).map(([k, v]) => ({ name: k, value: v ?? '—' }))}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StorageClassesPage() {
|
||||
const { storageClasses, persistentVolumes, loading, error } = useRookCephContext();
|
||||
const [selected, setSelected] = useState<RookCephStorageClass | null>(null);
|
||||
|
||||
if (loading) return <Loader title="Loading Rook-Ceph storage classes..." />;
|
||||
|
||||
const pvCountByClass = new Map<string, number>();
|
||||
for (const pv of persistentVolumes) {
|
||||
const sc = pv.spec.storageClassName ?? '';
|
||||
pvCountByClass.set(sc, (pvCountByClass.get(sc) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Rook-Ceph Storage Classes" />
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{storageClasses.length === 0 ? (
|
||||
<SectionBox title="No Storage Classes">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.' }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
<SectionBox title={`Storage Classes (${storageClasses.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'Name',
|
||||
getter: (sc: RookCephStorageClass) => (
|
||||
<button
|
||||
onClick={() => setSelected(sc)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
{sc.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
getter: (sc: RookCephStorageClass) => (
|
||||
<StatusLabel status="success">
|
||||
{formatStorageType(storageClassType(sc))}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Provisioner', getter: (sc: RookCephStorageClass) => sc.provisioner },
|
||||
{ label: 'Pool', getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—' },
|
||||
{ label: 'Reclaim', getter: (sc: RookCephStorageClass) => sc.reclaimPolicy ?? '—' },
|
||||
{ label: 'Expansion', getter: (sc: RookCephStorageClass) => sc.allowVolumeExpansion ? 'Yes' : 'No' },
|
||||
{ label: 'PVs', getter: (sc: RookCephStorageClass) => String(pvCountByClass.get(sc.metadata.name) ?? 0) },
|
||||
{ label: 'Age', getter: (sc: RookCephStorageClass) => formatAge(sc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={storageClasses}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<StorageClassDetail
|
||||
sc={selected}
|
||||
pvCount={pvCountByClass.get(selected.metadata.name) ?? 0}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* VolumesPage — lists Rook-Ceph PersistentVolumes.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useState } from 'react';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import { formatAccessModes, formatAge, phaseToStatus, RookCephPersistentVolume } from '../api/k8s';
|
||||
|
||||
function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () => void }) {
|
||||
const attrs = pv.spec.csi?.volumeAttributes ?? {};
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '520px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{pv.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<SectionBox title="PersistentVolume">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Name', value: pv.metadata.name },
|
||||
{ name: 'Capacity', value: pv.spec.capacity?.storage ?? '—' },
|
||||
{ name: 'Access Modes', value: formatAccessModes(pv.spec.accessModes) },
|
||||
{ name: 'Reclaim Policy', value: pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||
{ name: 'Storage Class', value: pv.spec.storageClassName ?? '—' },
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||
{pv.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Claim',
|
||||
value: pv.spec.claimRef
|
||||
? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}`
|
||||
: '—',
|
||||
},
|
||||
{ name: 'Age', value: formatAge(pv.metadata.creationTimestamp) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
<SectionBox title="CSI Volume Attributes">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: pv.spec.csi?.driver ?? '—' },
|
||||
{ name: 'Volume Handle', value: pv.spec.csi?.volumeHandle ?? '—' },
|
||||
...Object.entries(attrs).map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VolumesPage() {
|
||||
const { persistentVolumes, loading, error } = useRookCephContext();
|
||||
const [selected, setSelected] = useState<RookCephPersistentVolume | null>(null);
|
||||
|
||||
if (loading) return <Loader title="Loading Rook-Ceph volumes..." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Rook-Ceph Persistent Volumes" />
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{persistentVolumes.length === 0 ? (
|
||||
<SectionBox title="No Volumes">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No Rook-Ceph PersistentVolumes found.' }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
<SectionBox title={`Persistent Volumes (${persistentVolumes.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'Name',
|
||||
getter: (pv: RookCephPersistentVolume) => (
|
||||
<button
|
||||
onClick={() => setSelected(pv)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
{pv.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ label: 'Capacity', getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—' },
|
||||
{ label: 'Access Modes', getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes) },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (pv: RookCephPersistentVolume) => (
|
||||
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||
{pv.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Reclaim', getter: (pv: RookCephPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||
{ label: 'Pool', getter: (pv: RookCephPersistentVolume) => pv.spec.csi?.volumeAttributes?.['pool'] ?? '—' },
|
||||
{ label: 'Claim', getter: (pv: RookCephPersistentVolume) => pv.spec.claimRef ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` : '—' },
|
||||
{ label: 'Age', getter: (pv: RookCephPersistentVolume) => formatAge(pv.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={persistentVolumes}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<PVDetail pv={selected} onClose={() => setSelected(null)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* StorageClassColumns — registerResourceTableColumnsProcessor integration.
|
||||
*
|
||||
* Adds Rook-Ceph-specific columns to the native Headlamp StorageClass table
|
||||
* ('headlamp-storageclasses') and PV table ('headlamp-persistentvolumes').
|
||||
* Non-Rook-Ceph rows show '—'.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { isRookCephProvisioner, formatStorageType } from '../../api/k8s';
|
||||
|
||||
/** Safely read a nested field from either a KubeObject instance or plain object. */
|
||||
function getField(item: unknown, ...path: string[]): unknown {
|
||||
if (!item || typeof item !== 'object') return undefined;
|
||||
const obj = item as Record<string, unknown>;
|
||||
// KubeObject instances store raw JSON in .jsonData
|
||||
const raw =
|
||||
'jsonData' in obj && obj['jsonData'] && typeof obj['jsonData'] === 'object'
|
||||
? (obj['jsonData'] as Record<string, unknown>)
|
||||
: obj;
|
||||
let cur: unknown = raw;
|
||||
for (const key of path) {
|
||||
if (!cur || typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function isRookRow(item: unknown): boolean {
|
||||
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||
return typeof provisioner === 'string' && isRookCephProvisioner(provisioner);
|
||||
}
|
||||
|
||||
function isRookPvRow(item: unknown): boolean {
|
||||
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||
return typeof driver === 'string' && isRookCephProvisioner(driver);
|
||||
}
|
||||
|
||||
export function buildStorageClassColumns() {
|
||||
return [
|
||||
{
|
||||
label: 'Rook Type',
|
||||
getValue: (item: unknown) => {
|
||||
if (!isRookRow(item)) return null;
|
||||
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||
if (!provisioner) return null;
|
||||
const type = provisioner.includes('.rbd.') ? 'rbd' : provisioner.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
return formatStorageType(type as 'rbd' | 'cephfs' | 'unknown');
|
||||
},
|
||||
render: (item: unknown) => {
|
||||
if (!isRookRow(item)) return <span>—</span>;
|
||||
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||
if (!provisioner) return <span>—</span>;
|
||||
const type = provisioner.includes('.rbd.') ? 'rbd' : provisioner.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
return <span style={{ color: '#1976d2', fontWeight: 500 }}>{formatStorageType(type as 'rbd' | 'cephfs' | 'unknown')}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (item: unknown) => getField(item, 'parameters', 'pool') as string | null ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookRow(item)) return <span>—</span>;
|
||||
const pool = getField(item, 'parameters', 'pool') as string | undefined;
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cluster ID',
|
||||
getValue: (item: unknown) => getField(item, 'parameters', 'clusterID') as string | null ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookRow(item)) return <span>—</span>;
|
||||
const clusterID = getField(item, 'parameters', 'clusterID') as string | undefined;
|
||||
if (!clusterID) return <span>—</span>;
|
||||
// Truncate long cluster IDs
|
||||
return <span title={clusterID}>{clusterID.length > 16 ? `${clusterID.slice(0, 16)}…` : clusterID}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildPVColumns() {
|
||||
return [
|
||||
{
|
||||
label: 'Rook Type',
|
||||
getValue: (item: unknown) => {
|
||||
if (!isRookPvRow(item)) return null;
|
||||
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (!driver) return null;
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
return formatStorageType(type as 'rbd' | 'cephfs' | 'unknown');
|
||||
},
|
||||
render: (item: unknown) => {
|
||||
if (!isRookPvRow(item)) return <span>—</span>;
|
||||
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (!driver) return <span>—</span>;
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
return <span style={{ color: '#1976d2', fontWeight: 500 }}>{formatStorageType(type as 'rbd' | 'cephfs' | 'unknown')}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (item: unknown) => getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookPvRow(item)) return <span>—</span>;
|
||||
const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | undefined;
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user