diff --git a/packages/ui/src/api/volumes.spec.ts b/packages/ui/src/api/volumes.spec.ts index 0bcad5b..e838644 100644 --- a/packages/ui/src/api/volumes.spec.ts +++ b/packages/ui/src/api/volumes.spec.ts @@ -104,4 +104,28 @@ describe("volumes api", () => { expect(url).toBe("http://localhost:1234/admin/volumes/22222222-2222-2222-2222-222222222222/activate"); expect(init?.method).toBe("POST"); }); + + it("enqueues volume scan", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse(202, { + id: "33333333-3333-3333-3333-333333333333", + type: "VOLUME_AUTO_SCAN", + status: "QUEUED", + created_at: "2026-03-01T00:00:00Z", + }), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = createApiClient({ baseUrl: "http://localhost:1234" }); + const api = createVolumesApi(client); + + const result = await api.scanVolume("22222222-2222-2222-2222-222222222222", { dry_run: false }); + + expect(result.type).toBe("VOLUME_AUTO_SCAN"); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe("http://localhost:1234/admin/volumes/22222222-2222-2222-2222-222222222222/scan"); + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)); + expect(body).toEqual({ dry_run: false }); + }); }); diff --git a/packages/ui/src/api/volumes.ts b/packages/ui/src/api/volumes.ts index 42a3df4..b0ee147 100644 --- a/packages/ui/src/api/volumes.ts +++ b/packages/ui/src/api/volumes.ts @@ -1,11 +1,21 @@ import { createApiClient } from "./client"; import type { components } from "./schema"; -export type Volume = components["schemas"]["Volume"]; +export type VolumeScanState = "queued" | "running" | "succeeded" | "failed"; +export type Volume = components["schemas"]["Volume"] & { + scan_state?: VolumeScanState; + scan_job_id?: string | null; + scan_progress?: number | null; + scan_error?: string | null; + scan_updated_at?: string | null; +}; export type ListVolumesResponse = components["schemas"]["ListVolumesResponse"]; export type CreateVolumeRequest = components["schemas"]["CreateVolumeRequest"]; export type ValidatePathRequest = components["schemas"]["ValidatePathRequest"]; export type ValidatePathResponse = components["schemas"]["ValidatePathResponse"]; +export type ScanVolumeRequest = { + dry_run?: boolean; +}; export const createVolumesApi = (client: ReturnType) => { return { @@ -32,5 +42,12 @@ export const createVolumesApi = (client: ReturnType) => method: "POST", }); }, + scanVolume: async (volumeId: string, payload?: ScanVolumeRequest) => { + return client.request({ + path: `/admin/volumes/${volumeId}/scan`, + method: "POST", + body: payload && Object.keys(payload).length > 0 ? payload : undefined, + }); + }, }; }; diff --git a/packages/ui/src/app/AdminStoragePage.tsx b/packages/ui/src/app/AdminStoragePage.tsx index 33d5e1d..44d4b13 100644 --- a/packages/ui/src/app/AdminStoragePage.tsx +++ b/packages/ui/src/app/AdminStoragePage.tsx @@ -21,6 +21,13 @@ const statusKeyMap: Record = { OFFLINE: "status.volumeOffline", }; +const scanStatusKeyMap: Record<"queued" | "running" | "succeeded" | "failed", I18nKey> = { + queued: "status.jobQueued", + running: "status.jobRunning", + succeeded: "status.jobDone", + failed: "status.jobFailed", +}; + const formatBytes = (value?: number) => { if (value === null || value === undefined) return "-"; if (value === 0) return "0 B"; @@ -80,6 +87,7 @@ export default function AdminStoragePage() { const [scanJob, setScanJob] = useState(null); const [scanJobLoading, setScanJobLoading] = useState(false); const [scanJobErrorKey, setScanJobErrorKey] = useState(null); + const [scanRetrying, setScanRetrying] = useState(false); const loadVolumes = useCallback(async () => { setLoading(true); @@ -105,6 +113,38 @@ export default function AdminStoragePage() { [selectedVolumeId, volumes], ); + const fetchScanJob = useCallback( + async (jobId: string) => { + setScanJobLoading(true); + setScanJobErrorKey(null); + + try { + const job = await jobsApi.getJob(jobId); + setScanJob(job); + } catch (error) { + setScanJobErrorKey(error instanceof ApiError ? error.key : "err.network"); + } finally { + setScanJobLoading(false); + } + }, + [jobsApi], + ); + + useEffect(() => { + if (!activeVolume?.scan_job_id) return; + void fetchScanJob(activeVolume.scan_job_id); + }, [activeVolume?.scan_job_id, fetchScanJob]); + + useEffect(() => { + if (!activeVolume) return; + if (activeVolume.scan_state !== "queued" && activeVolume.scan_state !== "running") return; + + const timer = window.setTimeout(() => { + void loadVolumes(); + }, 3000); + return () => window.clearTimeout(timer); + }, [activeVolume, loadVolumes]); + const handleValidate = async () => { if (!validatePath || validating) return; @@ -160,20 +200,6 @@ export default function AdminStoragePage() { } }; - const fetchScanJob = async (jobId: string) => { - setScanJobLoading(true); - setScanJobErrorKey(null); - - try { - const job = await jobsApi.getJob(jobId); - setScanJob(job); - } catch (error) { - setScanJobErrorKey(error instanceof ApiError ? error.key : "err.network"); - } finally { - setScanJobLoading(false); - } - }; - const handleStartScan = async () => { if (scanSubmitting) return; @@ -199,6 +225,23 @@ export default function AdminStoragePage() { } }; + const handleRetryAutoScan = async () => { + if (!activeVolume || scanRetrying) return; + + setScanRetrying(true); + setScanErrorKey(null); + try { + const job = await volumesApi.scanVolume(activeVolume.id, { dry_run: false }); + setScanJob(job); + await loadVolumes(); + await fetchScanJob(job.id); + } catch (error) { + setScanErrorKey(error instanceof ApiError ? error.key : "err.network"); + } finally { + setScanRetrying(false); + } + }; + const columns = useMemo( () => [ { @@ -251,7 +294,14 @@ export default function AdminStoragePage() { } /> - +
diff --git a/packages/ui/src/app/AdminStorageSections.tsx b/packages/ui/src/app/AdminStorageSections.tsx index 26aaa09..22ead7f 100644 --- a/packages/ui/src/app/AdminStorageSections.tsx +++ b/packages/ui/src/app/AdminStorageSections.tsx @@ -18,9 +18,28 @@ type ActiveVolumeSectionProps = { loading: boolean; activeVolume: Volume | undefined; statusKeyMap: Record; + scanStatusKeyMap: Record<"queued" | "running" | "succeeded" | "failed", I18nKey>; + scanRetrying: boolean; + onRetryScan: () => void; }; -export function ActiveVolumeSection({ loading, activeVolume, statusKeyMap }: ActiveVolumeSectionProps) { +const formatProgress = (value: number | null | undefined) => { + if (value === null || value === undefined) return "-"; + return `${Math.round(value * 100)}%`; +}; + +export function ActiveVolumeSection({ + loading, + activeVolume, + statusKeyMap, + scanStatusKeyMap, + scanRetrying, + onRetryScan, +}: ActiveVolumeSectionProps) { + const scanState = activeVolume?.scan_state; + const scanStateKey = scanState ? scanStatusKeyMap[scanState] : null; + const showRetry = scanState === "failed"; + return (

{t("admin.storage.activeTitle")}

@@ -31,6 +50,20 @@ export function ActiveVolumeSection({ loading, activeVolume, statusKeyMap }: Act {activeVolume.name}

{activeVolume.base_path}

{t(statusKeyMap[activeVolume.status])}

+

+ {t("field.jobStatus")}: {scanStateKey ? t(scanStateKey) : "-"} +

+

+ {t("field.jobProgress")}: {formatProgress(activeVolume.scan_progress)} +

+ {activeVolume.scan_error ?

{activeVolume.scan_error}

: null} + {showRetry ? ( +
+ +
+ ) : null}
) : (

{t("msg.noActiveVolume")}