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:
2026-02-18 16:55:39 -05:00
commit 25175b65b8
30 changed files with 21588 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
/**
* RookCephDataContext — shared data provider for Rook-Ceph Kubernetes resources.
*
* Fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs
* plus StorageClasses, PVs, PVCs, and Rook-Ceph pods via Headlamp hooks and
* ApiProxy. Provides filtered data to all child pages via React context.
*/
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
CephBlockPool,
CephCluster,
CephFilesystem,
CephObjectStore,
filterRookCephPersistentVolumes,
filterRookCephPVCs,
filterRookCephStorageClasses,
isKubeList,
ROOK_CEPH_NAMESPACE,
RookCephPersistentVolume,
RookCephPVC,
RookCephPod,
RookCephStorageClass,
ROOK_CSI_CEPHFS_SELECTOR,
ROOK_CSI_RBD_SELECTOR,
ROOK_MGR_SELECTOR,
ROOK_MON_SELECTOR,
ROOK_OSD_SELECTOR,
ROOK_OPERATOR_SELECTOR,
} from './k8s';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface RookCephContextValue {
// Cluster presence
cephClusters: CephCluster[];
clusterInstalled: boolean;
// Core CRD resources
blockPools: CephBlockPool[];
filesystems: CephFilesystem[];
objectStores: CephObjectStore[];
// Core K8s resources (filtered to Rook-Ceph only)
storageClasses: RookCephStorageClass[];
persistentVolumes: RookCephPersistentVolume[];
persistentVolumeClaims: RookCephPVC[];
// Operator / daemon pods
operatorPods: RookCephPod[];
monPods: RookCephPod[];
osdPods: RookCephPod[];
mgrPods: RookCephPod[];
csiRbdPods: RookCephPod[];
csiCephfsPods: RookCephPod[];
// Loading / error state
loading: boolean;
error: string | null;
// Manual refresh trigger
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const RookCephContext = createContext<RookCephContextValue | null>(null);
export function useRookCephContext(): RookCephContextValue {
const ctx = useContext(RookCephContext);
if (!ctx) {
throw new Error('useRookCephContext must be used within a RookCephDataProvider');
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function RookCephDataProvider({ children }: { children: React.ReactNode }) {
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
const [allStorageClasses, scError] = K8s.ResourceClasses.StorageClass.useList();
const [allPvs, pvError] = K8s.ResourceClasses.PersistentVolume.useList();
const [allPvcs, pvcError] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
// Async-fetched resources (CRDs, pods)
const [cephClusters, setCephClusters] = useState<CephCluster[]>([]);
const [blockPools, setBlockPools] = useState<CephBlockPool[]>([]);
const [filesystems, setFilesystems] = useState<CephFilesystem[]>([]);
const [objectStores, setObjectStores] = useState<CephObjectStore[]>([]);
const [operatorPods, setOperatorPods] = useState<RookCephPod[]>([]);
const [monPods, setMonPods] = useState<RookCephPod[]>([]);
const [osdPods, setOsdPods] = useState<RookCephPod[]>([]);
const [mgrPods, setMgrPods] = useState<RookCephPod[]>([]);
const [csiRbdPods, setCsiRbdPods] = useState<RookCephPod[]>([]);
const [csiCephfsPods, setCsiCephfsPods] = useState<RookCephPod[]>([]);
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function fetchAsync() {
setAsyncLoading(true);
setAsyncError(null);
try {
// CephCluster CRDs
try {
const clusterList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephclusters`
);
if (!cancelled && isKubeList(clusterList)) {
setCephClusters(clusterList.items as CephCluster[]);
}
} catch {
if (!cancelled) setCephClusters([]);
}
// CephBlockPool CRDs
try {
const poolList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephblockpools`
);
if (!cancelled && isKubeList(poolList)) {
setBlockPools(poolList.items as CephBlockPool[]);
}
} catch {
if (!cancelled) setBlockPools([]);
}
// CephFilesystem CRDs
try {
const fsList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephfilesystems`
);
if (!cancelled && isKubeList(fsList)) {
setFilesystems(fsList.items as CephFilesystem[]);
}
} catch {
if (!cancelled) setFilesystems([]);
}
// CephObjectStore CRDs
try {
const osList = await ApiProxy.request(
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephobjectstores`
);
if (!cancelled && isKubeList(osList)) {
setObjectStores(osList.items as CephObjectStore[]);
}
} catch {
if (!cancelled) setObjectStores([]);
}
// Operator pods
try {
const opList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OPERATOR_SELECTOR)}`
);
if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]);
} catch {
if (!cancelled) setOperatorPods([]);
}
// MON pods
try {
const monList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MON_SELECTOR)}`
);
if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]);
} catch {
if (!cancelled) setMonPods([]);
}
// OSD pods
try {
const osdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OSD_SELECTOR)}`
);
if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]);
} catch {
if (!cancelled) setOsdPods([]);
}
// MGR pods
try {
const mgrList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MGR_SELECTOR)}`
);
if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]);
} catch {
if (!cancelled) setMgrPods([]);
}
// CSI RBD provisioner pods
try {
const csiRbdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_RBD_SELECTOR)}`
);
if (!cancelled && isKubeList(csiRbdList)) setCsiRbdPods(csiRbdList.items as RookCephPod[]);
} catch {
if (!cancelled) setCsiRbdPods([]);
}
// CSI CephFS provisioner pods
try {
const csiCephfsList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_CEPHFS_SELECTOR)}`
);
if (!cancelled && isKubeList(csiCephfsList)) setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
} catch {
if (!cancelled) setCsiCephfsPods([]);
}
} catch (err: unknown) {
if (!cancelled) {
setAsyncError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) setAsyncLoading(false);
}
}
void fetchAsync();
return () => { cancelled = true; };
}, [refreshKey]);
// ---------------------------------------------------------------------------
// Derived / filtered values — memoized to avoid recomputation on every render
// ---------------------------------------------------------------------------
// Headlamp useList() returns KubeObject class instances that store raw
// Kubernetes JSON under `.jsonData`. Extract it so our plain-object helpers
// work correctly.
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const storageClasses = useMemo(() => {
if (!allStorageClasses) return [];
return filterRookCephStorageClasses(extractJsonData(allStorageClasses as unknown[]));
}, [allStorageClasses]);
const persistentVolumes = useMemo(() => {
if (!allPvs) return [];
return filterRookCephPersistentVolumes(extractJsonData(allPvs as unknown[]));
}, [allPvs]);
const persistentVolumeClaims = useMemo(() => {
if (!allPvcs || persistentVolumes.length === 0) return [];
return filterRookCephPVCs(
extractJsonData(allPvcs as unknown[]) as RookCephPVC[],
persistentVolumes
);
}, [allPvcs, persistentVolumes]);
// ---------------------------------------------------------------------------
// Combined loading / error state
// ---------------------------------------------------------------------------
const loading = asyncLoading || !allStorageClasses || !allPvs || !allPvcs;
const errors: string[] = [];
if (scError) errors.push(String(scError));
if (pvError) errors.push(String(pvError));
if (pvcError) errors.push(String(pvcError));
if (asyncError) errors.push(asyncError);
const error = errors.length > 0 ? errors.join('; ') : null;
const clusterInstalled = cephClusters.length > 0;
// ---------------------------------------------------------------------------
// Memoized context value
// ---------------------------------------------------------------------------
const value = useMemo<RookCephContextValue>(
() => ({
cephClusters,
clusterInstalled,
blockPools,
filesystems,
objectStores,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
operatorPods,
monPods,
osdPods,
mgrPods,
csiRbdPods,
csiCephfsPods,
loading,
error,
refresh,
}),
[
cephClusters,
clusterInstalled,
blockPools,
filesystems,
objectStores,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
operatorPods,
monPods,
osdPods,
mgrPods,
csiRbdPods,
csiCephfsPods,
loading,
error,
refresh,
]
);
return <RookCephContext.Provider value={value}>{children}</RookCephContext.Provider>;
}