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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user