feat(web/m7-381): admin overview Library Scan section + manual trigger
Adds getScanStatus/triggerScan helpers, createScanStatusQuery factory (3s poll), qk.scanStatus(), and a Library Scan section on the admin overview page with per-stage tallies and inline Run scan button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getScanStatus, triggerScan } from './admin';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from './client';
|
||||
|
||||
describe('admin scan API', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('getScanStatus GETs the correct path', async () => {
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 's1', started_at: '2026-05-04T00:00:00Z', finished_at: null, in_flight: true
|
||||
});
|
||||
const got = await getScanStatus();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/status');
|
||||
expect(got.in_flight).toBe(true);
|
||||
});
|
||||
|
||||
it('triggerScan POSTs an empty body to /run', async () => {
|
||||
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: 's2' });
|
||||
const got = await triggerScan();
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/scan/run', {});
|
||||
expect(got.id).toBe('s2');
|
||||
});
|
||||
});
|
||||
@@ -181,3 +181,57 @@ export type RefetchMissingResponse = {
|
||||
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
||||
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
||||
}
|
||||
|
||||
// Library scan -------------------------------------------------------------
|
||||
|
||||
export type ScanStageLibrary = {
|
||||
scanned: number;
|
||||
added: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errored: number;
|
||||
};
|
||||
|
||||
export type ScanStageMbidBackfill = {
|
||||
processed: number;
|
||||
healed: number;
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
export type ScanStageCoverEnrich = {
|
||||
processed: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
export type ScanStatus = {
|
||||
id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
library?: ScanStageLibrary;
|
||||
mbid_backfill?: ScanStageMbidBackfill;
|
||||
cover_enrich?: ScanStageCoverEnrich;
|
||||
error_message?: string;
|
||||
in_flight: boolean;
|
||||
};
|
||||
|
||||
export async function getScanStatus(): Promise<ScanStatus> {
|
||||
return api.get<ScanStatus>('/api/admin/scan/status');
|
||||
}
|
||||
|
||||
export type TriggerScanResp = { id?: string };
|
||||
|
||||
export async function triggerScan(): Promise<TriggerScanResp> {
|
||||
return api.post<TriggerScanResp>('/api/admin/scan/run', {});
|
||||
}
|
||||
|
||||
// Polls every 3s while a scan is in flight; falls back to a 30s stale-time
|
||||
// when idle so the section auto-updates as workers complete each stage.
|
||||
export function createScanStatusQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.scanStatus(),
|
||||
queryFn: getScanStatus,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 3_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const qk = {
|
||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||
adminQuarantineActions: (limit?: number) =>
|
||||
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
home: () => ['home'] as const,
|
||||
|
||||
Reference in New Issue
Block a user