diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4f0be..1ba9af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.6] - 2026-03-04 + +### Fixed + +- **PVC bind loop leak** — benchmark page's PVC wait loop now checks a cancellation ref on unmount, preventing leaked async work after navigation +- **DeleteOptions body** — `deleteJob` now sends correct Kubernetes `DeleteOptions` with `apiVersion` and `kind` fields so foreground propagation is honored +- **Snapshot filtering** — `TnsCsiDataContext` now filters volume snapshots and snapshot classes to only tns-csi driver ones using `filterTnsCsiVolumeSnapshots` and `isTnsCsiVolumeSnapshotClass` (previously showed all drivers' snapshots) +- **Stale closures in Escape handlers** — `closeSc` / `closeVolume` in StorageClassesPage and VolumesPage wrapped in `useCallback` with proper deps, removing `eslint-disable` suppressions +- **Cleanup button double-click** — benchmark cleanup "Delete Job + PVC" button now has a `cleaningUp` loading state with disabled styling, replacing unsafe `window.confirm`/`window.alert` calls +- **Dark mode protocol colors** — replaced hardcoded hex colors in OverviewPage protocol chart with `var(--mui-palette-*)` tokens +- **Import style consistency** — unified `React.useMemo` to destructured `useMemo` in OverviewPage +- **Lint warnings** — fixed all 35 ESLint warnings (import sorting, indentation, boolean attributes, line length, chained call newlines) + ## [0.2.4] - 2026-02-26 ### Added @@ -89,7 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TypeScript strict mode with zero `any` types - ESLint + Prettier code quality tooling -[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.4...HEAD +[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.6...HEAD +[0.2.6]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.5...v0.2.6 [0.2.4]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.1...v0.2.2 diff --git a/src/api/TnsCsiDataContext.tsx b/src/api/TnsCsiDataContext.tsx index 428d0ea..ce44b29 100644 --- a/src/api/TnsCsiDataContext.tsx +++ b/src/api/TnsCsiDataContext.tsx @@ -13,12 +13,14 @@ import { filterTnsCsiPersistentVolumes, filterTnsCsiPVCs, filterTnsCsiStorageClasses, + filterTnsCsiVolumeSnapshots, isKubeList, + isTnsCsiVolumeSnapshotClass, + TNS_CSI_PROVISIONER, TnsCsiPersistentVolume, TnsCsiPersistentVolumeClaim, TnsCsiPod, TnsCsiStorageClass, - TNS_CSI_PROVISIONER, VolumeSnapshot, VolumeSnapshotClass, } from './k8s'; @@ -151,14 +153,19 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) '/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses' ); if (!cancelled && isKubeList(vscList)) { - setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]); + 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)) { - setVolumeSnapshots(vsList.items as VolumeSnapshot[]); + const allSnapshots = vsList.items as VolumeSnapshot[]; + setVolumeSnapshots(filterTnsCsiVolumeSnapshots(allSnapshots, tnsCsiClassNames)); } } } catch { diff --git a/src/api/kbench.ts b/src/api/kbench.ts index ae00b55..6a19fca 100644 --- a/src/api/kbench.ts +++ b/src/api/kbench.ts @@ -77,7 +77,8 @@ export const KBENCH_STORAGE_CLASS_ANNOTATION = 'tns-csi.headlamp/storage-class'; // --------------------------------------------------------------------------- function shortId(): string { - return Math.random().toString(36).slice(2, 8); + return Math.random().toString(36) + .slice(2, 8); } export function generateJobName(): string { @@ -269,7 +270,11 @@ export async function fetchKbenchLogs(jobName: string, namespace: string): Promi export async function deleteJob(jobName: string, namespace: string): Promise { await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, { method: 'DELETE', - body: JSON.stringify({ propagationPolicy: 'Foreground' }), + body: JSON.stringify({ + apiVersion: 'v1', + kind: 'DeleteOptions', + propagationPolicy: 'Foreground', + }), headers: { 'Content-Type': 'application/json' }, }); } diff --git a/src/components/BenchmarkPage.test.tsx b/src/components/BenchmarkPage.test.tsx index 0b28442..0bf1673 100644 --- a/src/components/BenchmarkPage.test.tsx +++ b/src/components/BenchmarkPage.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor, act } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ ApiProxy: { @@ -39,9 +39,9 @@ vi.mock('../api/kbench', async importOriginal => { }; }); -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; -import { createPvc, createJob, listKbenchJobs } from '../api/kbench'; +import { createJob, createPvc, listKbenchJobs } from '../api/kbench'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { defaultContext, makeSampleStorageClass } from '../test-helpers'; import BenchmarkPage from './BenchmarkPage'; diff --git a/src/components/BenchmarkPage.tsx b/src/components/BenchmarkPage.tsx index 9d53687..f0e3abe 100644 --- a/src/components/BenchmarkPage.tsx +++ b/src/components/BenchmarkPage.tsx @@ -15,7 +15,7 @@ import { StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; +import { formatAge } from '../api/k8s'; import type { BenchmarkState, KbenchJobSummary, KbenchResult } from '../api/kbench'; import { createJob, @@ -32,7 +32,7 @@ import { listKbenchJobs, parseKbenchLog, } from '../api/kbench'; -import { formatAge } from '../api/k8s'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; // --------------------------------------------------------------------------- // Result display components @@ -184,8 +184,8 @@ function KbenchResultDisplay({ result }: { result: KbenchResult }) { ]} /> - - + + ); @@ -601,6 +601,8 @@ export default function BenchmarkPage() { const [currentResult, setCurrentResult] = useState(null); const [lastNamespace, setLastNamespace] = useState('default'); const pollRef = useRef | null>(null); + const cancelledRef = useRef(false); + const [cleaningUp, setCleaningUp] = useState(false); const scNames = storageClasses.map(sc => sc.metadata.name); @@ -618,6 +620,7 @@ export default function BenchmarkPage() { mode: string; }) { stopPolling(); + cancelledRef.current = false; setCurrentResult(null); setLastNamespace(opts.namespace); @@ -650,7 +653,7 @@ export default function BenchmarkPage() { setBenchState({ status: 'waiting-pvc', pvcName }); const pvcDeadline = Date.now() + MAX_PVC_WAIT_MS; let pvcBound = false; - while (Date.now() < pvcDeadline) { + while (Date.now() < pvcDeadline && !cancelledRef.current) { try { const pvc = (await ApiProxy.request( `/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}` @@ -664,6 +667,7 @@ export default function BenchmarkPage() { } await new Promise(r => setTimeout(r, 5000)); } + if (cancelledRef.current) return; if (!pvcBound) { setBenchState({ status: 'failed', @@ -746,8 +750,14 @@ export default function BenchmarkPage() { }, POLL_INTERVAL_MS); } - // Clean up polling on unmount - useEffect(() => () => stopPolling(), []); + // Clean up polling and cancel async loops on unmount + useEffect( + () => () => { + cancelledRef.current = true; + stopPolling(); + }, + [] + ); const isRunning = benchState.status !== 'idle' && @@ -808,24 +818,25 @@ export default function BenchmarkPage() { ), }, diff --git a/src/components/DriverPodDetailSection.tsx b/src/components/DriverPodDetailSection.tsx index f74a4ef..53bd3ff 100644 --- a/src/components/DriverPodDetailSection.tsx +++ b/src/components/DriverPodDetailSection.tsx @@ -8,7 +8,7 @@ import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import React from 'react'; -import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s'; +import { formatAge, getPodRestarts, isPodReady, TnsCsiPod } from '../api/k8s'; interface DriverPodDetailSectionProps { resource: { diff --git a/src/components/DriverStatusCard.test.tsx b/src/components/DriverStatusCard.test.tsx index 6a01de2..5ee02bd 100644 --- a/src/components/DriverStatusCard.test.tsx +++ b/src/components/DriverStatusCard.test.tsx @@ -6,8 +6,8 @@ vi.mock( async () => await import('./__mocks__/commonComponents') ); +import { makeSampleMetrics, makeSamplePod, sampleCSIDriver } from '../test-helpers'; import DriverStatusCard from './DriverStatusCard'; -import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpers'; describe('DriverStatusCard', () => { it('shows "Not detected" when no CSI driver is present', () => { diff --git a/src/components/MetricsPage.test.tsx b/src/components/MetricsPage.test.tsx index eb19f14..aa49997 100644 --- a/src/components/MetricsPage.test.tsx +++ b/src/components/MetricsPage.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock( '@kinvolk/headlamp-plugin/lib/CommonComponents', @@ -15,9 +15,9 @@ vi.mock('../api/metrics', async importOriginal => { }; }); -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { fetchControllerMetrics } from '../api/metrics'; -import { defaultContext, makeSamplePod, makeSampleMetrics } from '../test-helpers'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; +import { defaultContext, makeSampleMetrics, makeSamplePod } from '../test-helpers'; import MetricsPage from './MetricsPage'; function mockContext(overrides?: Parameters[0]) { diff --git a/src/components/MetricsPage.tsx b/src/components/MetricsPage.tsx index 097028b..b973827 100644 --- a/src/components/MetricsPage.tsx +++ b/src/components/MetricsPage.tsx @@ -11,9 +11,9 @@ import { StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import React, { useCallback, useEffect, useState } from 'react'; -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import type { TnsCsiMetrics } from '../api/metrics'; import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; function formatAuditTime(iso: string): string { const date = new Date(iso); diff --git a/src/components/OverviewPage.test.tsx b/src/components/OverviewPage.test.tsx index 9860026..00cadf5 100644 --- a/src/components/OverviewPage.test.tsx +++ b/src/components/OverviewPage.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock( '@kinvolk/headlamp-plugin/lib/CommonComponents', @@ -15,15 +15,15 @@ vi.mock('../api/metrics', async importOriginal => { }; }); -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { fetchControllerMetrics } from '../api/metrics'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { defaultContext, + makeSampleMetrics, makeSamplePod, makeSamplePV, makeSamplePVC, makeSampleStorageClass, - makeSampleMetrics, sampleCSIDriver, } from '../test-helpers'; import OverviewPage from './OverviewPage'; diff --git a/src/components/OverviewPage.tsx b/src/components/OverviewPage.tsx index daf6261..4e2a938 100644 --- a/src/components/OverviewPage.tsx +++ b/src/components/OverviewPage.tsx @@ -14,11 +14,11 @@ import { SimpleTable, StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s'; import type { TnsCsiMetrics } from '../api/metrics'; import { fetchControllerMetrics } from '../api/metrics'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import DriverStatusCard from './DriverStatusCard'; // --------------------------------------------------------------------------- @@ -26,10 +26,10 @@ import DriverStatusCard from './DriverStatusCard'; // --------------------------------------------------------------------------- const PROTOCOL_COLORS: Record = { - NFS: '#1976d2', - 'NVMe-oF': '#9c27b0', - iSCSI: '#f57c00', - Other: '#9e9e9e', + NFS: 'var(--mui-palette-primary-main, #1976d2)', + 'NVMe-oF': 'var(--mui-palette-secondary-main, #9c27b0)', + iSCSI: 'var(--mui-palette-warning-main, #f57c00)', + Other: 'var(--mui-palette-action-disabled, #9e9e9e)', }; function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) { @@ -85,7 +85,7 @@ export default function OverviewPage() { void fetchMetrics(); }, [fetchMetrics]); - const capacityByPool: Map = React.useMemo(() => { + const capacityByPool: Map = useMemo(() => { const map = new Map(); if (!metrics) return map; const handleToPool = new Map(); @@ -256,19 +256,19 @@ export default function OverviewPage() { }, ...(pvcStatusCounts.Pending > 0 ? [ - { - name: 'PVCs (Pending)', - value: {pvcStatusCounts.Pending}, - }, - ] + { + name: 'PVCs (Pending)', + value: {pvcStatusCounts.Pending}, + }, + ] : []), ...(pvcStatusCounts.Lost > 0 ? [ - { - name: 'PVCs (Lost)', - value: {pvcStatusCounts.Lost}, - }, - ] + { + name: 'PVCs (Lost)', + value: {pvcStatusCounts.Lost}, + }, + ] : []), ]} /> @@ -318,7 +318,8 @@ export default function OverviewPage() { )} - {/* Provisioned capacity by pool (from Prometheus metrics — shown when TrueNAS API not configured) */} + {/* Provisioned capacity by pool (from Prometheus metrics — + shown when TrueNAS API not configured) */} {poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && ( ({ vi.mock('../api/TnsCsiDataContext'); import { useTnsCsiContext } from '../api/TnsCsiDataContext'; -import { defaultContext, makeSampleStorageClass, makeSamplePV } from '../test-helpers'; +import { defaultContext, makeSamplePV, makeSampleStorageClass } from '../test-helpers'; import StorageClassesPage from './StorageClassesPage'; function mockContext(overrides?: Parameters[0]) { diff --git a/src/components/StorageClassesPage.tsx b/src/components/StorageClassesPage.tsx index 3d5af61..74ee719 100644 --- a/src/components/StorageClassesPage.tsx +++ b/src/components/StorageClassesPage.tsx @@ -13,11 +13,11 @@ import { SimpleTable, StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import type { TnsCsiStorageClass } from '../api/k8s'; import { formatProtocol } from '../api/k8s'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; // --------------------------------------------------------------------------- // Detail drawer @@ -196,10 +196,10 @@ export default function StorageClassesPage() { history.push(`${location.pathname}#${name}`); }; - const closeSc = () => { + const closeSc = useCallback(() => { setSelectedName(null); history.push(location.pathname); - }; + }, [history, location.pathname]); useEffect(() => { if (!selectedName) return; @@ -208,8 +208,7 @@ export default function StorageClassesPage() { }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedName]); + }, [selectedName, closeSc]); if (loading) return ; diff --git a/src/components/VolumesPage.test.tsx b/src/components/VolumesPage.test.tsx index f3e5bc2..c90bf4b 100644 --- a/src/components/VolumesPage.test.tsx +++ b/src/components/VolumesPage.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock( '@kinvolk/headlamp-plugin/lib/CommonComponents', diff --git a/src/components/VolumesPage.tsx b/src/components/VolumesPage.tsx index 8c4aefd..c8a2943 100644 --- a/src/components/VolumesPage.tsx +++ b/src/components/VolumesPage.tsx @@ -11,11 +11,11 @@ import { SimpleTable, StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import type { TnsCsiPersistentVolume } from '../api/k8s'; import { formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s'; +import { useTnsCsiContext } from '../api/TnsCsiDataContext'; // --------------------------------------------------------------------------- // Detail panel @@ -180,10 +180,10 @@ export default function VolumesPage() { history.push(`${location.pathname}#${name}`); }; - const closeVolume = () => { + const closeVolume = useCallback(() => { setSelectedName(null); history.push(location.pathname); - }; + }, [history, location.pathname]); useEffect(() => { if (!selectedName) return; @@ -192,8 +192,7 @@ export default function VolumesPage() { }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedName]); + }, [selectedName, closeVolume]); if (loading) return ; diff --git a/src/index.tsx b/src/index.tsx index 6ba827d..81be362 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,20 +15,20 @@ import { } from '@kinvolk/headlamp-plugin/lib'; import React from 'react'; import { TnsCsiDataProvider } from './api/TnsCsiDataContext'; -import TnsCsiSettings from './components/TnsCsiSettings'; import BenchmarkPage from './components/BenchmarkPage'; import DriverPodDetailSection from './components/DriverPodDetailSection'; +import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton'; import { buildPVColumns, buildStorageClassColumns, } from './components/integrations/StorageClassColumns'; -import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton'; import MetricsPage from './components/MetricsPage'; import OverviewPage from './components/OverviewPage'; import PVCDetailSection from './components/PVCDetailSection'; import PVDetailSection from './components/PVDetailSection'; import SnapshotsPage from './components/SnapshotsPage'; import StorageClassesPage from './components/StorageClassesPage'; +import TnsCsiSettings from './components/TnsCsiSettings'; import VolumesPage from './components/VolumesPage'; // --------------------------------------------------------------------------- diff --git a/src/test-helpers.tsx b/src/test-helpers.tsx index 61b3493..e44c3ee 100644 --- a/src/test-helpers.tsx +++ b/src/test-helpers.tsx @@ -4,7 +4,6 @@ */ import { vi } from 'vitest'; -import type { TnsCsiContextValue } from './api/TnsCsiDataContext'; import type { CSIDriver, TnsCsiPersistentVolume, @@ -15,6 +14,7 @@ import type { VolumeSnapshotClass, } from './api/k8s'; import type { TnsCsiMetrics } from './api/metrics'; +import type { TnsCsiContextValue } from './api/TnsCsiDataContext'; // --------------------------------------------------------------------------- // Default context value (everything empty / zeroed)