feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,58 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
|
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
|
||||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { api } from './client';
|
|
||||||
|
|
||||||
describe('admin scan-schedule API', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('getScanSchedule GETs the correct path', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'daily',
|
|
||||||
interval_hours: null,
|
|
||||||
time_of_day: '03:00',
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: '2026-05-07T03:00:00Z'
|
|
||||||
};
|
|
||||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await getScanSchedule();
|
|
||||||
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
|
|
||||||
expect(got).toEqual(sample);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updateScanSchedule PATCHes with the right body', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'interval',
|
|
||||||
interval_hours: 6,
|
|
||||||
time_of_day: null,
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: '2026-05-06T20:00:00Z'
|
|
||||||
};
|
|
||||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
|
|
||||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
|
|
||||||
mode: 'interval',
|
|
||||||
interval_hours: 6
|
|
||||||
});
|
|
||||||
expect(got.mode).toBe('interval');
|
|
||||||
expect(got.interval_hours).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updateScanSchedule with mode=off sends just mode', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'off',
|
|
||||||
interval_hours: null,
|
|
||||||
time_of_day: null,
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: null
|
|
||||||
};
|
|
||||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await updateScanSchedule({ mode: 'off' });
|
|
||||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
|
|
||||||
expect(got.next_scheduled_at).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -383,43 +383,6 @@ export function createCoverProvidersQuery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan schedule -----------------------------------------------------------
|
|
||||||
|
|
||||||
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
|
||||||
|
|
||||||
export type ScanSchedule = {
|
|
||||||
mode: ScanScheduleMode;
|
|
||||||
interval_hours: number | null;
|
|
||||||
time_of_day: string | null; // 'HH:MM'
|
|
||||||
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
|
||||||
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ScanScheduleUpdate = {
|
|
||||||
mode: ScanScheduleMode;
|
|
||||||
interval_hours?: number | null;
|
|
||||||
time_of_day?: string | null;
|
|
||||||
weekly_day?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getScanSchedule(): Promise<ScanSchedule> {
|
|
||||||
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
|
||||||
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings rarely change in operator time. 60s staleTime; refetches
|
|
||||||
// on window focus + after any PATCH invalidation — no polling.
|
|
||||||
export function createScanScheduleQuery() {
|
|
||||||
return createQuery({
|
|
||||||
queryKey: qk.scanSchedule(),
|
|
||||||
queryFn: getScanSchedule,
|
|
||||||
staleTime: 60_000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin user-management ------------------------------------------------------
|
// Admin user-management ------------------------------------------------------
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const qk = {
|
|||||||
adminPlaybackErrors: (resolved?: boolean) =>
|
adminPlaybackErrors: (resolved?: boolean) =>
|
||||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||||
scanStatus: () => ['scanStatus'] as const,
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
scanSchedule: () => ['scanSchedule'] as const,
|
|
||||||
coverage: () => ['coverage'] as const,
|
coverage: () => ['coverage'] as const,
|
||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
adminUsers: () => ['adminUsers'] as const,
|
adminUsers: () => ['adminUsers'] as const,
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
createAdminQuarantineQuery,
|
createAdminQuarantineQuery,
|
||||||
createScanStatusQuery,
|
createScanStatusQuery,
|
||||||
createCoverageQuery,
|
createCoverageQuery,
|
||||||
createScanScheduleQuery,
|
|
||||||
updateScanSchedule,
|
|
||||||
approveRequest,
|
approveRequest,
|
||||||
rejectRequest,
|
rejectRequest,
|
||||||
resolveQuarantine,
|
resolveQuarantine,
|
||||||
@@ -19,9 +17,7 @@
|
|||||||
deleteQuarantineViaLidarr,
|
deleteQuarantineViaLidarr,
|
||||||
refetchMissingCovers,
|
refetchMissingCovers,
|
||||||
triggerScan,
|
triggerScan,
|
||||||
researchMissingArt,
|
researchMissingArt
|
||||||
type ScanSchedule,
|
|
||||||
type ScanScheduleMode
|
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
@@ -211,89 +207,6 @@
|
|||||||
return new Date(iso).toLocaleString();
|
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) {
|
|
||||||
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
|
|
||||||
} 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 ----
|
// ---- Cover art bulk refetch ----
|
||||||
let bulkBusy = $state(false);
|
let bulkBusy = $state(false);
|
||||||
let bulkResult = $state<string | null>(null);
|
let bulkResult = $state<string | null>(null);
|
||||||
@@ -517,77 +430,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Cover art bulk refetch -->
|
||||||
<section class="rounded border border-border bg-surface p-4">
|
<section class="rounded border border-border bg-surface p-4">
|
||||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||||
|
|||||||
@@ -42,21 +42,7 @@ vi.mock('$lib/api/admin', async () => {
|
|||||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||||
triggerScan: vi.fn().mockResolvedValue({}),
|
triggerScan: vi.fn().mockResolvedValue({}),
|
||||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 })
|
||||||
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({})
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,14 +236,6 @@ 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('renders Re-search missing art button and invokes on click', async () => {
|
test('renders Re-search missing art button and invokes on click', async () => {
|
||||||
setupOverview();
|
setupOverview();
|
||||||
render(OverviewPage);
|
render(OverviewPage);
|
||||||
|
|||||||
Reference in New Issue
Block a user