mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 12:16:54 -05:00
Add persistent automatic scheduling toggle to admin panel
This commit is contained in:
@@ -392,3 +392,42 @@ describe('Worker routing', () => {
|
||||
expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps manual runs available with scheduling off and does not replay missed hours', async () => {
|
||||
network();
|
||||
|
||||
let scheduler = stub();
|
||||
await scheduler.ensureArmed();
|
||||
await scheduler.setAutomaticScheduling(false);
|
||||
|
||||
expect(await scheduler.ensureArmed()).toEqual({ armed: false, automaticSchedulingEnabled: false });
|
||||
expect(await scheduler.status()).toMatchObject({
|
||||
automaticSchedulingEnabled: false,
|
||||
alarmAt: null,
|
||||
hourlyAt: null,
|
||||
retryAt: null,
|
||||
});
|
||||
|
||||
let manual = await scheduler.startManual('data');
|
||||
expect(manual.ok).toBe(true);
|
||||
expect(await scheduler.setAutomaticScheduling(true)).toMatchObject({ busy: true });
|
||||
|
||||
let status = await runAlarmUntil(scheduler, s => s.lastManualRun !== null);
|
||||
expect(status.lastManualRun.ok).toBe(true);
|
||||
expect(status.lastRun).toBeNull();
|
||||
expect(status.alarmAt).toBeNull();
|
||||
expect(status.automaticSchedulingEnabled).toBe(false);
|
||||
|
||||
// A stale platform alarm must not run automatic work after disabling it.
|
||||
await runInDurableObject(scheduler, instance => instance.alarm());
|
||||
expect((await scheduler.status()).lastRun).toBeNull();
|
||||
|
||||
expect((await scheduler.run({ only: ['Schedules'] })).ok).toBe(true);
|
||||
expect((await scheduler.status()).alarmAt).toBeNull();
|
||||
|
||||
await scheduler.setAutomaticScheduling(true);
|
||||
status = await scheduler.status();
|
||||
expect(status.hourlyAt).toBe(nextRunAt());
|
||||
expect(status.alarmAt).toBe(status.hourlyAt);
|
||||
expect(status.lastRun).toBeNull();
|
||||
});
|
||||
|
||||
@@ -63,3 +63,27 @@ it.each(['/', '/admin/'])(
|
||||
expect(await response.text()).not.toContain('__NONCE__');
|
||||
},
|
||||
);
|
||||
|
||||
it('protects scheduling changes with Access, same-origin checks and boolean validation', async () => {
|
||||
const request = (enabled, origin = url) => new Request(url + '/admin/api/scheduling', {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const setAutomaticScheduling = vi.fn(async enabled => ({ ok: true, automaticSchedulingEnabled: enabled }));
|
||||
const scheduler = () => ({ setAutomaticScheduling });
|
||||
|
||||
verifyAccess.mockResolvedValue(null);
|
||||
expect((await adminRequest(request(false), {}, scheduler)).status).toBe(401);
|
||||
|
||||
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
|
||||
expect((await adminRequest(request(false, 'https://other.test'), {}, scheduler)).status).toBe(403);
|
||||
expect((await adminRequest(request('false'), {}, scheduler)).status).toBe(400);
|
||||
expect(setAutomaticScheduling).not.toHaveBeenCalled();
|
||||
|
||||
expect((await adminRequest(request(false), {}, scheduler)).status).toBe(200);
|
||||
expect(setAutomaticScheduling).toHaveBeenCalledWith(false);
|
||||
|
||||
setAutomaticScheduling.mockResolvedValue({ ok: false, busy: true });
|
||||
expect((await adminRequest(request(true), {}, scheduler)).status).toBe(409);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const state = {
|
||||
preview: true,
|
||||
user: { email: 'Local preview' },
|
||||
paused: false,
|
||||
automaticSchedulingEnabled: true,
|
||||
busy: false,
|
||||
hourlyAt: hour + 3600000 + 10000,
|
||||
retryAt: null,
|
||||
@@ -68,7 +69,7 @@ createServer(async (request, response) => {
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status')
|
||||
return json(state);
|
||||
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
if (request.method === 'POST' && ['/admin/api/run', '/admin/api/scheduling'].includes(url.pathname)) {
|
||||
if (request.headers.origin !== `http://${request.headers.host}`)
|
||||
return json({ error: 'Invalid origin.' }, 403);
|
||||
|
||||
@@ -84,7 +85,19 @@ createServer(async (request, response) => {
|
||||
return json({ error: 'Request too large.' }, 413);
|
||||
}
|
||||
|
||||
const { mode } = JSON.parse(body);
|
||||
const input = JSON.parse(body);
|
||||
|
||||
if (url.pathname === '/admin/api/scheduling') {
|
||||
if (typeof input?.enabled !== 'boolean')
|
||||
return json({ error: 'Enabled must be a boolean.' }, 400);
|
||||
|
||||
state.automaticSchedulingEnabled = input.enabled;
|
||||
state.hourlyAt = input.enabled ? Math.floor(Date.now() / 3600000) * 3600000 + 3610000 : null;
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
const { mode } = input;
|
||||
|
||||
if (!['data', 'social', 'both'].includes(mode))
|
||||
return json({ error: 'Unknown mode.' }, 400);
|
||||
|
||||
@@ -26,6 +26,8 @@ export class Scheduler extends DurableObject {
|
||||
// becomes one immediate full run, then the generic queue is retired.
|
||||
return {
|
||||
paused: saved.paused ?? false,
|
||||
automaticSchedulingEnabled: saved.automaticSchedulingEnabled
|
||||
?? (this.env.AUTOMATIC_SCHEDULING_ENABLED !== 'false'),
|
||||
hourlyAt: saved.hourlyAt ?? null,
|
||||
retryAt: saved.retryAt ?? (saved.pending?.length ? Date.now() : null),
|
||||
retries: saved.retries ?? 0,
|
||||
@@ -48,16 +50,70 @@ export class Scheduler extends DurableObject {
|
||||
|
||||
let armed = (await this.ctx.storage.getAlarm()) === null;
|
||||
|
||||
state.hourlyAt ??= nextRunAt();
|
||||
let manual = await this.#pendingManual();
|
||||
|
||||
if (!state.automaticSchedulingEnabled && !manual) {
|
||||
await this.ctx.storage.deleteAlarm();
|
||||
|
||||
return { armed: false, automaticSchedulingEnabled: false };
|
||||
}
|
||||
|
||||
if (state.automaticSchedulingEnabled)
|
||||
state.hourlyAt ??= nextRunAt();
|
||||
await this.ctx.storage.put('state', state);
|
||||
|
||||
let alarmAt = (await this.#pendingManual()) ? Date.now() : (state.retryAt ?? state.hourlyAt);
|
||||
let alarmAt = manual ? Date.now() : (state.retryAt ?? state.hourlyAt);
|
||||
|
||||
await this.ctx.storage.setAlarm(alarmAt);
|
||||
|
||||
return { armed, hourlyAt: state.hourlyAt, alarmAt };
|
||||
}
|
||||
|
||||
async setAutomaticScheduling(enabled) {
|
||||
if (typeof enabled !== 'boolean')
|
||||
return { ok: false, error: 'Enabled must be a boolean.' };
|
||||
|
||||
if (this.#running)
|
||||
return { ok: false, busy: true, error: 'Wait for the current run to finish.' };
|
||||
|
||||
this.#running = true;
|
||||
|
||||
try {
|
||||
if (await this.#pendingManual())
|
||||
return { ok: false, busy: true, error: 'Wait for the queued run to finish.' };
|
||||
|
||||
let state = await this.#state();
|
||||
|
||||
if (state.paused)
|
||||
return { ok: false, paused: true, error: 'The Worker is paused for maintenance.' };
|
||||
|
||||
if (state.automaticSchedulingEnabled !== enabled) {
|
||||
state.hourlyAt = enabled ? nextRunAt() : null;
|
||||
state.retryAt = null;
|
||||
state.retries = 0;
|
||||
}
|
||||
|
||||
state.automaticSchedulingEnabled = enabled;
|
||||
|
||||
if (enabled)
|
||||
state.hourlyAt ??= nextRunAt();
|
||||
|
||||
await this.ctx.storage.transaction(async txn => {
|
||||
await txn.put('state', state);
|
||||
|
||||
if (enabled) {
|
||||
await txn.setAlarm(state.retryAt ?? state.hourlyAt);
|
||||
} else {
|
||||
await txn.deleteAlarm();
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, automaticSchedulingEnabled: enabled };
|
||||
} finally {
|
||||
this.#running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async pause() {
|
||||
if (this.#running || (await this.#pendingManual()))
|
||||
return { ok: false, busy: true, error: 'Wait for the current run to finish before pausing.' };
|
||||
@@ -254,6 +310,12 @@ export class Scheduler extends DurableObject {
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.automaticSchedulingEnabled) {
|
||||
await this.ctx.storage.deleteAlarm();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
state.hourlyAt ??= nextRunAt();
|
||||
|
||||
let scheduledFor = state.retryAt ?? state.hourlyAt;
|
||||
|
||||
@@ -445,7 +445,10 @@
|
||||
</div>
|
||||
<div class="sub" id="state-detail">Checking the updater</div>
|
||||
</div>
|
||||
<div class="next"><small>Next scheduled run</small><strong id="next">—</strong></div>
|
||||
<div class="next">
|
||||
<small>Next scheduled run</small><strong id="next">—</strong>
|
||||
<button class="refresh" id="scheduling" role="switch" aria-checked="false" disabled>Automatic scheduling</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="section-label">
|
||||
<h2>Start a run</h2>
|
||||
@@ -526,6 +529,8 @@
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
element('scheduling').disabled = sending || !state || state.busy || state.paused;
|
||||
|
||||
for (const button of buttons)
|
||||
button.disabled = sending || !state || state.busy || state.paused;
|
||||
}
|
||||
@@ -638,6 +643,10 @@
|
||||
element('state').textContent = 'Scheduler paused';
|
||||
element('dot').className = 'dot warning';
|
||||
element('state-detail').textContent = 'Manual runs are disabled while paused.';
|
||||
} else if (!state.automaticSchedulingEnabled) {
|
||||
element('state').textContent = 'Automatic scheduling off';
|
||||
element('dot').className = 'dot neutral';
|
||||
element('state-detail').textContent = 'Manual runs are available.';
|
||||
} else {
|
||||
element('state').textContent = 'Ready when you are';
|
||||
element('dot').className = 'dot';
|
||||
@@ -650,7 +659,10 @@
|
||||
element('state-detail').textContent = `${name} · ${progress}`;
|
||||
}
|
||||
|
||||
element('next').textContent = state.paused ? 'Paused' : formatDate(state.retryAt || state.hourlyAt);
|
||||
element('next').textContent = state.paused ? 'Paused'
|
||||
: state.automaticSchedulingEnabled ? formatDate(state.retryAt || state.hourlyAt) : 'Off';
|
||||
element('scheduling').setAttribute('aria-checked', String(state.automaticSchedulingEnabled));
|
||||
element('scheduling').textContent = `Automatic scheduling: ${state.automaticSchedulingEnabled ? 'On' : 'Off'}`;
|
||||
element('live-run').hidden = !state.activeRun;
|
||||
|
||||
if (state.activeRun) {
|
||||
@@ -772,6 +784,29 @@
|
||||
}
|
||||
});
|
||||
|
||||
element('scheduling').addEventListener('click', async () => {
|
||||
const enabled = !state.automaticSchedulingEnabled;
|
||||
sending = true;
|
||||
updateControls();
|
||||
|
||||
try {
|
||||
await requestAdmin('scheduling', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
element('notice').textContent = enabled
|
||||
? 'Automatic scheduling enabled. The next run starts after the next hour.'
|
||||
: 'Automatic scheduling disabled. You can still run updates manually.';
|
||||
} catch (error) {
|
||||
element('notice').textContent = error.message;
|
||||
} finally {
|
||||
sending = false;
|
||||
await refreshStatus();
|
||||
updateControls();
|
||||
}
|
||||
});
|
||||
|
||||
element('refresh').addEventListener('click', refreshStatus);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden)
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function adminRequest(request, env, getScheduler) {
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status')
|
||||
return json({ ...(await getScheduler().status()), user, preview: false });
|
||||
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
if (request.method === 'POST' && ['/admin/api/run', '/admin/api/scheduling'].includes(url.pathname)) {
|
||||
// Access cookies authenticate the user; require a same-origin JSON request as well.
|
||||
if (
|
||||
request.headers.get('Origin') !== url.origin ||
|
||||
@@ -37,14 +37,25 @@ export async function adminRequest(request, env, getScheduler) {
|
||||
)
|
||||
return json({ error: 'A same-origin JSON request is required.' }, 403);
|
||||
|
||||
let mode;
|
||||
let body;
|
||||
|
||||
try {
|
||||
({ mode } = await request.json());
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid request.' }, 400);
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/api/scheduling') {
|
||||
if (typeof body?.enabled !== 'boolean')
|
||||
return json({ error: 'Enabled must be a boolean.' }, 400);
|
||||
|
||||
let result = await getScheduler().setAutomaticScheduling(body.enabled);
|
||||
|
||||
return json(result, result.ok ? 200 : result.busy || result.paused ? 409 : 400);
|
||||
}
|
||||
|
||||
let mode = body?.mode;
|
||||
|
||||
if (!MANUAL_MODES.includes(mode))
|
||||
return json({ error: 'Unknown run mode.' }, 400);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user