mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 12:16:54 -05:00
Make admin panel source easier to read
This commit is contained in:
@@ -4,19 +4,44 @@ import { generateKeyPair, SignJWT } from 'jose';
|
||||
import { verifyAccess } from '../../workers/updater/src/admin/access.mjs';
|
||||
|
||||
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
||||
const env = { ADMIN_HOSTNAME: 'admin.example.test', ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience' };
|
||||
async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, expires = '5m' } = {}) {
|
||||
return new SignJWT({ email: 'admin@example.test' }).setProtectedHeader({ alg: 'RS256' }).setSubject('user-id').setIssuer(issuer).setAudience(audience).setExpirationTime(expires).sign(privateKey);
|
||||
const env = {
|
||||
ADMIN_HOSTNAME: 'admin.example.test',
|
||||
ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com',
|
||||
ACCESS_AUD: 'admin-audience',
|
||||
};
|
||||
async function token({
|
||||
audience = env.ACCESS_AUD,
|
||||
issuer = 'https://' + env.ACCESS_TEAM_DOMAIN,
|
||||
expires = '5m',
|
||||
} = {}) {
|
||||
return new SignJWT({ email: 'admin@example.test' })
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setSubject('user-id')
|
||||
.setIssuer(issuer)
|
||||
.setAudience(audience)
|
||||
.setExpirationTime(expires)
|
||||
.sign(privateKey);
|
||||
}
|
||||
function request(jwt, host = env.ADMIN_HOSTNAME) {
|
||||
return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {} });
|
||||
return new Request(`https://${host}/admin/`, {
|
||||
headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {},
|
||||
});
|
||||
}
|
||||
|
||||
test('validates Access signature, issuer, audience, expiration and configured hostname', async () => {
|
||||
assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test' });
|
||||
for (const options of [{ audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }])
|
||||
assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), {
|
||||
email: 'admin@example.test',
|
||||
});
|
||||
for (const options of [
|
||||
{ audience: 'different-app' },
|
||||
{ issuer: 'https://other.cloudflareaccess.com' },
|
||||
{ expires: '0s' },
|
||||
])
|
||||
assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null);
|
||||
assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null);
|
||||
assert.equal(
|
||||
await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey),
|
||||
null,
|
||||
);
|
||||
const wrong = await generateKeyPair('RS256');
|
||||
assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null);
|
||||
});
|
||||
|
||||
@@ -14,15 +14,34 @@ test('bounds retained logs, redacts secrets, and preserves normal console output
|
||||
assert.equal(capture.snapshot.omitted, 6);
|
||||
assert.equal(capture.snapshot.lines.at(-1).text, '[redacted] Bearer [redacted]');
|
||||
assert.equal(consoleLog.mock.callCount(), 206);
|
||||
} finally { mock.restoreAll(); }
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
test('concurrent run contexts do not capture each other or unrelated messages', async () => {
|
||||
mock.method(console, 'info', () => {});
|
||||
try {
|
||||
const a = createRunLog(), b = createRunLog();
|
||||
await Promise.all([a.run(async () => { await Promise.resolve(); logMessage('info', 'a'); }), b.run(async () => { logMessage('info', 'b'); })]);
|
||||
const a = createRunLog(),
|
||||
b = createRunLog();
|
||||
await Promise.all([
|
||||
a.run(async () => {
|
||||
await Promise.resolve();
|
||||
logMessage('info', 'a');
|
||||
}),
|
||||
b.run(async () => {
|
||||
logMessage('info', 'b');
|
||||
}),
|
||||
]);
|
||||
logMessage('info', 'outside');
|
||||
assert.deepEqual(a.snapshot.lines.map(l => l.text), ['a']);
|
||||
assert.deepEqual(b.snapshot.lines.map(l => l.text), ['b']);
|
||||
} finally { mock.restoreAll(); }
|
||||
assert.deepEqual(
|
||||
a.snapshot.lines.map((l) => l.text),
|
||||
['a'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
b.snapshot.lines.map((l) => l.text),
|
||||
['b'],
|
||||
);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,12 +4,21 @@ import { verifyAccess } from './src/admin/access.mjs';
|
||||
vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() }));
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
const url = 'https://admin.example.test';
|
||||
const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) });
|
||||
const post = (mode = 'both', origin = url) =>
|
||||
new Request(url + '/admin/api/run', {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
|
||||
it('requires Access before exposing the panel, status or actions', async () => {
|
||||
verifyAccess.mockResolvedValue(null);
|
||||
const get = vi.fn();
|
||||
for (const request of [new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post()])
|
||||
for (const request of [
|
||||
new Request(url + '/admin/'),
|
||||
new Request(url + '/admin/api/status'),
|
||||
post(),
|
||||
])
|
||||
expect((await adminRequest(request, {}, get)).status).toBe(401);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -22,7 +31,10 @@ it('rejects cross-origin requests and unknown modes before scheduling work', asy
|
||||
});
|
||||
it('returns an accepted run immediately and reports overlap without starting another', async () => {
|
||||
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
|
||||
const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' } }));
|
||||
const startManual = vi.fn(async (mode) => ({
|
||||
ok: true,
|
||||
run: { id: 'one', mode, status: 'queued' },
|
||||
}));
|
||||
expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202);
|
||||
expect(startManual).toHaveBeenCalledWith('social');
|
||||
startManual.mockResolvedValue({ ok: false, busy: true });
|
||||
|
||||
@@ -6,45 +6,148 @@ import { randomUUID } from 'node:crypto';
|
||||
const port = Number(process.env.ADMIN_PREVIEW_PORT || 8788);
|
||||
const hour = Math.floor(Date.now() / 3600000) * 3600000;
|
||||
const state = {
|
||||
preview: true, user: { email: 'Local preview' }, paused: false, busy: false,
|
||||
hourlyAt: hour + 3600000 + 10000, retryAt: null, pendingManual: null,
|
||||
lastManualRun: { logs: { lines: [{ at: hour - 1800000, level: 'info', text: 'Starting social cycle' }, { at: hour - 1757700, level: 'error', text: 'Browser screenshot timed out. The post was not sent.' }], omitted: 0 }, id: 'previous', mode: 'social', ok: false, status: 'failed', startedAt: hour - 1800000, runMs: 42300, error: 'Browser screenshot timed out. The post was not sent.' },
|
||||
lastRun: { mode: 'both', ok: true, startedAt: hour + 10000, runMs: 18200, updaters: { ok: true }, social: { ok: true } },
|
||||
preview: true,
|
||||
user: { email: 'Local preview' },
|
||||
paused: false,
|
||||
busy: false,
|
||||
hourlyAt: hour + 3600000 + 10000,
|
||||
retryAt: null,
|
||||
pendingManual: null,
|
||||
lastManualRun: {
|
||||
logs: {
|
||||
lines: [
|
||||
{ at: hour - 1800000, level: 'info', text: 'Starting social cycle' },
|
||||
{
|
||||
at: hour - 1757700,
|
||||
level: 'error',
|
||||
text: 'Browser screenshot timed out. The post was not sent.',
|
||||
},
|
||||
],
|
||||
omitted: 0,
|
||||
},
|
||||
id: 'previous',
|
||||
mode: 'social',
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
startedAt: hour - 1800000,
|
||||
runMs: 42300,
|
||||
error: 'Browser screenshot timed out. The post was not sent.',
|
||||
},
|
||||
lastRun: {
|
||||
mode: 'both',
|
||||
ok: true,
|
||||
startedAt: hour + 10000,
|
||||
runMs: 18200,
|
||||
updaters: { ok: true },
|
||||
social: { ok: true },
|
||||
},
|
||||
};
|
||||
createServer(async (request, response) => {
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
const url = new URL(request.url, `http://127.0.0.1:${port}`);
|
||||
function json(body, status = 200) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }
|
||||
function json(body, status = 200) {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
try {
|
||||
if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
return response.end((await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll('__NONCE__', 'preview'));
|
||||
return response.end(
|
||||
(await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll(
|
||||
'__NONCE__',
|
||||
'preview',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state);
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403);
|
||||
if (request.headers.origin !== `http://${request.headers.host}`)
|
||||
return json({ error: 'Invalid origin.' }, 403);
|
||||
if (state.busy) return json({ error: 'A run is already active.' }, 409);
|
||||
let body = '';
|
||||
for await (const chunk of request) { body += chunk; if (body.length > 1024) return json({ error: 'Request too large.' }, 413); }
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
if (body.length > 1024) return json({ error: 'Request too large.' }, 413);
|
||||
}
|
||||
const { mode } = JSON.parse(body);
|
||||
if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400);
|
||||
const run = { id: randomUUID(), mode, status: 'queued', requestedAt: Date.now() };
|
||||
state.busy = true; state.pendingManual = run;
|
||||
const logs = { lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }], omitted: 0 };
|
||||
state.busy = true;
|
||||
state.pendingManual = run;
|
||||
const logs = {
|
||||
lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }],
|
||||
omitted: 0,
|
||||
};
|
||||
state.activeRun = { mode, startedAt: Date.now(), logs };
|
||||
setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Checking post checkpoints…' : '[Updater] [Schedules] Updating data…' }), 1500);
|
||||
setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Preparing a due Bluesky post…' : '[Updater] [Schedules] Done.' }), 3200);
|
||||
setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: '[Preview] Simulated work completed.' }), 5000);
|
||||
setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700);
|
||||
setTimeout(
|
||||
() =>
|
||||
logs.lines.push({
|
||||
at: Date.now(),
|
||||
level: 'info',
|
||||
text:
|
||||
mode === 'social'
|
||||
? '[Social] Checking post checkpoints…'
|
||||
: '[Updater] [Schedules] Updating data…',
|
||||
}),
|
||||
1500,
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
logs.lines.push({
|
||||
at: Date.now(),
|
||||
level: 'info',
|
||||
text:
|
||||
mode === 'social'
|
||||
? '[Social] Preparing a due Bluesky post…'
|
||||
: '[Updater] [Schedules] Done.',
|
||||
}),
|
||||
3200,
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
logs.lines.push({
|
||||
at: Date.now(),
|
||||
level: 'info',
|
||||
text: '[Preview] Simulated work completed.',
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
setTimeout(() => {
|
||||
state.lastManualRun = { ...run, logs, ok: true, status: 'succeeded', finishedAt: Date.now(), runMs: Date.now() - run.startedAt,
|
||||
updaters: mode === 'social' ? { ok: true, skipped: true } : { ok: true, updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(name => ({ name, ok: true })) },
|
||||
social: mode === 'data' ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] },
|
||||
run.status = 'running';
|
||||
run.startedAt = Date.now();
|
||||
}, 700);
|
||||
setTimeout(() => {
|
||||
state.lastManualRun = {
|
||||
...run,
|
||||
logs,
|
||||
ok: true,
|
||||
status: 'succeeded',
|
||||
finishedAt: Date.now(),
|
||||
runMs: Date.now() - run.startedAt,
|
||||
updaters:
|
||||
mode === 'social'
|
||||
? { ok: true, skipped: true }
|
||||
: {
|
||||
ok: true,
|
||||
updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(
|
||||
(name) => ({ name, ok: true }),
|
||||
),
|
||||
},
|
||||
social:
|
||||
mode === 'data'
|
||||
? { ok: true, skipped: true }
|
||||
: { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] },
|
||||
};
|
||||
state.busy = false; state.pendingManual = null; state.activeRun = null;
|
||||
state.busy = false;
|
||||
state.pendingManual = null;
|
||||
state.activeRun = null;
|
||||
}, 6000);
|
||||
return json({ ok: true, run }, 202);
|
||||
}
|
||||
json({ error: 'Not found.' }, 404);
|
||||
} catch { json({ error: 'Invalid preview request.' }, 400); }
|
||||
}).listen(port, '127.0.0.1', () => console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`));
|
||||
} catch {
|
||||
json({ error: 'Invalid preview request.' }, 400);
|
||||
}
|
||||
}).listen(port, '127.0.0.1', () =>
|
||||
console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`),
|
||||
);
|
||||
|
||||
@@ -7,19 +7,23 @@ export async function verifyAccess(request, env, resolveKey) {
|
||||
if (!domain || !env.ACCESS_AUD || new URL(request.url).hostname !== env.ADMIN_HOSTNAME)
|
||||
return null;
|
||||
let token = request.headers.get('Cf-Access-Jwt-Assertion');
|
||||
if (!token)
|
||||
return null;
|
||||
if (!token) return null;
|
||||
try {
|
||||
let issuer = new URL(`https://${domain}`);
|
||||
if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com'))
|
||||
return null;
|
||||
if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com')) return null;
|
||||
if (!resolveKey) {
|
||||
if (!keySets.has(domain))
|
||||
keySets.set(domain, createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 }));
|
||||
keySets.set(
|
||||
domain,
|
||||
createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 }),
|
||||
);
|
||||
resolveKey = keySets.get(domain);
|
||||
}
|
||||
let { payload } = await jwtVerify(token, resolveKey, {
|
||||
issuer: issuer.origin, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub'],
|
||||
issuer: issuer.origin,
|
||||
audience: env.ACCESS_AUD,
|
||||
algorithms: ['RS256'],
|
||||
requiredClaims: ['exp', 'sub'],
|
||||
});
|
||||
return { email: payload.email ?? 'Authenticated administrator' };
|
||||
} catch {
|
||||
|
||||
@@ -1,121 +1,742 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Splatoon2.ink · Administration</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#f3f2fa;background:#101116;font-synthesis:none;--muted:#aaaaba;--line:#30313d;--purple:#b1a0ff;--green:#91ddb6}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 85% 0%,#242037 0,transparent 48%);min-height:100vh}button,a{-webkit-tap-highlight-color:transparent}button{font:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:var(--purple)}button:focus-visible,a:focus-visible{outline:3px solid var(--purple);outline-offset:4px}.shell{width:min(920px,100% - 48px);margin:auto;padding:38px 0 30px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:30px;border-bottom:1px solid var(--line)}.top{margin-bottom:26px}.brand{font-weight:750;font-size:18px;letter-spacing:-.5px}.brand span{color:var(--purple)}.tag{font-size:11px;letter-spacing:1.4px;text-transform:uppercase;color:var(--muted);padding:6px 10px;border:1px solid var(--line);border-radius:5px}p{color:var(--muted);font-size:14px;line-height:1.65;margin:0}.preview{display:none;padding:12px 16px;border:1px solid #54476b;background:#251f32;border-radius:9px;color:#d0bee9;margin-bottom:20px;font-size:13px}.status{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:20px 23px;border:1px solid var(--line);border-radius:12px;background:#1b1c25;margin-bottom:30px}.state-line{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:650}.dot{height:8px;width:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px #91ddb612}.dot.busy{background:var(--purple)}.dot.warning{background:#edb685}.sub{font-size:12px;color:var(--muted);margin-top:7px}.next{text-align:right}.next strong{font-size:14px;font-weight:500}.next small{display:block;color:var(--muted);font-size:11px;margin-bottom:7px}.section-label{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}h2{font-size:15px;margin:0;font-weight:650}.hint{font-size:12px;color:var(--muted)}.actions{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:17px}.action{display:flex;flex-direction:column;background:#191a23;border:1px solid var(--line);border-radius:12px;padding:23px 21px}.icon{width:38px;height:38px;display:grid;place-items:center;border-radius:10px;background:#2c2640;color:var(--purple);margin-bottom:22px;font-size:19px}.action:nth-child(2) .icon{background:#20352e;color:#91ddb6}.action:nth-child(3) .icon{background:#333024;color:#e6d595}h3{font-size:16px;margin:0 0 9px;font-weight:650}.action p{font-size:13px;line-height:1.65;flex:1;margin-bottom:24px}.run{width:100%;border:1px solid #454050;border-radius:7px;background:#272431;color:#e6defc;padding:11px 8px;font-size:13px;font-weight:650;min-height:44px}.run:hover:not(:disabled){background:#373046;border-color:#8c75b7}.action:first-child .run{background:#b6a4fa;color:#191226;border-color:#b6a4fa}.action:first-child .run:hover:not(:disabled){background:#cbbbff}.note{font-size:12px;color:var(--muted);margin-bottom:32px;display:flex;gap:8px}.notice{border-radius:8px;background:#272333;border:1px solid #615277;padding:13px 16px;font-size:13px;margin:0 0 20px;line-height:1.6}.notice:empty{display:none}.history{border:1px solid var(--line);border-radius:12px;overflow:hidden}.record{padding:20px 23px;background:#191a23}.record+.record{border-top:1px solid var(--line)}.record-top{display:flex;justify-content:space-between;align-items:center;gap:16px}.record-name{font-weight:550;font-size:14px}.badge{font-size:11px;padding:4px 8px;border-radius:5px;color:var(--green);background:#20352b;white-space:nowrap}.badge.failed{color:#edbd9b;background:#392b27}.badge.neutral{color:var(--muted);background:#292a35}.detail{font-size:12px;color:var(--muted);line-height:1.6;margin-top:8px}.error{color:#edbd9b;margin-top:8px;font-size:12px;overflow-wrap:anywhere}details{margin-top:12px;font-size:12px;color:var(--muted)}summary{cursor:pointer;min-height:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:12px;background:#111218;border-radius:7px;font-size:11px;line-height:1.6}.footer{display:flex;justify-content:space-between;gap:16px;color:#898a9d;font-size:11px;padding-top:26px;line-height:1.7}.refresh{background:transparent;border:0;color:var(--purple);font-size:12px;padding:8px;min-height:36px}#sign-in{display:none;margin-left:8px}.identity{overflow-wrap:anywhere}
|
||||
@media(max-width:640px){.shell{width:calc(100% - 32px);padding-top:22px}.top{padding-bottom:22px}.actions{grid-template-columns:1fr}.action{padding:20px;display:grid;grid-template-columns:40px 1fr;column-gap:16px}.icon{grid-row:1/3;margin:0}.action p{margin:0 0 16px}.run{grid-column:2}.status{padding:18px 17px;align-items:flex-start}.next strong{font-size:12px}.sub{max-width:180px;line-height:1.5}.record{padding:18px 17px}.footer{flex-direction:column;gap:5px}.hint{font-size:11px}.tag{font-size:10px}h3{margin-top:2px}.note{line-height:1.6}}
|
||||
.log-view{max-height:300px;overflow:auto;background:#111218;border:1px solid var(--line);border-radius:8px;padding:12px;font:11px/1.8 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.log-line.error{color:#edbd9b;margin:0;font:inherit}.log-line.warn{color:#e6d595}.log-time{color:#898a9d}.log-note{color:var(--muted);font-size:11px;margin:8px 0}#live-run{margin-bottom:24px}#live-run[hidden]{display:none}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="top"><div class="brand">splatoon<span>2</span>.ink</div><span class="tag">Administration</span></header>
|
||||
<div class="preview" id="preview">Local preview · All runs are simulated. No production data or social accounts are touched.</div>
|
||||
<section class="status" aria-label="Scheduler status"><div><div class="state-line"><span class="dot" id="dot"></span><span id="state">Connecting…</span></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></section>
|
||||
<div class="section-label"><h2>Start a run</h2><span class="hint">One run at a time</span></div>
|
||||
<section class="actions" aria-label="Manual actions">
|
||||
<article class="action"><div class="icon" aria-hidden="true">↻</div><h3>Update game data</h3><p>Refresh schedules, gear, and the rest of the SplatNet data.</p><button class="run" data-mode="data" disabled>Update data <span aria-hidden="true">→</span></button></article>
|
||||
<article class="action"><div class="icon" aria-hidden="true">↗</div><h3>Catch up on social</h3><p>Send any due Bluesky posts using the latest published data.</p><button class="run" data-mode="social" disabled>Run social cycle <span aria-hidden="true">→</span></button></article>
|
||||
<article class="action"><div class="icon" aria-hidden="true">⇄</div><h3>Run the full cycle</h3><p>Update game data, then send any social posts that are due.</p><button class="run" data-mode="both" disabled>Update & post <span aria-hidden="true">→</span></button></article>
|
||||
</section>
|
||||
<p class="note"><span aria-hidden="true">✓</span> Successful posts are remembered. Retrying a cycle skips posts already sent.</p>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite"></div><a id="sign-in" href="/admin/">Sign in again</a>
|
||||
<section id="live-run" hidden><div class="section-label"><h2>Live run log</h2><span class="hint">Updates every 2 seconds</span></div><div id="live-log"></div></section>
|
||||
<section><div class="section-label"><h2>Recent activity</h2><button class="refresh" id="refresh">Refresh status ↻</button></div><div class="history" id="history"><div class="record"><p>Loading recent runs…</p></div></div></section>
|
||||
<footer class="footer"><div class="identity" id="identity">Protected by Cloudflare Access</div><div id="checked">Times shown in your local timezone.</div></footer>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
const names = { data: 'Data update', social: 'Social cycle', both: 'Full cycle' };
|
||||
const buttons = [...document.querySelectorAll('[data-mode]')];
|
||||
const $ = id => document.getElementById(id);
|
||||
let state, sending = false, polling = false, timer, lastRunId;
|
||||
function date(value) { return value ? new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; }
|
||||
function controls() { for (const button of buttons) button.disabled = sending || !state || state.busy || state.paused; }
|
||||
function logView(logs) {
|
||||
const box = document.createElement('div'); box.className = 'log-view';
|
||||
if (!logs?.lines?.length) { box.textContent = 'No log lines recorded for this run.'; return box; }
|
||||
if (logs.omitted) { const note = document.createElement('div'); note.className = 'log-note'; note.textContent = `${logs.omitted} earlier lines omitted. Showing the most recent log lines.`; box.append(note); }
|
||||
for (const line of logs.lines) {
|
||||
const row = document.createElement('div'); row.className = 'log-line ' + line.level;
|
||||
const time = document.createElement('span'); time.className = 'log-time'; time.textContent = new Date(line.at).toLocaleTimeString() + ' ';
|
||||
row.append(time, document.createTextNode(line.text)); box.append(row);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
function record(label, run) {
|
||||
const row = document.createElement('div'); row.className = 'record';
|
||||
const top = document.createElement('div'); top.className = 'record-top';
|
||||
const title = document.createElement('span'); title.className = 'record-name'; title.textContent = label;
|
||||
const badge = document.createElement('span'); badge.className = 'badge' + (!run ? ' neutral' : run.ok ? '' : ' failed'); badge.textContent = !run ? 'No runs yet' : run.ok ? 'Completed' : 'Needs attention';
|
||||
top.append(title, badge); row.append(top);
|
||||
const detail = document.createElement('div'); detail.className = 'detail';
|
||||
detail.textContent = run ? `${names[run.mode] || 'Full cycle'} · ${date(run.startedAt)} · ${Math.max(1, Math.round((run.runMs || 0) / 1000))}s` : 'Results will appear here after a run.'; row.append(detail);
|
||||
if (run) {
|
||||
const messages = [run.error, ...(run.updaters?.updaters || []).filter(item => !item.ok).map(item => item.error || `${item.name} failed`), ...(run.social?.posts || []).filter(item => item && !item.ok).map(item => item.error || 'A social post failed')].filter(Boolean);
|
||||
if (messages.length) { const error = document.createElement('div'); error.className = 'error'; error.textContent = messages.join(' · '); row.append(error); }
|
||||
const logDetails = document.createElement('details'), logSummary = document.createElement('summary'); logSummary.textContent = 'View log lines'; logDetails.append(logSummary, logView(run.logs)); row.append(logDetails);
|
||||
const details = document.createElement('details'), summary = document.createElement('summary'), pre = document.createElement('pre'); summary.textContent = 'View run details'; const { logs, ...summaryResult } = run; pre.textContent = JSON.stringify(summaryResult, null, 2); details.append(summary, pre); row.append(details);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
function render() {
|
||||
$('preview').style.display = state.preview ? 'block' : 'none';
|
||||
$('state').textContent = state.busy ? 'Run in progress' : state.paused ? 'Scheduler paused' : 'Ready when you are';
|
||||
$('dot').className = 'dot' + (state.busy ? ' busy' : state.paused ? ' warning' : '');
|
||||
$('state-detail').textContent = state.pendingManual ? `${names[state.pendingManual.mode]} · ${state.pendingManual.status === 'queued' ? 'waiting to start' : 'running'}` : state.busy ? 'A scheduled or API run is active.' : state.paused ? 'Manual runs are disabled while paused.' : 'The hourly schedule is running automatically.';
|
||||
$('next').textContent = state.paused ? 'Paused' : date(state.retryAt || state.hourlyAt);
|
||||
$('live-run').hidden = !state.activeRun;
|
||||
if (state.activeRun) {
|
||||
const old = $('live-log').firstElementChild;
|
||||
const follow = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 30;
|
||||
const position = old?.scrollTop || 0;
|
||||
const box = logView(state.activeRun.logs); $('live-log').replaceChildren(box);
|
||||
box.scrollTop = follow ? box.scrollHeight : position;
|
||||
}
|
||||
// Keep expanded details open during polling when the records have not changed.
|
||||
const signature = JSON.stringify([state.lastManualRun, state.lastRun]);
|
||||
if ($('history').dataset.signature !== signature) {
|
||||
$('history').replaceChildren(record('Last manual run', state.lastManualRun), record('Last scheduled run', state.lastRun));
|
||||
$('history').dataset.signature = signature;
|
||||
}
|
||||
if (lastRunId && state.lastManualRun?.id === lastRunId) { $('notice').textContent = state.lastManualRun.ok ? 'Your run finished successfully.' : 'Your run needs attention. See the result below before retrying.'; lastRunId = null; }
|
||||
$('identity').textContent = state.preview ? 'Preview session · Simulated results' : `Signed in as ${state.user.email}`;
|
||||
$('checked').textContent = `Checked ${new Date().toLocaleTimeString([], {hour: 'numeric', minute: '2-digit', second: '2-digit'})} · Local time`;
|
||||
controls();
|
||||
}
|
||||
async function api(path, options) {
|
||||
const response = await fetch('/admin/api/' + path, { ...options, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
if (response.status === 401 || response.status === 403) { $('sign-in').style.display = 'inline-block'; throw new Error('Your session may have expired. Sign in again to continue.'); }
|
||||
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Could not read status. Reload to sign in again.');
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'The request failed.');
|
||||
return result;
|
||||
}
|
||||
async function poll() {
|
||||
clearTimeout(timer);
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
try { state = await api('status'); render(); }
|
||||
catch (error) { state = null; controls(); $('state').textContent = 'Connection unavailable'; $('state-detail').textContent = 'Reconnect before starting a run.'; $('dot').className = 'dot warning'; $('notice').textContent = error.message; }
|
||||
finally { polling = false; if (!document.hidden) timer = setTimeout(poll, state?.busy ? 2000 : 15000); }
|
||||
}
|
||||
for (const button of buttons) button.addEventListener('click', async () => {
|
||||
sending = true; controls(); $('notice').textContent = 'Starting your run…';
|
||||
try {
|
||||
const result = await api('run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: button.dataset.mode }) });
|
||||
lastRunId = result.run.id;
|
||||
if (state) { state.busy = true; state.pendingManual = result.run; render(); }
|
||||
$('notice').textContent = 'Run accepted. You can close this page and check back later.';
|
||||
} catch (error) { $('notice').textContent = `${error.message} Check status before trying again; the run may already have started.`; }
|
||||
finally { sending = false; await poll(); controls(); }
|
||||
});
|
||||
$('refresh').addEventListener('click', poll);
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) clearTimeout(timer); else poll(); });
|
||||
poll();
|
||||
</script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Splatoon2.ink · Administration</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
color: #f3f2fa;
|
||||
background: #101116;
|
||||
font-synthesis: none;
|
||||
--muted: #aaaaba;
|
||||
--line: #30313d;
|
||||
--purple: #b1a0ff;
|
||||
--green: #91ddb6;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(ellipse at 85% 0%, #242037 0, transparent 48%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
a {
|
||||
color: var(--purple);
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 3px solid var(--purple);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.shell {
|
||||
width: min(920px, 100% - 48px);
|
||||
margin: auto;
|
||||
padding: 38px 0 30px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.top {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 750;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.brand span {
|
||||
color: var(--purple);
|
||||
}
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
.preview {
|
||||
display: none;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #54476b;
|
||||
background: #251f32;
|
||||
border-radius: 9px;
|
||||
color: #d0bee9;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px 23px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #1b1c25;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.state-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 4px #91ddb612;
|
||||
}
|
||||
.dot.busy {
|
||||
background: var(--purple);
|
||||
}
|
||||
.dot.warning {
|
||||
background: #edb685;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 7px;
|
||||
}
|
||||
.next {
|
||||
text-align: right;
|
||||
}
|
||||
.next strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.next small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.section-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
font-weight: 650;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
.action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #191a23;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 23px 21px;
|
||||
}
|
||||
.icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: #2c2640;
|
||||
color: var(--purple);
|
||||
margin-bottom: 22px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.action:nth-child(2) .icon {
|
||||
background: #20352e;
|
||||
color: #91ddb6;
|
||||
}
|
||||
.action:nth-child(3) .icon {
|
||||
background: #333024;
|
||||
color: #e6d595;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.action p {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.run {
|
||||
width: 100%;
|
||||
border: 1px solid #454050;
|
||||
border-radius: 7px;
|
||||
background: #272431;
|
||||
color: #e6defc;
|
||||
padding: 11px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
min-height: 44px;
|
||||
}
|
||||
.run:hover:not(:disabled) {
|
||||
background: #373046;
|
||||
border-color: #8c75b7;
|
||||
}
|
||||
.action:first-child .run {
|
||||
background: #b6a4fa;
|
||||
color: #191226;
|
||||
border-color: #b6a4fa;
|
||||
}
|
||||
.action:first-child .run:hover:not(:disabled) {
|
||||
background: #cbbbff;
|
||||
}
|
||||
.note {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.notice {
|
||||
border-radius: 8px;
|
||||
background: #272333;
|
||||
border: 1px solid #615277;
|
||||
padding: 13px 16px;
|
||||
font-size: 13px;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notice:empty {
|
||||
display: none;
|
||||
}
|
||||
.history {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.record {
|
||||
padding: 20px 23px;
|
||||
background: #191a23;
|
||||
}
|
||||
.record + .record {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.record-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.record-name {
|
||||
font-weight: 550;
|
||||
font-size: 14px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
color: var(--green);
|
||||
background: #20352b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.failed {
|
||||
color: #edbd9b;
|
||||
background: #392b27;
|
||||
}
|
||||
.badge.neutral {
|
||||
color: var(--muted);
|
||||
background: #292a35;
|
||||
}
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.error {
|
||||
color: #edbd9b;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
details {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
min-height: 24px;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
background: #111218;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: #898a9d;
|
||||
font-size: 11px;
|
||||
padding-top: 26px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.refresh {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--purple);
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
#sign-in {
|
||||
display: none;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.identity {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
width: calc(100% - 32px);
|
||||
padding-top: 22px;
|
||||
}
|
||||
.top {
|
||||
padding-bottom: 22px;
|
||||
}
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.action {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
column-gap: 16px;
|
||||
}
|
||||
.icon {
|
||||
grid-row: 1/3;
|
||||
margin: 0;
|
||||
}
|
||||
.action p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.run {
|
||||
grid-column: 2;
|
||||
}
|
||||
.status {
|
||||
padding: 18px 17px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.next strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sub {
|
||||
max-width: 180px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.record {
|
||||
padding: 18px 17px;
|
||||
}
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
h3 {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.note {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
.log-view {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
background: #111218;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font: 11px/1.8 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.log-line.error {
|
||||
color: #edbd9b;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.log-line.warn {
|
||||
color: #e6d595;
|
||||
}
|
||||
.log-time {
|
||||
color: #898a9d;
|
||||
}
|
||||
.log-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
#live-run {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
#live-run[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="top">
|
||||
<div class="brand">splatoon<span>2</span>.ink</div>
|
||||
<span class="tag">Administration</span>
|
||||
</header>
|
||||
<div class="preview" id="preview">
|
||||
Local preview · All runs are simulated. No production data or social accounts are touched.
|
||||
</div>
|
||||
<section class="status" aria-label="Scheduler status">
|
||||
<div>
|
||||
<div class="state-line">
|
||||
<span class="dot" id="dot"></span><span id="state">Connecting…</span>
|
||||
</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>
|
||||
</section>
|
||||
<div class="section-label">
|
||||
<h2>Start a run</h2>
|
||||
<span class="hint">One run at a time</span>
|
||||
</div>
|
||||
<section class="actions" aria-label="Manual actions">
|
||||
<article class="action">
|
||||
<div class="icon" aria-hidden="true">↻</div>
|
||||
<h3>Update game data</h3>
|
||||
<p>Refresh schedules, gear, and the rest of the SplatNet data.</p>
|
||||
<button class="run" data-mode="data" disabled>
|
||||
Update data <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</article>
|
||||
<article class="action">
|
||||
<div class="icon" aria-hidden="true">↗</div>
|
||||
<h3>Catch up on social</h3>
|
||||
<p>Send any due Bluesky posts using the latest published data.</p>
|
||||
<button class="run" data-mode="social" disabled>
|
||||
Run social cycle <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</article>
|
||||
<article class="action">
|
||||
<div class="icon" aria-hidden="true">⇄</div>
|
||||
<h3>Run the full cycle</h3>
|
||||
<p>Update game data, then send any social posts that are due.</p>
|
||||
<button class="run" data-mode="both" disabled>
|
||||
Update & post <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<p class="note">
|
||||
<span aria-hidden="true">✓</span> Successful posts are remembered. Retrying a cycle skips
|
||||
posts already sent.
|
||||
</p>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite"></div>
|
||||
<a id="sign-in" href="/admin/">Sign in again</a>
|
||||
<section id="live-run" hidden>
|
||||
<div class="section-label">
|
||||
<h2>Live run log</h2>
|
||||
<span class="hint">Updates every 2 seconds</span>
|
||||
</div>
|
||||
<div id="live-log"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<h2>Recent activity</h2>
|
||||
<button class="refresh" id="refresh">Refresh status ↻</button>
|
||||
</div>
|
||||
<div class="history" id="history">
|
||||
<div class="record"><p>Loading recent runs…</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="footer">
|
||||
<div class="identity" id="identity">Protected by Cloudflare Access</div>
|
||||
<div id="checked">Times shown in your local timezone.</div>
|
||||
</footer>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
const runNames = { data: 'Data update', social: 'Social cycle', both: 'Full cycle' };
|
||||
const buttons = [...document.querySelectorAll('[data-mode]')];
|
||||
const element = (id) => document.getElementById(id);
|
||||
let state;
|
||||
let sending = false;
|
||||
let polling = false;
|
||||
let timer;
|
||||
let lastRunId;
|
||||
|
||||
function formatDate(value) {
|
||||
return value
|
||||
? new Date(value).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '—';
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
for (const button of buttons)
|
||||
button.disabled = sending || !state || state.busy || state.paused;
|
||||
}
|
||||
|
||||
function renderLogLines(logs) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'log-view';
|
||||
if (!logs?.lines?.length) {
|
||||
box.textContent = 'No log lines recorded for this run.';
|
||||
return box;
|
||||
}
|
||||
if (logs.omitted) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'log-note';
|
||||
note.textContent = `${logs.omitted} earlier lines omitted. Showing the most recent log lines.`;
|
||||
box.append(note);
|
||||
}
|
||||
for (const line of logs.lines) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'log-line ' + line.level;
|
||||
const time = document.createElement('span');
|
||||
time.className = 'log-time';
|
||||
time.textContent = new Date(line.at).toLocaleTimeString() + ' ';
|
||||
row.append(time, document.createTextNode(line.text));
|
||||
box.append(row);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderRunRecord(label, run) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'record';
|
||||
const top = document.createElement('div');
|
||||
top.className = 'record-top';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'record-name';
|
||||
title.textContent = label;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge' + (!run ? ' neutral' : run.ok ? '' : ' failed');
|
||||
badge.textContent = !run ? 'No runs yet' : run.ok ? 'Completed' : 'Needs attention';
|
||||
top.append(title, badge);
|
||||
row.append(top);
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'detail';
|
||||
detail.textContent = run
|
||||
? `${runNames[run.mode] || 'Full cycle'} · ${formatDate(run.startedAt)} · ${Math.max(
|
||||
1,
|
||||
Math.round((run.runMs || 0) / 1000),
|
||||
)}s`
|
||||
: 'Results will appear here after a run.';
|
||||
row.append(detail);
|
||||
if (run) {
|
||||
const messages = [
|
||||
run.error,
|
||||
...(run.updaters?.updaters || [])
|
||||
.filter((item) => !item.ok)
|
||||
.map((item) => item.error || `${item.name} failed`),
|
||||
...(run.social?.posts || [])
|
||||
.filter((item) => item && !item.ok)
|
||||
.map((item) => item.error || 'A social post failed'),
|
||||
].filter(Boolean);
|
||||
if (messages.length) {
|
||||
const error = document.createElement('div');
|
||||
error.className = 'error';
|
||||
error.textContent = messages.join(' · ');
|
||||
row.append(error);
|
||||
}
|
||||
const logDetails = document.createElement('details');
|
||||
const logSummary = document.createElement('summary');
|
||||
logSummary.textContent = 'View log lines';
|
||||
logDetails.append(logSummary, renderLogLines(run.logs));
|
||||
row.append(logDetails);
|
||||
const details = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
const pre = document.createElement('pre');
|
||||
summary.textContent = 'View run details';
|
||||
const summaryResult = { ...run };
|
||||
delete summaryResult.logs;
|
||||
pre.textContent = JSON.stringify(summaryResult, null, 2);
|
||||
details.append(summary, pre);
|
||||
row.append(details);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function render() {
|
||||
element('preview').style.display = state.preview ? 'block' : 'none';
|
||||
if (state.busy) {
|
||||
element('state').textContent = 'Run in progress';
|
||||
element('dot').className = 'dot busy';
|
||||
element('state-detail').textContent = 'A scheduled or API run is active.';
|
||||
} else if (state.paused) {
|
||||
element('state').textContent = 'Scheduler paused';
|
||||
element('dot').className = 'dot warning';
|
||||
element('state-detail').textContent = 'Manual runs are disabled while paused.';
|
||||
} else {
|
||||
element('state').textContent = 'Ready when you are';
|
||||
element('dot').className = 'dot';
|
||||
element('state-detail').textContent = 'The hourly schedule is running automatically.';
|
||||
}
|
||||
if (state.pendingManual) {
|
||||
const name = runNames[state.pendingManual.mode];
|
||||
const progress = state.pendingManual.status === 'queued' ? 'waiting to start' : 'running';
|
||||
element('state-detail').textContent = `${name} · ${progress}`;
|
||||
}
|
||||
element('next').textContent = state.paused ? 'Paused' : formatDate(state.retryAt || state.hourlyAt);
|
||||
element('live-run').hidden = !state.activeRun;
|
||||
if (state.activeRun) {
|
||||
const old = element('live-log').firstElementChild;
|
||||
const follow = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 30;
|
||||
const position = old?.scrollTop || 0;
|
||||
const box = renderLogLines(state.activeRun.logs);
|
||||
element('live-log').replaceChildren(box);
|
||||
box.scrollTop = follow ? box.scrollHeight : position;
|
||||
}
|
||||
// Keep expanded details open during polling when the records have not changed.
|
||||
const signature = JSON.stringify([state.lastManualRun, state.lastRun]);
|
||||
if (element('history').dataset.signature !== signature) {
|
||||
element('history').replaceChildren(
|
||||
renderRunRecord('Last manual run', state.lastManualRun),
|
||||
renderRunRecord('Last scheduled run', state.lastRun),
|
||||
);
|
||||
element('history').dataset.signature = signature;
|
||||
}
|
||||
if (lastRunId && state.lastManualRun?.id === lastRunId) {
|
||||
element('notice').textContent = state.lastManualRun.ok
|
||||
? 'Your run finished successfully.'
|
||||
: 'Your run needs attention. See the result below before retrying.';
|
||||
lastRunId = null;
|
||||
}
|
||||
element('identity').textContent = state.preview
|
||||
? 'Preview session · Simulated results'
|
||||
: `Signed in as ${state.user.email}`;
|
||||
element('checked').textContent = `Checked ${new Date().toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})} · Local time`;
|
||||
updateControls();
|
||||
}
|
||||
|
||||
async function requestAdmin(path, options) {
|
||||
const response = await fetch('/admin/api/' + path, {
|
||||
...options,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
element('sign-in').style.display = 'inline-block';
|
||||
throw new Error('Your session may have expired. Sign in again to continue.');
|
||||
}
|
||||
if (!response.headers.get('content-type')?.includes('application/json'))
|
||||
throw new Error('Could not read status. Reload to sign in again.');
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'The request failed.');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
clearTimeout(timer);
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
state = await requestAdmin('status');
|
||||
render();
|
||||
} catch (error) {
|
||||
state = null;
|
||||
updateControls();
|
||||
element('state').textContent = 'Connection unavailable';
|
||||
element('state-detail').textContent = 'Reconnect before starting a run.';
|
||||
element('dot').className = 'dot warning';
|
||||
element('notice').textContent = error.message;
|
||||
} finally {
|
||||
polling = false;
|
||||
if (!document.hidden) timer = setTimeout(refreshStatus, state?.busy ? 2000 : 15000);
|
||||
}
|
||||
}
|
||||
for (const button of buttons)
|
||||
button.addEventListener('click', async () => {
|
||||
sending = true;
|
||||
updateControls();
|
||||
element('notice').textContent = 'Starting your run…';
|
||||
try {
|
||||
const result = await requestAdmin('run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: button.dataset.mode }),
|
||||
});
|
||||
lastRunId = result.run.id;
|
||||
if (state) {
|
||||
state.busy = true;
|
||||
state.pendingManual = result.run;
|
||||
render();
|
||||
}
|
||||
element('notice').textContent = 'Run accepted. You can close this page and check back later.';
|
||||
} catch (error) {
|
||||
element(
|
||||
'notice',
|
||||
).textContent = `${error.message} Check status before trying again; the run may already have started.`;
|
||||
} finally {
|
||||
sending = false;
|
||||
await refreshStatus();
|
||||
updateControls();
|
||||
}
|
||||
});
|
||||
element('refresh').addEventListener('click', refreshStatus);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) clearTimeout(timer);
|
||||
else refreshStatus();
|
||||
});
|
||||
refreshStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,28 +7,35 @@ const json = (body, status = 200) => Response.json(body, { status, headers });
|
||||
|
||||
export async function adminRequest(request, env, getScheduler) {
|
||||
let user = await verifyAccess(request, env);
|
||||
if (!user)
|
||||
return json({ error: 'Sign in through Cloudflare Access to continue.' }, 401);
|
||||
if (!user) return json({ error: 'Sign in through Cloudflare Access to continue.' }, 401);
|
||||
let url = new URL(request.url);
|
||||
if (request.method === 'GET' && ['/admin', '/admin/'].includes(url.pathname)) {
|
||||
let nonce = crypto.randomUUID();
|
||||
return new Response(page.replaceAll('__NONCE__', nonce), { headers: {
|
||||
...headers,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`,
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
} });
|
||||
return new Response(page.replaceAll('__NONCE__', nonce), {
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`,
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/admin/api/status')
|
||||
return json({ ...await getScheduler().status(), user, preview: false });
|
||||
return json({ ...(await getScheduler().status()), user, preview: false });
|
||||
if (request.method === 'POST' && url.pathname === '/admin/api/run') {
|
||||
// Access cookies authenticate the user; require a same-origin JSON request as well.
|
||||
if (request.headers.get('Origin') !== url.origin || request.headers.get('Content-Type') !== 'application/json')
|
||||
if (
|
||||
request.headers.get('Origin') !== url.origin ||
|
||||
request.headers.get('Content-Type') !== 'application/json'
|
||||
)
|
||||
return json({ error: 'A same-origin JSON request is required.' }, 403);
|
||||
let mode;
|
||||
try { ({ mode } = await request.json()); } catch { return json({ error: 'Invalid request.' }, 400); }
|
||||
if (!MANUAL_MODES.includes(mode))
|
||||
return json({ error: 'Unknown run mode.' }, 400);
|
||||
try {
|
||||
({ mode } = await request.json());
|
||||
} catch {
|
||||
return json({ error: 'Invalid request.' }, 400);
|
||||
}
|
||||
if (!MANUAL_MODES.includes(mode)) return json({ error: 'Unknown run mode.' }, 400);
|
||||
let result = await getScheduler().startManual(mode);
|
||||
return json(result, result.ok ? 202 : result.busy || result.paused ? 409 : 400);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user