Add the admin page (phase 3)

services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:59:21 +00:00
parent 3f646048a2
commit 32cf13a5cb
23 changed files with 1474 additions and 6 deletions
+29 -1
View File
@@ -35,6 +35,13 @@ const changeSchema = z.discriminatedUnion("op", [
}),
]);
const syncSchema = z.object({
job: z.string().regex(/^[a-z0-9-]{1,40}$/),
ok: z.boolean(),
message: z.string().max(2000).optional(),
counts: z.record(z.string(), z.number()).optional(),
});
const types = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -107,7 +114,9 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
const type = q("type");
const slug = q("slug");
let note: StoredNote | null = null;
const name = q("name");
if (p) note = store.get(p);
else if (name) note = store.byName(name);
else if (type && slug) {
note = store.bySlug(type, slug);
if (!visible(note, role)) {
@@ -115,7 +124,7 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
const target = to ? visible(store.get(to), role) : null;
if (target) return send(res, 200, { redirect: { type: target.type, slug: target.slug, path: target.path } });
}
} else throw new HttpError(400, "Give path, or type and slug");
} else throw new HttpError(400, "Give path, name, or type and slug");
const found = visible(note, role);
if (!found) throw new HttpError(404, "No such note");
return send(res, 200, {
@@ -143,6 +152,17 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
if (path === "/api/redirects") return send(res, 200, store.redirects());
if (path === "/api/status") {
need(can.readDrafts(role));
return send(res, 200, { ...service.health, counts: store.count(), syncs: store.syncJobs() });
}
if (path === "/api/syncs") {
need(can.readDrafts(role));
const job = q("job");
return send(res, 200, job ? store.syncRuns(job) : store.syncJobs());
}
if (path === "/api/problems") {
need(can.readDrafts(role));
return send(res, 200, service.problems());
@@ -210,6 +230,14 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
return send(res, outcome.status === "rejected" ? 422 : 200, outcomeView(outcome));
}
if (path === "/api/syncs") {
need(can.write(role));
const body = syncSchema.safeParse(await readJson(req));
if (!body.success) throw new HttpError(400, z.prettifyError(body.error));
store.reportSync({ ...body.data, message: body.data.message ?? "", counts: body.data.counts ?? {}, at: new Date().toISOString() });
return send(res, 200, { ok: true });
}
if (path === "/api/scan") {
need(can.review(role));
return send(res, 200, service.scan());
+4 -1
View File
@@ -34,7 +34,10 @@ const watcher = new Watcher(service, config.siteDir, {
if (r.changed) console.log(`Scan: ${r.changed} changed, ${r.held} held`);
if (config.healthcheckUrl) fetch(config.healthcheckUrl).catch(() => {});
},
onError: (err) => console.error("Watcher:", err),
onError: (err) => {
service.noteError(err);
console.error("Watcher:", err);
},
});
watcher.start();
+12
View File
@@ -50,6 +50,17 @@ export class ContentService {
this.onHold = o.onHold;
}
/** What the admin page shows as the service's own health. */
readonly health: {
startedAt: string;
lastScan: { at: string; changed: number; held: number } | null;
lastError: { at: string; message: string } | null;
} = { startedAt: new Date().toISOString(), lastScan: null, lastError: null };
noteError(err: unknown) {
this.health.lastError = { at: this.iso(), message: err instanceof Error ? err.message : String(err) };
}
private iso = () => this.now().toISOString();
private today = () => this.iso().slice(0, 10);
@@ -94,6 +105,7 @@ export class ContentService {
if (o.status !== "unchanged") changed++;
if (o.status === "held") held++;
}
this.health.lastScan = { at: this.iso(), changed, held };
return { changed, held };
}
+47
View File
@@ -117,6 +117,15 @@ create table if not exists seen (
path text primary key,
hash text not null
);
create table if not exists sync_runs (
id integer primary key autoincrement,
job text not null,
at text not null,
ok integer not null,
message text not null,
counts text not null
);
create index if not exists sync_runs_job on sync_runs(job, id);
create virtual table if not exists search using fts5(path unindexed, title, body, tokenize = 'porter unicode61');
`;
@@ -152,6 +161,19 @@ const toHeld = (r: Row): HeldChange => ({
decidedAt: r.decided_at,
});
export interface SyncRun {
job: string;
at: string;
ok: boolean;
message: string;
counts: Record<string, number>;
}
const toSyncRun = (r: Row): SyncRun => ({ job: r.job, at: r.at, ok: !!r.ok, message: r.message, counts: JSON.parse(r.counts) });
/** Runs kept per sync job; older ones are dropped. */
const SYNC_RUNS_KEPT = 50;
export class Store {
readonly db: DatabaseSync;
@@ -379,4 +401,29 @@ export class Store {
seenPaths(): string[] {
return (this.db.prepare("select path from seen").all() as Row[]).map((r) => r.path);
}
// ------------------------------------------------------------ sync jobs
/** A sync job (PersonalReader, BoardGameGeek and the like) reports how its last run went. */
reportSync(run: SyncRun) {
this.db
.prepare("insert into sync_runs (job, at, ok, message, counts) values (?, ?, ?, ?, ?)")
.run(run.job, run.at, run.ok ? 1 : 0, run.message, JSON.stringify(run.counts));
this.db
.prepare("delete from sync_runs where job = ? and id not in (select id from sync_runs where job = ? order by id desc limit ?)")
.run(run.job, run.job, SYNC_RUNS_KEPT);
}
/** Each job's latest run, with when it last succeeded. */
syncJobs(): (SyncRun & { lastOkAt: string | null })[] {
const rows = this.db.prepare("select * from sync_runs where id in (select max(id) from sync_runs group by job) order by job").all() as Row[];
return rows.map((r) => ({
...toSyncRun(r),
lastOkAt: ((this.db.prepare("select max(at) at from sync_runs where job = ? and ok = 1").get(r.job) as Row).at as string) ?? null,
}));
}
syncRuns(job: string, limit = 20): SyncRun[] {
return (this.db.prepare("select * from sync_runs where job = ? order by id desc limit ?").all(job, limit) as Row[]).map(toSyncRun);
}
}