Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/ui/src/api/volumes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
19 changes: 18 additions & 1 deletion packages/ui/src/api/volumes.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createApiClient>) => {
return {
Expand All @@ -32,5 +42,12 @@ export const createVolumesApi = (client: ReturnType<typeof createApiClient>) =>
method: "POST",
});
},
scanVolume: async (volumeId: string, payload?: ScanVolumeRequest) => {
return client.request<components["schemas"]["Job"]>({
path: `/admin/volumes/${volumeId}/scan`,
method: "POST",
body: payload && Object.keys(payload).length > 0 ? payload : undefined,
});
},
};
};
80 changes: 65 additions & 15 deletions packages/ui/src/app/AdminStoragePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const statusKeyMap: Record<Volume["status"], I18nKey> = {
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";
Expand Down Expand Up @@ -80,6 +87,7 @@ export default function AdminStoragePage() {
const [scanJob, setScanJob] = useState<Job | null>(null);
const [scanJobLoading, setScanJobLoading] = useState(false);
const [scanJobErrorKey, setScanJobErrorKey] = useState<I18nKey | null>(null);
const [scanRetrying, setScanRetrying] = useState(false);

const loadVolumes = useCallback(async () => {
setLoading(true);
Expand All @@ -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;

Comment on lines +139 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate auto-refresh on existing scan job

This polling effect refreshes whenever scan_state is queued/running, but it does not require scan_job_id; since volumes can exist with scan_state='queued' and scan_job_id=NULL (see db/init/03_volumes.sql defaults), Admin Storage can enter an endless 3s /admin/volumes poll loop even when no scan is actually in flight. In that state users get repeated loading flicker and the backend gets unnecessary traffic, so the timer should only run when there is a real job id to track.

Useful? React with 👍 / 👎.

const timer = window.setTimeout(() => {
void loadVolumes();
}, 3000);
return () => window.clearTimeout(timer);
}, [activeVolume, loadVolumes]);

const handleValidate = async () => {
if (!validatePath || validating) return;

Expand Down Expand Up @@ -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;

Expand All @@ -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(
() => [
{
Expand Down Expand Up @@ -251,7 +294,14 @@ export default function AdminStoragePage() {
}
/>

<ActiveVolumeSection loading={loading} activeVolume={activeVolume} statusKeyMap={statusKeyMap} />
<ActiveVolumeSection
loading={loading}
activeVolume={activeVolume}
statusKeyMap={statusKeyMap}
scanStatusKeyMap={scanStatusKeyMap}
scanRetrying={scanRetrying}
onRetryScan={handleRetryAutoScan}
/>

<div className="admin-storage__section">
<div className="admin-storage__row">
Expand Down
35 changes: 34 additions & 1 deletion packages/ui/src/app/AdminStorageSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,28 @@ type ActiveVolumeSectionProps = {
loading: boolean;
activeVolume: Volume | undefined;
statusKeyMap: Record<Volume["status"], I18nKey>;
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 (
<section className="admin-storage__section">
<h2 className="admin-storage__section-title">{t("admin.storage.activeTitle")}</h2>
Expand All @@ -31,6 +50,20 @@ export function ActiveVolumeSection({ loading, activeVolume, statusKeyMap }: Act
<strong>{activeVolume.name}</strong>
<p className="admin-storage__muted">{activeVolume.base_path}</p>
<p className="admin-storage__muted">{t(statusKeyMap[activeVolume.status])}</p>
<p className="admin-storage__muted">
{t("field.jobStatus")}: {scanStateKey ? t(scanStateKey) : "-"}
</p>
<p className="admin-storage__muted">
{t("field.jobProgress")}: {formatProgress(activeVolume.scan_progress)}
</p>
{activeVolume.scan_error ? <p className="admin-storage__muted">{activeVolume.scan_error}</p> : null}
{showRetry ? (
<div className="admin-storage__actions">
<Button variant="secondary" onClick={onRetryScan} loading={scanRetrying}>
{t("action.retry")}
</Button>
</div>
) : null}
</div>
) : (
<p className="admin-storage__muted">{t("msg.noActiveVolume")}</p>
Expand Down
Loading