c1c5e8a37d
- Fix PVC bind loop leak on unmount via cancellation ref - Fix DeleteOptions body structure for proper foreground propagation - Filter snapshots to tns-csi driver only (was showing all drivers) - Fix stale closures in Escape key handlers with useCallback - Add loading state to cleanup delete button, remove window.confirm/alert - Use CSS custom properties for protocol chart colors (dark mode support) - Fix all 35 ESLint warnings (import sort, indent, boolean attrs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
/**
|
|
* TnsCsiDataContext — shared data provider for tns-csi Kubernetes resources.
|
|
*
|
|
* Wraps the K8s hook calls and provides filtered tns-csi resources to all
|
|
* child pages through React context, avoiding prop drilling and duplicate
|
|
* API calls.
|
|
*/
|
|
|
|
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
CSIDriver,
|
|
filterTnsCsiPersistentVolumes,
|
|
filterTnsCsiPVCs,
|
|
filterTnsCsiStorageClasses,
|
|
filterTnsCsiVolumeSnapshots,
|
|
isKubeList,
|
|
isTnsCsiVolumeSnapshotClass,
|
|
TNS_CSI_PROVISIONER,
|
|
TnsCsiPersistentVolume,
|
|
TnsCsiPersistentVolumeClaim,
|
|
TnsCsiPod,
|
|
TnsCsiStorageClass,
|
|
VolumeSnapshot,
|
|
VolumeSnapshotClass,
|
|
} from './k8s';
|
|
import { fetchTruenasPoolStats, getTnsCsiConfig, PoolStats } from './truenas';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TnsCsiContextValue {
|
|
// Driver presence
|
|
csiDriver: CSIDriver | null;
|
|
driverInstalled: boolean;
|
|
|
|
// Core resources (filtered to tns-csi only)
|
|
storageClasses: TnsCsiStorageClass[];
|
|
persistentVolumes: TnsCsiPersistentVolume[];
|
|
persistentVolumeClaims: TnsCsiPersistentVolumeClaim[];
|
|
|
|
// Driver pods
|
|
controllerPods: TnsCsiPod[];
|
|
nodePods: TnsCsiPod[];
|
|
|
|
// Snapshots (CRD — may be unavailable)
|
|
volumeSnapshots: VolumeSnapshot[];
|
|
volumeSnapshotClasses: VolumeSnapshotClass[];
|
|
snapshotCrdAvailable: boolean;
|
|
|
|
// TrueNAS pool capacity (only populated when API key is configured)
|
|
poolStats: PoolStats[];
|
|
poolStatsError: string | null;
|
|
|
|
// Loading / error state
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Manual refresh trigger
|
|
refresh: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TnsCsiContext = createContext<TnsCsiContextValue | null>(null);
|
|
|
|
export function useTnsCsiContext(): TnsCsiContextValue {
|
|
const ctx = useContext(TnsCsiContext);
|
|
if (!ctx) {
|
|
throw new Error('useTnsCsiContext must be used within a TnsCsiDataProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function TnsCsiDataProvider({ 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: '' });
|
|
|
|
// Pods fetched via label selector through ApiProxy (useList doesn't support selectors easily)
|
|
const [controllerPods, setControllerPods] = useState<TnsCsiPod[]>([]);
|
|
const [nodePods, setNodePods] = useState<TnsCsiPod[]>([]);
|
|
const [csiDriver, setCsiDriver] = useState<CSIDriver | null>(null);
|
|
const [volumeSnapshots, setVolumeSnapshots] = useState<VolumeSnapshot[]>([]);
|
|
const [volumeSnapshotClasses, setVolumeSnapshotClasses] = useState<VolumeSnapshotClass[]>([]);
|
|
const [snapshotCrdAvailable, setSnapshotCrdAvailable] = useState(false);
|
|
const [asyncLoading, setAsyncLoading] = useState(true);
|
|
const [asyncError, setAsyncError] = useState<string | null>(null);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [poolStats, setPoolStats] = useState<PoolStats[]>([]);
|
|
const [poolStatsError, setPoolStatsError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(() => {
|
|
setRefreshKey(k => k + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function fetchAsync() {
|
|
setAsyncLoading(true);
|
|
setAsyncError(null);
|
|
try {
|
|
// CSIDriver
|
|
try {
|
|
const driver = (await ApiProxy.request(
|
|
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
|
|
)) as CSIDriver;
|
|
if (!cancelled) setCsiDriver(driver);
|
|
} catch {
|
|
if (!cancelled) setCsiDriver(null);
|
|
}
|
|
|
|
// Controller pods
|
|
try {
|
|
const ctrlList = await ApiProxy.request(
|
|
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
|
|
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller'
|
|
)}`
|
|
);
|
|
if (!cancelled && isKubeList(ctrlList)) {
|
|
setControllerPods(ctrlList.items as TnsCsiPod[]);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setControllerPods([]);
|
|
}
|
|
|
|
// Node pods
|
|
try {
|
|
const nodeList = await ApiProxy.request(
|
|
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
|
|
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node'
|
|
)}`
|
|
);
|
|
if (!cancelled && isKubeList(nodeList)) {
|
|
setNodePods(nodeList.items as TnsCsiPod[]);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setNodePods([]);
|
|
}
|
|
|
|
// VolumeSnapshots (CRD — graceful degradation)
|
|
try {
|
|
const vscList = await ApiProxy.request(
|
|
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
|
|
);
|
|
if (!cancelled && isKubeList(vscList)) {
|
|
const allSnapshotClasses = vscList.items as VolumeSnapshotClass[];
|
|
const tnsCsiSnapshotClasses = allSnapshotClasses.filter(isTnsCsiVolumeSnapshotClass);
|
|
setVolumeSnapshotClasses(tnsCsiSnapshotClasses);
|
|
setSnapshotCrdAvailable(true);
|
|
|
|
const tnsCsiClassNames = new Set(tnsCsiSnapshotClasses.map(c => c.metadata.name));
|
|
|
|
const vsList = await ApiProxy.request(
|
|
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
|
|
);
|
|
if (!cancelled && isKubeList(vsList)) {
|
|
const allSnapshots = vsList.items as VolumeSnapshot[];
|
|
setVolumeSnapshots(filterTnsCsiVolumeSnapshots(allSnapshots, tnsCsiClassNames));
|
|
}
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setSnapshotCrdAvailable(false);
|
|
setVolumeSnapshotClasses([]);
|
|
setVolumeSnapshots([]);
|
|
}
|
|
}
|
|
|
|
// TrueNAS pool stats (only when API key is configured)
|
|
const config = getTnsCsiConfig();
|
|
if (config.truenasApiKey.trim()) {
|
|
const server = config.truenasServerOverride.trim();
|
|
if (server) {
|
|
try {
|
|
const pools = await fetchTruenasPoolStats(server, config.truenasApiKey.trim());
|
|
if (!cancelled) {
|
|
setPoolStats(pools);
|
|
setPoolStatsError(null);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (!cancelled) {
|
|
setPoolStats([]);
|
|
setPoolStatsError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (!cancelled) {
|
|
setPoolStats([]);
|
|
setPoolStatsError(null);
|
|
}
|
|
}
|
|
} 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`. Direct property access only works for fields that have
|
|
// explicit getter definitions in the class (e.g. provisioner, reclaimPolicy).
|
|
// Fields like `parameters`, `spec`, `status` must be read from `.jsonData`.
|
|
// We extract jsonData here so our plain-object type 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 filterTnsCsiStorageClasses(extractJsonData(allStorageClasses as unknown[]));
|
|
}, [allStorageClasses]);
|
|
|
|
const persistentVolumes = useMemo(() => {
|
|
if (!allPvs) return [];
|
|
return filterTnsCsiPersistentVolumes(extractJsonData(allPvs as unknown[]));
|
|
}, [allPvs]);
|
|
|
|
const persistentVolumeClaims = useMemo(() => {
|
|
if (!allPvcs || persistentVolumes.length === 0) return [];
|
|
return filterTnsCsiPVCs(
|
|
extractJsonData(allPvcs as unknown[]) as TnsCsiPersistentVolumeClaim[],
|
|
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 driverInstalled = csiDriver !== null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memoized context value to prevent unnecessary re-renders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const value = useMemo<TnsCsiContextValue>(
|
|
() => ({
|
|
csiDriver,
|
|
driverInstalled,
|
|
storageClasses,
|
|
persistentVolumes,
|
|
persistentVolumeClaims,
|
|
controllerPods,
|
|
nodePods,
|
|
volumeSnapshots,
|
|
volumeSnapshotClasses,
|
|
snapshotCrdAvailable,
|
|
poolStats,
|
|
poolStatsError,
|
|
loading,
|
|
error,
|
|
refresh,
|
|
}),
|
|
[
|
|
csiDriver,
|
|
driverInstalled,
|
|
storageClasses,
|
|
persistentVolumes,
|
|
persistentVolumeClaims,
|
|
controllerPods,
|
|
nodePods,
|
|
volumeSnapshots,
|
|
volumeSnapshotClasses,
|
|
snapshotCrdAvailable,
|
|
poolStats,
|
|
poolStatsError,
|
|
loading,
|
|
error,
|
|
refresh,
|
|
]
|
|
);
|
|
|
|
return <TnsCsiContext.Provider value={value}>{children}</TnsCsiContext.Provider>;
|
|
}
|