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,59 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock headlamp plugin APIs before importing the module under test
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: {
|
||||
request: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
StorageClass: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolume: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolumeClaim: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { RookCephDataProvider, useRookCephContext } from './RookCephDataContext';
|
||||
|
||||
describe('useRookCephContext', () => {
|
||||
it('throws when used outside RookCephDataProvider', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useRookCephContext());
|
||||
}).toThrow('useRookCephContext must be used within a RookCephDataProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns context value when inside RookCephDataProvider', async () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RookCephDataProvider>{children}</RookCephDataProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRookCephContext(), { wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.storageClasses).toBeInstanceOf(Array);
|
||||
expect(result.current.persistentVolumes).toBeInstanceOf(Array);
|
||||
expect(result.current.persistentVolumeClaims).toBeInstanceOf(Array);
|
||||
expect(result.current.cephClusters).toBeInstanceOf(Array);
|
||||
expect(result.current.blockPools).toBeInstanceOf(Array);
|
||||
expect(result.current.filesystems).toBeInstanceOf(Array);
|
||||
expect(result.current.objectStores).toBeInstanceOf(Array);
|
||||
expect(result.current.operatorPods).toBeInstanceOf(Array);
|
||||
expect(result.current.monPods).toBeInstanceOf(Array);
|
||||
expect(result.current.osdPods).toBeInstanceOf(Array);
|
||||
expect(result.current.mgrPods).toBeInstanceOf(Array);
|
||||
expect(typeof result.current.refresh).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterRookCephPersistentVolumes,
|
||||
filterRookCephStorageClasses,
|
||||
formatAge,
|
||||
formatAccessModes,
|
||||
formatBytes,
|
||||
formatStorageType,
|
||||
healthToStatus,
|
||||
isKubeList,
|
||||
isPodReady,
|
||||
isRookCephPersistentVolume,
|
||||
isRookCephProvisioner,
|
||||
isRookCephStorageClass,
|
||||
parseStorageToBytes,
|
||||
phaseToStatus,
|
||||
ROOK_CEPH_CEPHFS_PROVISIONER,
|
||||
ROOK_CEPH_RBD_PROVISIONER,
|
||||
storageClassType,
|
||||
filterRookCephPVCs,
|
||||
findBoundPv,
|
||||
getPodRestarts,
|
||||
} from './k8s';
|
||||
|
||||
describe('isRookCephProvisioner', () => {
|
||||
it('recognises default namespace RBD provisioner', () => {
|
||||
expect(isRookCephProvisioner(ROOK_CEPH_RBD_PROVISIONER)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises default namespace CephFS provisioner', () => {
|
||||
expect(isRookCephProvisioner(ROOK_CEPH_CEPHFS_PROVISIONER)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises custom namespace provisioners', () => {
|
||||
expect(isRookCephProvisioner('my-namespace.rbd.csi.ceph.com')).toBe(true);
|
||||
expect(isRookCephProvisioner('my-namespace.cephfs.csi.ceph.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-rook provisioners', () => {
|
||||
expect(isRookCephProvisioner('tns.csi.io')).toBe(false);
|
||||
expect(isRookCephProvisioner('ebs.csi.aws.com')).toBe(false);
|
||||
expect(isRookCephProvisioner('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRookCephStorageClass', () => {
|
||||
it('accepts a Rook-Ceph SC', () => {
|
||||
const sc = { metadata: { name: 'rook-ceph-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
|
||||
expect(isRookCephStorageClass(sc)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-Rook SC', () => {
|
||||
const sc = { metadata: { name: 'other' }, provisioner: 'tns.csi.io' };
|
||||
expect(isRookCephStorageClass(sc)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null / non-object', () => {
|
||||
expect(isRookCephStorageClass(null)).toBe(false);
|
||||
expect(isRookCephStorageClass('string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRookCephStorageClasses', () => {
|
||||
it('filters to Rook-Ceph only', () => {
|
||||
const items = [
|
||||
{ metadata: { name: 'rook-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER },
|
||||
{ metadata: { name: 'other' }, provisioner: 'tns.csi.io' },
|
||||
{ metadata: { name: 'rook-cephfs' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER },
|
||||
];
|
||||
const result = filterRookCephStorageClasses(items);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(s => s.metadata.name)).toEqual(['rook-block', 'rook-cephfs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageClassType', () => {
|
||||
it('returns rbd for RBD provisioner', () => {
|
||||
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
|
||||
expect(storageClassType(sc)).toBe('rbd');
|
||||
});
|
||||
|
||||
it('returns cephfs for CephFS provisioner', () => {
|
||||
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER };
|
||||
expect(storageClassType(sc)).toBe('cephfs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRookCephPersistentVolume', () => {
|
||||
it('accepts a Rook-Ceph PV', () => {
|
||||
const pv = {
|
||||
metadata: { name: 'pvc-123' },
|
||||
spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } },
|
||||
};
|
||||
expect(isRookCephPersistentVolume(pv)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-Rook PV', () => {
|
||||
const pv = { metadata: { name: 'other' }, spec: { csi: { driver: 'tns.csi.io' } } };
|
||||
expect(isRookCephPersistentVolume(pv)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects PVs with no spec.csi', () => {
|
||||
expect(isRookCephPersistentVolume({ metadata: { name: 'x' }, spec: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRookCephPersistentVolumes', () => {
|
||||
it('returns only Rook-Ceph PVs', () => {
|
||||
const items = [
|
||||
{ metadata: { name: 'pv-a' }, spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } } },
|
||||
{ metadata: { name: 'pv-b' }, spec: { csi: { driver: 'tns.csi.io' } } },
|
||||
];
|
||||
expect(filterRookCephPersistentVolumes(items)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRookCephPVCs', () => {
|
||||
it('returns PVCs bound to Rook-Ceph PVs', () => {
|
||||
const pvs = [
|
||||
{
|
||||
metadata: { name: 'pv-1' },
|
||||
spec: {
|
||||
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
|
||||
claimRef: { name: 'my-pvc', namespace: 'default' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const pvcs = [
|
||||
{ metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} },
|
||||
{ metadata: { name: 'other-pvc', namespace: 'default' }, spec: {} },
|
||||
];
|
||||
const result = filterRookCephPVCs(pvcs as never, pvs as never);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].metadata.name).toBe('my-pvc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBoundPv', () => {
|
||||
it('finds the matching PV', () => {
|
||||
const pv = {
|
||||
metadata: { name: 'pv-1' },
|
||||
spec: {
|
||||
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
|
||||
claimRef: { name: 'my-pvc', namespace: 'default' },
|
||||
},
|
||||
};
|
||||
const pvc = { metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} };
|
||||
const result = findBoundPv(pvc as never, [pv] as never);
|
||||
expect(result?.metadata.name).toBe('pv-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthToStatus', () => {
|
||||
it('maps health strings correctly', () => {
|
||||
expect(healthToStatus('HEALTH_OK')).toBe('success');
|
||||
expect(healthToStatus('HEALTH_WARN')).toBe('warning');
|
||||
expect(healthToStatus('HEALTH_ERR')).toBe('error');
|
||||
expect(healthToStatus(undefined)).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phaseToStatus', () => {
|
||||
it('maps phase strings correctly', () => {
|
||||
expect(phaseToStatus('Ready')).toBe('success');
|
||||
expect(phaseToStatus('Bound')).toBe('success');
|
||||
expect(phaseToStatus('Progressing')).toBe('warning');
|
||||
expect(phaseToStatus('Pending')).toBe('warning');
|
||||
expect(phaseToStatus('Failed')).toBe('error');
|
||||
expect(phaseToStatus(undefined)).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPodReady', () => {
|
||||
it('returns true when Ready condition is True', () => {
|
||||
const pod = {
|
||||
metadata: { name: 'p' },
|
||||
status: { conditions: [{ type: 'Ready', status: 'True' }] },
|
||||
};
|
||||
expect(isPodReady(pod as never)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when Ready condition is False', () => {
|
||||
const pod = {
|
||||
metadata: { name: 'p' },
|
||||
status: { conditions: [{ type: 'Ready', status: 'False' }] },
|
||||
};
|
||||
expect(isPodReady(pod as never)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPodRestarts', () => {
|
||||
it('sums restart counts across containers', () => {
|
||||
const pod = {
|
||||
metadata: { name: 'p' },
|
||||
status: {
|
||||
containerStatuses: [
|
||||
{ name: 'c1', ready: true, restartCount: 2 },
|
||||
{ name: 'c2', ready: true, restartCount: 3 },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(getPodRestarts(pod as never)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAge', () => {
|
||||
it('returns unknown for undefined', () => {
|
||||
expect(formatAge(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('formats seconds', () => {
|
||||
const ts = new Date(Date.now() - 30_000).toISOString();
|
||||
expect(formatAge(ts)).toBe('30s');
|
||||
});
|
||||
|
||||
it('formats minutes', () => {
|
||||
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
expect(formatAge(ts)).toBe('5m');
|
||||
});
|
||||
|
||||
it('formats hours', () => {
|
||||
const ts = new Date(Date.now() - 3 * 3600_000).toISOString();
|
||||
expect(formatAge(ts)).toBe('3h');
|
||||
});
|
||||
|
||||
it('formats days', () => {
|
||||
const ts = new Date(Date.now() - 2 * 86400_000).toISOString();
|
||||
expect(formatAge(ts)).toBe('2d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAccessModes', () => {
|
||||
it('abbreviates access modes', () => {
|
||||
expect(formatAccessModes(['ReadWriteOnce'])).toBe('RWO');
|
||||
expect(formatAccessModes(['ReadWriteMany', 'ReadOnlyMany'])).toBe('RWX, ROX');
|
||||
});
|
||||
|
||||
it('returns — for empty', () => {
|
||||
expect(formatAccessModes([])).toBe('—');
|
||||
expect(formatAccessModes(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats various byte sizes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
expect(formatBytes(1024)).toBe('1.0 KiB');
|
||||
expect(formatBytes(1024 ** 2)).toBe('1.0 MiB');
|
||||
expect(formatBytes(1024 ** 3)).toBe('1.0 GiB');
|
||||
expect(formatBytes(1024 ** 4)).toBe('1.0 TiB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStorageToBytes', () => {
|
||||
it('parses Gi suffix', () => {
|
||||
expect(parseStorageToBytes('10Gi')).toBe(10 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it('parses Mi suffix', () => {
|
||||
expect(parseStorageToBytes('512Mi')).toBe(512 * 1024 ** 2);
|
||||
});
|
||||
|
||||
it('returns 0 for invalid', () => {
|
||||
expect(parseStorageToBytes('invalid')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatStorageType', () => {
|
||||
it('formats storage types', () => {
|
||||
expect(formatStorageType('rbd')).toBe('Block (RBD)');
|
||||
expect(formatStorageType('cephfs')).toBe('Filesystem (CephFS)');
|
||||
expect(formatStorageType('unknown')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKubeList', () => {
|
||||
it('accepts objects with items array', () => {
|
||||
expect(isKubeList({ items: [] })).toBe(true);
|
||||
expect(isKubeList({ items: [1, 2] })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-list shapes', () => {
|
||||
expect(isKubeList(null)).toBe(false);
|
||||
expect(isKubeList({})).toBe(false);
|
||||
expect(isKubeList({ items: 'not-array' })).toBe(false);
|
||||
});
|
||||
});
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Kubernetes type definitions and helper functions for Rook-Ceph resources.
|
||||
*
|
||||
* All K8s resource types are typed at the fields we actually use.
|
||||
* External data from the API is validated at the boundary before use.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provisioner constants (namespace-prefixed — default namespace: rook-ceph)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ROOK_CEPH_NAMESPACE = 'rook-ceph' as const;
|
||||
export const ROOK_CEPH_API_GROUP = 'ceph.rook.io' as const;
|
||||
export const ROOK_CEPH_API_VERSION = 'v1' as const;
|
||||
|
||||
/** RBD (block) provisioner — prefix matches operator namespace */
|
||||
export const ROOK_CEPH_RBD_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.rbd.csi.ceph.com` as const;
|
||||
/** CephFS provisioner — prefix matches operator namespace */
|
||||
export const ROOK_CEPH_CEPHFS_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.cephfs.csi.ceph.com` as const;
|
||||
|
||||
/** Returns true if the provisioner string is a known Rook-Ceph provisioner. */
|
||||
export function isRookCephProvisioner(provisioner: string): boolean {
|
||||
return (
|
||||
provisioner === ROOK_CEPH_RBD_PROVISIONER ||
|
||||
provisioner === ROOK_CEPH_CEPHFS_PROVISIONER ||
|
||||
// Handle non-default namespaces: ends with .rbd.csi.ceph.com or .cephfs.csi.ceph.com
|
||||
provisioner.endsWith('.rbd.csi.ceph.com') ||
|
||||
provisioner.endsWith('.cephfs.csi.ceph.com')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pod label selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ROOK_OPERATOR_SELECTOR = 'app=rook-ceph-operator';
|
||||
export const ROOK_MON_SELECTOR = 'app=rook-ceph-mon';
|
||||
export const ROOK_OSD_SELECTOR = 'app=rook-ceph-osd';
|
||||
export const ROOK_MGR_SELECTOR = 'app=rook-ceph-mgr';
|
||||
export const ROOK_MDS_SELECTOR = 'app=rook-ceph-mds';
|
||||
export const ROOK_RGW_SELECTOR = 'app=rook-ceph-rgw';
|
||||
export const ROOK_CSI_RBD_SELECTOR = 'app=csi-rbdplugin-provisioner';
|
||||
export const ROOK_CSI_CEPHFS_SELECTOR = 'app=csi-cephfsplugin-provisioner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic Kubernetes object base shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeObjectMeta {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface KubeObject {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
metadata: KubeObjectMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CephCluster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CephClusterStatusCeph {
|
||||
health?: 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR' | string;
|
||||
lastChecked?: string;
|
||||
capacity?: {
|
||||
bytesAvailable?: number;
|
||||
bytesTotal?: number;
|
||||
bytesUsed?: number;
|
||||
lastUpdated?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CephClusterStatusStorage {
|
||||
deviceClasses?: Array<{ name: string }>;
|
||||
osd?: { storeType?: Record<string, number> };
|
||||
}
|
||||
|
||||
export interface CephClusterStatusVersion {
|
||||
image?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface CephClusterCondition {
|
||||
type: string;
|
||||
status: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
lastTransitionTime?: string;
|
||||
lastHeartbeatTime?: string;
|
||||
}
|
||||
|
||||
export interface CephClusterStatus {
|
||||
phase?: 'Ready' | 'Progressing' | 'Failed' | string;
|
||||
state?: 'Created' | 'Updating' | 'Deleting' | string;
|
||||
message?: string;
|
||||
ceph?: CephClusterStatusCeph;
|
||||
storage?: CephClusterStatusStorage;
|
||||
version?: CephClusterStatusVersion;
|
||||
conditions?: CephClusterCondition[];
|
||||
}
|
||||
|
||||
export interface CephClusterSpec {
|
||||
cephVersion?: { image?: string; allowUnsupported?: boolean };
|
||||
dataDirHostPath?: string;
|
||||
mon?: { count?: number; allowMultiplePerNode?: boolean };
|
||||
mgr?: { count?: number };
|
||||
dashboard?: { enabled?: boolean; ssl?: boolean };
|
||||
monitoring?: { enabled?: boolean };
|
||||
storage?: {
|
||||
useAllNodes?: boolean;
|
||||
useAllDevices?: boolean;
|
||||
deviceFilter?: string;
|
||||
nodes?: unknown[];
|
||||
};
|
||||
network?: { hostNetwork?: boolean };
|
||||
resources?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CephCluster extends KubeObject {
|
||||
spec?: CephClusterSpec;
|
||||
status?: CephClusterStatus;
|
||||
}
|
||||
|
||||
export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' {
|
||||
switch (health) {
|
||||
case 'HEALTH_OK': return 'success';
|
||||
case 'HEALTH_WARN': return 'warning';
|
||||
default: return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
|
||||
switch (phase) {
|
||||
case 'Ready':
|
||||
case 'Bound':
|
||||
case 'Available':
|
||||
case 'Running':
|
||||
case 'Succeeded':
|
||||
return 'success';
|
||||
case 'Progressing':
|
||||
case 'Pending':
|
||||
case 'Released':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CephBlockPool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CephBlockPoolSpec {
|
||||
failureDomain?: string;
|
||||
replicated?: { size?: number; requireSafeReplicaSize?: boolean };
|
||||
erasureCoded?: { codingChunks?: number; dataChunks?: number };
|
||||
parameters?: Record<string, string>;
|
||||
mirroring?: { enabled?: boolean };
|
||||
}
|
||||
|
||||
export interface CephBlockPoolStatus {
|
||||
phase?: string;
|
||||
info?: Record<string, string>;
|
||||
conditions?: CephClusterCondition[];
|
||||
}
|
||||
|
||||
export interface CephBlockPool extends KubeObject {
|
||||
spec?: CephBlockPoolSpec;
|
||||
status?: CephBlockPoolStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CephFilesystem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CephFilesystemSpec {
|
||||
metadataPool?: { replicated?: { size?: number } };
|
||||
dataPools?: Array<{ name?: string; replicated?: { size?: number } }>;
|
||||
metadataServer?: { activeCount?: number; activeStandby?: boolean };
|
||||
}
|
||||
|
||||
export interface CephFilesystemStatus {
|
||||
phase?: string;
|
||||
conditions?: CephClusterCondition[];
|
||||
info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CephFilesystem extends KubeObject {
|
||||
spec?: CephFilesystemSpec;
|
||||
status?: CephFilesystemStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CephObjectStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CephObjectStoreSpec {
|
||||
metadataPool?: { replicated?: { size?: number } };
|
||||
dataPool?: { replicated?: { size?: number } };
|
||||
gateway?: { port?: number; securePort?: number; instances?: number };
|
||||
}
|
||||
|
||||
export interface CephObjectStoreStatus {
|
||||
phase?: string;
|
||||
conditions?: CephClusterCondition[];
|
||||
info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CephObjectStore extends KubeObject {
|
||||
spec?: CephObjectStoreSpec;
|
||||
status?: CephObjectStoreStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StorageClass (Rook-Ceph provisioned)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RookCephStorageClass extends KubeObject {
|
||||
provisioner: string;
|
||||
reclaimPolicy?: string;
|
||||
volumeBindingMode?: string;
|
||||
allowVolumeExpansion?: boolean;
|
||||
parameters?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function isRookCephStorageClass(sc: unknown): sc is RookCephStorageClass {
|
||||
if (!sc || typeof sc !== 'object') return false;
|
||||
const obj = sc as Record<string, unknown>;
|
||||
const provisioner = obj['provisioner'];
|
||||
return typeof provisioner === 'string' && isRookCephProvisioner(provisioner);
|
||||
}
|
||||
|
||||
export function filterRookCephStorageClasses(items: unknown[]): RookCephStorageClass[] {
|
||||
return items.filter(isRookCephStorageClass);
|
||||
}
|
||||
|
||||
/** Returns 'rbd' or 'cephfs' based on provisioner string, or 'unknown'. */
|
||||
export function storageClassType(sc: RookCephStorageClass): 'rbd' | 'cephfs' | 'unknown' {
|
||||
if (sc.provisioner.includes('.rbd.')) return 'rbd';
|
||||
if (sc.provisioner.includes('.cephfs.')) return 'cephfs';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PersistentVolume (Rook-Ceph provisioned)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RookCsiSpec {
|
||||
driver: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ClaimRef {
|
||||
name: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export interface PersistentVolumeSpec {
|
||||
csi?: RookCsiSpec;
|
||||
capacity?: { storage?: string };
|
||||
accessModes?: string[];
|
||||
persistentVolumeReclaimPolicy?: string;
|
||||
storageClassName?: string;
|
||||
claimRef?: ClaimRef;
|
||||
}
|
||||
|
||||
export interface RookCephPersistentVolume extends KubeObject {
|
||||
spec: PersistentVolumeSpec;
|
||||
status?: { phase?: string };
|
||||
}
|
||||
|
||||
export function isRookCephPersistentVolume(pv: unknown): pv is RookCephPersistentVolume {
|
||||
if (!pv || typeof pv !== 'object') return false;
|
||||
const obj = pv as Record<string, unknown>;
|
||||
const spec = obj['spec'] as Record<string, unknown> | undefined;
|
||||
if (!spec) return false;
|
||||
const csi = spec['csi'] as Record<string, unknown> | undefined;
|
||||
const driver = csi?.['driver'];
|
||||
return typeof driver === 'string' && isRookCephProvisioner(driver);
|
||||
}
|
||||
|
||||
export function filterRookCephPersistentVolumes(items: unknown[]): RookCephPersistentVolume[] {
|
||||
return items.filter(isRookCephPersistentVolume);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PersistentVolumeClaim (Rook-Ceph)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PVCSpec {
|
||||
storageClassName?: string;
|
||||
accessModes?: string[];
|
||||
resources?: { requests?: { storage?: string } };
|
||||
volumeName?: string;
|
||||
}
|
||||
|
||||
export interface RookCephPVC extends KubeObject {
|
||||
spec: PVCSpec;
|
||||
status?: {
|
||||
phase?: string;
|
||||
capacity?: { storage?: string };
|
||||
accessModes?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function filterRookCephPVCs(
|
||||
pvcs: RookCephPVC[],
|
||||
rookPvs: RookCephPersistentVolume[]
|
||||
): RookCephPVC[] {
|
||||
const boundSet = new Set<string>();
|
||||
for (const pv of rookPvs) {
|
||||
const ref = pv.spec.claimRef;
|
||||
if (ref) boundSet.add(`${ref.namespace}/${ref.name}`);
|
||||
}
|
||||
return pvcs.filter(pvc => {
|
||||
const ns = pvc.metadata.namespace ?? '';
|
||||
return boundSet.has(`${ns}/${pvc.metadata.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
export function findBoundPv(
|
||||
pvc: RookCephPVC,
|
||||
rookPvs: RookCephPersistentVolume[]
|
||||
): RookCephPersistentVolume | undefined {
|
||||
const ns = pvc.metadata.namespace ?? '';
|
||||
const name = pvc.metadata.name;
|
||||
return rookPvs.find(
|
||||
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ContainerStatus {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string; message?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PodStatus {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: ContainerStatus[];
|
||||
}
|
||||
|
||||
export interface PodSpec {
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface RookCephPod extends KubeObject {
|
||||
spec?: PodSpec;
|
||||
status?: PodStatus;
|
||||
}
|
||||
|
||||
export function isPodReady(pod: RookCephPod): boolean {
|
||||
return (
|
||||
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function getPodRestarts(pod: RookCephPod): number {
|
||||
return (
|
||||
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getPodImage(pod: RookCephPod): string {
|
||||
return pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// K8s API list response envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeList<T> {
|
||||
items: T[];
|
||||
metadata?: { resourceVersion?: string };
|
||||
}
|
||||
|
||||
export function isKubeList(value: unknown): value is KubeList<unknown> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return Array.isArray((value as Record<string, unknown>)['items']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatAge(timestamp: string | undefined): string {
|
||||
if (!timestamp) return 'unknown';
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||
const secs = Math.floor(diffMs / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
const ACCESS_MODE_ABBREV: Record<string, string> = {
|
||||
ReadWriteOnce: 'RWO',
|
||||
ReadWriteMany: 'RWX',
|
||||
ReadOnlyMany: 'ROX',
|
||||
ReadWriteOncePod: 'RWOP',
|
||||
};
|
||||
|
||||
export function formatAccessModes(modes: string[] | undefined): string {
|
||||
if (!modes || modes.length === 0) return '—';
|
||||
return modes.map(m => ACCESS_MODE_ABBREV[m] ?? m).join(', ');
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
/** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */
|
||||
export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string {
|
||||
switch (type) {
|
||||
case 'rbd': return 'Block (RBD)';
|
||||
case 'cephfs': return 'Filesystem (CephFS)';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts pool/subvolume group name from a Rook-Ceph PV volumeHandle. */
|
||||
export function extractPoolFromVolumeHandle(handle: string | undefined): string {
|
||||
if (!handle) return '—';
|
||||
// RBD format: "<csi-vol-id>-<pool>-..." — pool is in volumeAttributes
|
||||
// We rely on volumeAttributes.pool instead; this just provides a fallback.
|
||||
return handle;
|
||||
}
|
||||
Reference in New Issue
Block a user