@@ -10,6 +10,8 @@ crawlers. It provides:
1010 back to.
1111- Sitemap ingestion (` Sitemap ` ) that discovers URLs and schedules them for rendering.
1212- A prerendered-page cache (` PrerenderedPage ` ) and indexability signals (` NonIndexable ` ).
13+ - A management API + UI at ` /prerender_admin ` (see [ Management UI] ( #management-ui-prerender_admin ) ),
14+ authenticated with Harper users and restricted to ` super_user ` .
1315
1416Everything that used to be hardcoded — domains, security token, device types, render/refresh
1517schedules, user-agent strings, TTLs — is supplied per deployment through the host application's
@@ -91,6 +93,10 @@ rest: true # required for the @export-ed table REST endpoints
9193 jobLeaseTime : 600000 # 10m — how long a claimed job is leased
9294 statusSyncInterval : 60000 # 1m — how often queue status is recomputed/broadcast
9395
96+ management : # the admin API + UI at /prerender_admin
97+ enabled : true # false makes every management route 404
98+ scanCap : 20000 # ceiling on rows an overview scan walks (see "Management UI")
99+
94100 userAgents : # per-device User-Agent strings sent to the origin
95101 desktop : ' Mozilla/5.0 ... HarperPrerender/1.0'
96102 mobile : ' Mozilla/5.0 ... HarperPrerender/1.0'
@@ -166,14 +172,14 @@ Database/table names are fixed. Tables are split across databases by write-trans
166172Harper serializes writes per database and commits each database independently, so the hot, high-write
167173queue table is isolated and bursty/heavy writes don't serialize against it:
168174
169- | Database | Tables | Notes |
170- | ----------------- | ----------------------------- | ---------------------------------------------- |
171- | ` render_schedule ` | ` RenderSchedule ` | the hot render queue — isolated |
172- | ` render_service ` | ` RenderTarget ` , ` QueueStatus ` | render- target registry + per-host queue status |
173- | ` page_cache ` | ` PrerenderedPage ` | rendered-HTML cache (heavy blob writes) |
174- | ` sitemaps ` | ` Sitemap ` , ` SitemapRefresh ` | sitemap data + refresh marker |
175- | ` signals ` | ` NonIndexable ` | indexability signals |
176- | ` coordination ` | ` SharedBuffer ` | node-local cross-worker SAB (never replicated) |
175+ | Database | Tables | Notes |
176+ | ----------------- | --------------------------------------------- | -- ---------------------------------------------- |
177+ | ` render_schedule ` | ` RenderSchedule ` | the hot render queue — isolated |
178+ | ` render_service ` | ` RenderTarget ` , ` QueueStatus ` , ` QueueControl ` | target registry, observed status, desired status |
179+ | ` page_cache ` | ` PrerenderedPage ` | rendered-HTML cache (heavy blob writes) |
180+ | ` sitemaps ` | ` Sitemap ` , ` SitemapRefresh ` | sitemap data + refresh marker |
181+ | ` signals ` | ` NonIndexable ` | indexability signals |
182+ | ` coordination ` | ` SharedBuffer ` | node-local cross-worker SAB (never replicated) |
177183
178184Because ` RenderTarget ` and ` RenderSchedule ` now live in separate databases, a target and its schedule
179185are written as two independent commits (target first). The brief window where a target exists without a
@@ -186,14 +192,98 @@ See [`src/schemas/schema.graphql`](src/schemas/schema.graphql).
186192| Method & path | Purpose |
187193| -------------------------------------------- | ------------------------------------------------------------------- |
188194| ` GET /p/<absolute-url> ` | Serve prerendered/cached HTML for a bot (cache hit or origin fetch) |
189- | ` POST /render_queue/pause ` | Pause the queue |
190- | ` POST /render_queue/resume ` | Resume the queue |
195+ | ` POST /render_queue/pause ` | Pause ** this node's ** queue |
196+ | ` POST /render_queue/resume ` | Clear this node's pause override |
191197| ` POST /render_queue/claim ` | Claim due render jobs (` { "limit": N } ` ) |
192198| ` POST /render_queue/job_result ` | Submit a render result (binary; ` x-metadata-size ` header) |
193199| ` GET/PUT/DELETE /RenderTarget/... ` | Manage render targets |
194200| ` POST /RenderTarget ` ` {action:"revalidate"} ` | Force re-render of matching targets |
195201| ` GET/POST/DELETE /sitemaps/<url> ` | Ingest / list / remove sitemaps |
196- | ` GET /queue_status ` | Read per-host queue status |
202+ | ` GET /queue_status ` | Read per-node queue status (** observed** ) |
203+ | ` GET /queue_control ` | Read the desired pause state (** intent** ) |
204+ | ` GET /prerender_admin ` | Management UI + API — see below |
205+
206+ ## Management UI (` /prerender_admin ` )
207+
208+ A single self-contained page (no build step, no external requests) plus the JSON API behind
209+ it. Open ` https://<host>:<port>/prerender_admin ` and sign in with a Harper username and
210+ password.
211+
212+ ** Authentication is Harper's own.** ` POST /prerender_admin/login ` calls Harper's
213+ ` context.login() ` , which authenticates against Harper users and sets the ` hdb-session `
214+ cookie; every data and action route then requires ` role.permission.super_user ` . There is no
215+ separate password to configure. Two consequences worth knowing:
216+
217+ - The instance needs ` authentication.enableSessions: true ` (Harper's default). The UI says so
218+ explicitly if sessions are off rather than failing obscurely.
219+ - With ` authentication.authorizeLocal: true ` (also the default) requests from ` 127.0.0.1 ` are
220+ auto-authorized as super-user — so on a local instance the UI opens without a login. Set it
221+ to ` false ` if that matters to you.
222+
223+ The super-user check is written out on every route rather than relying on Harper's
224+ ` allowRead ` /` allowCreate ` hooks, because those only run when ` loadAsInstance !== false ` — and
225+ this plugin's resources all set ` loadAsInstance = false ` .
226+
227+ | Method & path | Purpose | Gate |
228+ | ------------------------------- | --------------------------------------- | ------------ |
229+ | ` GET /prerender_admin ` | the UI page (contains no data) | public |
230+ | ` GET /prerender_admin/session ` | who am I | public |
231+ | ` POST /prerender_admin/login ` | ` { username, password } ` | public |
232+ | ` POST /prerender_admin/logout ` | end the session | session |
233+ | ` GET /prerender_admin/overview ` | nodes, counts, backlog shape | ` super_user ` |
234+ | ` GET /prerender_admin/config ` | effective config + warnings | ` super_user ` |
235+ | ` POST /prerender_admin/explain ` | ` { url, deviceType } ` → cache-key trace | ` super_user ` |
236+ | ` POST /prerender_admin/queue ` | ` { scope, paused } ` → pause control | ` super_user ` |
237+
238+ ### What it shows
239+
240+ - ** Overview** — per-node queue status with staleness, table counts, the due-now backlog, and
241+ a next-24h histogram of ` nextRenderTime ` . That histogram is the quickest way to tell a
242+ healthy jittered spread from a render herd: a flat distribution means the initial-render
243+ jitter is working, a single tall bar means everything comes due at once.
244+ - ** URL explainer** — paste a URL and see the ingress route that matched, the query allowlist
245+ it selected, the canonical URL, the resulting cache key, and the live
246+ ` RenderTarget ` /` RenderSchedule ` /` PrerenderedPage ` /` NonIndexable ` rows under it. It also
247+ reports the key the URL would get under the global ` url.queryParams ` , and flags a
248+ difference — that divergence is the usual fingerprint of a permanent cache miss caused by a
249+ missing or misordered route. It surfaces a ` NonIndexable ` suppression too, which otherwise
250+ removes a URL from rotation silently.
251+ - ** Config** — the effective merge of defaults and host overrides, with secrets shown only as
252+ whether they are set, alongside the risky-config warnings that previously existed only as
253+ startup log lines (empty security token, staging passthrough enabled, ` renderNow ` without a
254+ token).
255+
256+ ### Counting is capped, on purpose
257+
258+ Table totals come from Harper's ` getRecordCount() ` , which is time-bounded and switches to
259+ sampling on a large table — it is reported with its ` estimatedRange ` rather than as an exact
260+ figure. The backlog/histogram scan walks at most ` management.scanCap ` rows (default 20 000)
261+ and marks the result ` truncated ` when it hits that ceiling. At 1M+ targets an exact range
262+ count is not a page-load query, so the UI labels an estimate as an estimate instead of
263+ presenting a short count as the total.
264+
265+ ### Queue control: intent vs. observed
266+
267+ ` claim ` reads a ** node-local** , non-replicated flag (a ` SharedBuffer ` SAB), which is why
268+ pausing used to mean calling ` POST /render_queue/pause ` on every node in turn. The
269+ ` QueueControl ` table now holds the _ desired_ state and ** is** replicated:
270+
271+ | Scope | Meaning |
272+ | ------------ | ------------------------------------------------------- |
273+ | ` all ` | cluster-wide default |
274+ | ` <hostname> ` | per-node override — wins over ` all ` , in both directions |
275+
276+ ` paused: true ` pauses, ` paused: false ` explicitly keeps a node running _ through_ a
277+ cluster-wide pause, and deleting a node's row (` paused: null ` ) returns it to inheriting ` all ` .
278+ Each node resolves the intent on its own ` queue.statusSyncInterval ` tick, so ** a change
279+ reaches a remote node within one interval (default 1m), not instantly** — the UI states this.
280+ ` QueueStatus ` remains what each node last _ observed_ ; the UI shows both, and marks a node
281+ stale when it stops reporting.
282+
283+ ` POST /render_queue/pause ` stays deliberately node-scoped: that endpoint sets
284+ ` loadAsInstance = false ` and therefore enforces no authentication of its own, so it must not
285+ be able to stop the whole fleet. Cluster-scoped control is only reachable through the
286+ super-user-gated admin route.
197287
198288## How it fits together
199289
0 commit comments