feat(web/m7-recurring-scans): admin Scan schedule section
Adds a "Scan schedule" section to the admin overview page between the existing Library Scan and Cover Art sections. Mode picker (Off / Every N hours / Daily / Weekly) with conditional interval / time / day inputs. Save button disabled until local state diverges from the server state. "Next scan: in N hours (timestamp)" inline indicator when mode != off. formatRelative is a static helper (recomputes on render); operator sees the configuration confirmation at a glance — not a countdown timer. admin.test.ts mock extended with createScanScheduleQuery returning mode='off' default. New test asserts the section renders. Existing assertions still pass.
This commit is contained in:
@@ -10,13 +10,17 @@
|
||||
createAdminQuarantineQuery,
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createScanScheduleQuery,
|
||||
updateScanSchedule,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
resolveQuarantine,
|
||||
deleteQuarantineFile,
|
||||
deleteQuarantineViaLidarr,
|
||||
refetchMissingCovers,
|
||||
triggerScan
|
||||
triggerScan,
|
||||
type ScanSchedule,
|
||||
type ScanScheduleMode
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { copyForCode } from '$lib/api/error-copy';
|
||||
@@ -224,6 +228,89 @@
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- Scan schedule ----
|
||||
const scheduleStore = $derived(createScanScheduleQuery());
|
||||
const scheduleQ = $derived($scheduleStore);
|
||||
const schedule = $derived(scheduleQ.data);
|
||||
|
||||
let scheduleLocal = $state<{
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours: number;
|
||||
time_of_day: string;
|
||||
weekly_day: number;
|
||||
}>({
|
||||
mode: 'off',
|
||||
interval_hours: 6,
|
||||
time_of_day: '03:00',
|
||||
weekly_day: 7,
|
||||
});
|
||||
let scheduleSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (schedule) {
|
||||
scheduleLocal = {
|
||||
mode: schedule.mode,
|
||||
interval_hours: schedule.interval_hours ?? 6,
|
||||
time_of_day: schedule.time_of_day ?? '03:00',
|
||||
weekly_day: schedule.weekly_day ?? 7,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function scheduleHasChanges(): boolean {
|
||||
if (!schedule) return false;
|
||||
if (scheduleLocal.mode !== schedule.mode) return true;
|
||||
switch (scheduleLocal.mode) {
|
||||
case 'interval':
|
||||
return scheduleLocal.interval_hours !== (schedule.interval_hours ?? -1);
|
||||
case 'daily':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '');
|
||||
case 'weekly':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '')
|
||||
|| scheduleLocal.weekly_day !== (schedule.weekly_day ?? -1);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveSchedule() {
|
||||
scheduleSaving = true;
|
||||
try {
|
||||
const patch: { mode: ScanScheduleMode; interval_hours?: number; time_of_day?: string; weekly_day?: number } = {
|
||||
mode: scheduleLocal.mode,
|
||||
};
|
||||
if (scheduleLocal.mode === 'interval') patch.interval_hours = scheduleLocal.interval_hours;
|
||||
if (scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly') patch.time_of_day = scheduleLocal.time_of_day;
|
||||
if (scheduleLocal.mode === 'weekly') patch.weekly_day = scheduleLocal.weekly_day;
|
||||
await updateScanSchedule(patch);
|
||||
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
||||
} catch (e) {
|
||||
showToast(`Schedule save failed: ${(e as { code?: string })?.code ?? 'unknown'}`);
|
||||
} finally {
|
||||
scheduleSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const target = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
const absHours = Math.abs(diffMs) / 3_600_000;
|
||||
let rel: string;
|
||||
if (absHours < 1) {
|
||||
const mins = Math.round(Math.abs(diffMs) / 60_000);
|
||||
rel = diffMs >= 0 ? `in ${mins} min` : `${mins} min ago`;
|
||||
} else if (absHours < 48) {
|
||||
const h = Math.round(absHours);
|
||||
rel = diffMs >= 0 ? `in ${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
const days = Math.round(absHours / 24);
|
||||
rel = diffMs >= 0 ? `in ${days} days` : `${days} days ago`;
|
||||
}
|
||||
const local = target.toLocaleString();
|
||||
return `${rel} (${local})`;
|
||||
}
|
||||
|
||||
// ---- Cover art bulk refetch ----
|
||||
let bulkBusy = $state(false);
|
||||
let bulkResult = $state<string | null>(null);
|
||||
@@ -430,6 +517,77 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Scan schedule -->
|
||||
<section class="rounded border border-border bg-surface p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-medium">Scan schedule</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Run a library scan automatically on a recurring cadence. Manual triggers
|
||||
and boot scans are unaffected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if schedule}
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="schedule-mode">Mode</label>
|
||||
<select id="schedule-mode" bind:value={scheduleLocal.mode}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value="off">Off</option>
|
||||
<option value="interval">Every N hours</option>
|
||||
<option value="daily">Daily at time</option>
|
||||
<option value="weekly">Weekly at day + time</option>
|
||||
</select>
|
||||
|
||||
{#if scheduleLocal.mode === 'interval'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-interval">Interval</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="schedule-interval" type="number" min="1" max="168"
|
||||
bind:value={scheduleLocal.interval_hours}
|
||||
class="w-20 text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
<span class="text-sm text-text-muted">hours</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-time">Time</label>
|
||||
<input id="schedule-time" type="time" bind:value={scheduleLocal.time_of_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-day">Day</label>
|
||||
<select id="schedule-day" bind:value={scheduleLocal.weekly_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
<option value={7}>Sunday</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" disabled={!scheduleHasChanges() || scheduleSaving}
|
||||
onclick={onSaveSchedule}
|
||||
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50">
|
||||
{scheduleSaving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{#if schedule.next_scheduled_at}
|
||||
<span class="text-xs text-text-muted">
|
||||
Next scan: {formatRelative(schedule.next_scheduled_at)}
|
||||
</span>
|
||||
{:else if scheduleLocal.mode === 'off'}
|
||||
<span class="text-xs text-text-muted">Schedule is off.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Cover art bulk refetch -->
|
||||
<section class="rounded border border-border bg-surface p-4">
|
||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||
|
||||
@@ -41,7 +41,21 @@ vi.mock('$lib/api/admin', async () => {
|
||||
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
|
||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||
triggerScan: vi.fn().mockResolvedValue({}),
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 })
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 }),
|
||||
createScanScheduleQuery: vi.fn(() =>
|
||||
readable({
|
||||
data: {
|
||||
mode: 'off',
|
||||
interval_hours: null,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
),
|
||||
updateScanSchedule: vi.fn().mockResolvedValue({})
|
||||
};
|
||||
});
|
||||
|
||||
@@ -242,6 +256,14 @@ describe('admin Overview page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the scan-schedule section with mode=off default', () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
expect(screen.getByText('Scan schedule')).toBeInTheDocument();
|
||||
const select = screen.getByLabelText('Mode') as HTMLSelectElement;
|
||||
expect(select.value).toBe('off');
|
||||
});
|
||||
|
||||
test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
|
||||
Reference in New Issue
Block a user