Skip to content

Web front & read API

One Deployment, htrflow-web, serves three things on one origin:

  • the campaign browser, a static single-page app, at /
  • Universal Viewer at /uv.html
  • the read API at /api/v1/…

The read API computes every answer live from the campaign Jobs, their Pods and ConfigMaps, plus each running volume's progress.json, read through the results proxy. It writes one thing: each campaign's status record (Campaigns → The record). It holds no cluster credentials for the browser. Every /api/v1 route needs a login on the results store, checked with the results proxy; who may reach the site at all is decided by the network (Deploy). The proxy, htrflow-results, is a second Deployment on the same image that serves /results files and the login (Security → The results boundary).

Source: packages/web (the API) and frontend/ (the browser). Their READMEs are the developer documentation.

Routes

Route What it is
/ The campaign list: one card per campaign.
/log?log=<url>&manifest=<url>[&live=1] The run viewer: one volume's run log grouped by stage, and a summary of its manifest.json (counts, timings, slowest and failed pages, a per-page grid). With live=1 it follows the log until the run ends.
/alto?src=<url> The ALTO viewer: one page's text in reading order, tinted by confidence, with a raw-XML toggle.
/uv.html#?manifest=<url> Universal Viewer on a IIIF manifest.
/config.js The browser's configuration, written by the API from its own environment (below).
GET /healthz {"ok": true}.
GET, HEAD /results/<key> A result file, streamed from the results proxy (through the web front, which reads nothing from the answer). 401 without a session.
POST /results/_login, POST /results/_logout, GET /results/_session The login form's target (user name and password), the log-out, and {"user": …} for a valid session (401 otherwise). Posted bodies are capped at 16 KiB, and only the session cookie is forwarded: __Host-htr_session when the browser is on HTTPS, htr_session on plain HTTP.
GET /api/v1/version {"version", "web"}: the release tag both images carry, and the web package's version.
GET /api/v1/jobs?reaped=20 One summary per campaign (below).
GET /api/v1/jobs/{namespace}/{name}?offset=0&limit=200 One campaign's detail (below). 404 for a name that is not a campaign.

Every /api/v1 answer, its 401 and 502 included, carries Cache-Control: private, no-store: each is one logged-in person's view.

/log and /alto open only URLs under the results base, so a mailed link cannot point them at another host, nor outside the base on the same one: one with an escaped / or \ in its path below the base is refused too, since a server that decodes before it resolves .. would read it as a way out. How to reach these from a browser is in View Results.

The campaign list: GET /api/v1/jobs

Every live campaign Job in the namespace, plus the reaped newest campaigns whose Job is gone (default 20, at most 10 000). The X-Reaped-Total header says how many reaped campaigns there are in all. Each row is a JobSummary:

Field Meaning
namespace, name, pipeline Which campaign.
phase See Phases.
counts total (= completions), active, done (= completed indexes), failed (= failed indexes).
suspended The Job's spec.suspend.
createdAt, startedAt, finishedAt Timestamps; finishedAt is set once the campaign has ended.
resultsBase <results URL>/<namespace>/<pipeline>.
warmup {phase, reason?} from the pipeline's warm-up Job: missing, pending, running, succeeded or failed (with a reason).
jobGone true for a campaign whose Job is past its TTL. The row comes from the campaign's two ConfigMaps.
qualityPrediction true when the campaign's htr-pipeline-<id> ConfigMap has a QualityPrediction step. false when it has none, or when that ConfigMap is gone or unreadable.

One campaign: GET /api/v1/jobs/{namespace}/{name}

A JobDetail is the summary plus:

Field Meaning
volumes One row per index, paged by offset and limit (default 200, at most 1000).
failures Up to 50 of the newest failed rows, with a reason or without.
latest The newest active volume, else the newest done one, else null.
pipelineSteps, pipelineYaml The step names and the steps document, from the htr-pipeline-<id> ConfigMap. Empty when it is gone.
pagesDone, pagesTotal, pagesFailed, errors Page counts summed over every volume whose progress has been read.
lastError The most recent page failure among them, with its volume and that volume's logUrl.
pagesCoverage {counted, of}: how many run volumes those sums cover. It reaches of over a few polls.
quality The campaign's predicted quality, summed over the same volumes, or null when none of them scored a page. See below.

Everything but volumes is computed over every volume, not only the requested page. A campaign of thousands shows its first 200 rows, and the volume in flight is rarely among them.

Each volume row (VolumeView):

Field Meaning
index, id The index and its line of volumes.txt.
state See Phases.
manifestUrl, iiifUrl, altoPrefix Result URLs under resultsBase.
sourceUrl The URL half of the volumes.txt line. null for an images: volume, or for a URL a browser could not open.
logUrl <results URL>/<namespace>/status/logs/<pipeline>/<id>.txt, always present.
reason {stage, permanent, error} from a failed pod's termination message, while a pod for that index still exists.
progress From the volume's progress.json (below), or null.
forbidden true when the results proxy refused the signed-in user the volume's progress file (a 403): their store account may not read the volume. A refusal is an answer, kept as long as any other: an hour for a volume that is over, a few seconds for one still running.

Where a reason comes from. The wrapper's own JSON termination message when it wrote one. A pod stopped by the warm-up gate carries the gate's message. A pod killed at its deadline reads "error": "DeadlineExceeded". A pod that left no message reads its own reason (Evicted), else the container's (OOMKilled (exit code 137)).

progress is {done, total, failed, lastPage, stage, updatedAt, ageSeconds, lastError, errors, viewerPublished, quality}, read from the volume's progress.json (or, for an older run, its manifest.json counts). The fields are described in S3 Layout. ageSeconds is computed by the API from its own clock, so a reader's clock skew never shows "0 s ago".

progress.quality is null when the volume has not scored a page, else {mean, min, scored, model, revision, lowest} — the wrapper's own block (docs: S3 Layout), sanitised: a value outside what the wrapper could have written (a mean out of [0, 1], a negative scored, a string where a number belongs) drops the whole block rather than show a wrong one, and lowest is capped at 5 entries, each {page, quality, canvas} (canvas is null when the wrapper's was not a valid index).

The detail's own quality (next to pagesDone) is null when no read volume scored a page, else {mean, min, scored, volumes, lowest}: mean is every scored volume's mean weighted by its scored pages, min is the lowest of every volume's min, scored is the pages summed, and volumes is how many volumes contributed — the card says so when that is fewer than the campaign's total. lowest is the worst 5 pages across every volume that scored one, each {volume, page, quality, canvas, iiifUrl} — iiifUrl is that volume's own, so the page links straight to the viewer without looking the volume up.

  • A pending volume is never read. A bucket that does not answer gives null, never a 500.
  • Answers are cached: 5 s for a running volume, an hour for one that is over (an absent file included).
  • One request makes at most 100 bucket reads and spends at most 5 s on them, active volumes first. The rest come from later polls.
  • The API reads progress through the results proxy at HTRFLOW_INTERNAL_RESULTS_BASE, forwarding the caller's session cookie; the cache is per user, so one person never sees what another's account read. A volume the account may not read shows no progress.

Phases and volume states

A campaign's phase comes from its Job:

Phase When
Queued Suspended, nothing done yet.
Paused Suspended, some indexes done.
Running Not suspended and not ended. A campaign whose warm-up has not finished also reads Running; the warm-up chip says why nothing moves.
Succeeded The Job's Complete condition.
PartiallyFailed The Failed condition with some indexes completed: what they published is there.
Failed The Failed condition with nothing completed.
Unknown Only on a jobGone row whose record never reached an ending (the Job was deleted by hand or by a prune).

A campaign that stays Queued with free capacity is covered in Queueing.

A volume's state is pending, active, done or failed, computed from the Job's index sets and pods. unknown appears only on a jobGone campaign, for volumes whose ending the record does not say.

Reaped campaigns

A campaign whose Job is past ttlSecondsAfterFinished keeps its row, with jobGone: true, the phase and counts its record kept, and finishedAt. Its detail rebuilds the volume rows from volumes.txt: a volume named in the record's failedVolumes is failed with that sentence, and every other volume takes the campaign's ending. When the record counts more failures than it names, those others read unknown rather than done. Their links still point under the results URL, and their progress is still read through the results proxy.

What a campaign card shows

Every card has the same four zones, in the same order, so ten campaigns scan like a table. A folded card shows only the first; the rest appear when it is opened:

  1. Identity and state. The campaign's name (click to fold or unfold; namespace/name only when the list spans more than one namespace), the warm-up chip while the warm-up has not succeeded, a "job removed" chip for a reaped campaign, its phase, when it was created and finished, and on a folded card the pages done so far (411 / 1914 pages · 3 failed, each figure marked ≥ while the sums do not yet cover every volume). Every part of the line has its place from the first paint, so nothing on it moves as the rest arrives.
  2. The body. The totals (volumes, pages) with progress bars, then the loaded page of the campaign's volumes, with "load more" for the rest.
  3. Problems, only when there is one: why the warm-up failed, each failed volume as id: sentence linked to its run log, and the latest page error when its volume is not on screen. Past three sentences the rest wait behind "N more".
  4. Provenance. The pipeline (click for its YAML) and, on the row below, the models it loads, each linked to its Hugging Face revision, or marked unpinned.

Each volume row links:

  • the id to Universal Viewer: the published iiif.json once the volume is done or has published an interim one, its source manifest before that, and plain text when there is neither
  • a manifest icon to the source manifest (empty for an images: volume)
  • a log icon, last on the row after its state, to the run viewer, live while the volume is not done

The warm-up chip reads "warm-up pending", "running", "failed" or "no warm-up" (missing). A failed warm-up also colours the card as failed, since no pod can start without it; its reason is in the chip's title. missing colours the card only while the campaign has not succeeded.

Paging and polling. The list re-fetches every 60 s. An open card fetches its own detail, re-fetching every open page on each poll. A folded card reads the same detail for its page count, and only while it is on screen: every two minutes for a campaign still going. A finished, unknown or reaped campaign is read until its page sums cover every volume, and not polled again. At most four cards read at a time; the rest wait their turn, behind a card the reader has just opened. A failed poll puts a banner at the foot of the window, over the last list, saying when the next try is, and backs off. Older reaped campaigns wait behind "show older campaigns", 20 at a time.

Order. The list opens sorted by what wants a person: running, then in trouble, then finished (newest first), then not started. A poll keeps that order, so no card moves under a reader, with two exceptions that are news: a campaign that falls into trouble, and a new one, take their place in the order at once, gliding there. Any other change leaves the card where it is, and the foot of the window offers to re-sort. The list is also sorted afresh when the reader comes back to the tab.

The page and component internals are in the frontend README.

Configuration

The browser has no environment of its own. It reads window.API_BASE and window.RESULTS_BASE from /config.js, which the API writes from its own environment on each request: /api/v1, and HTRFLOW_RESULTS_URL. Set resultsUrl on the chart and the page follows; there is no second copy to keep in step.

API env var Default Meaning
HTRFLOW_RESULTS_URL required The browser-reachable base every result URL is built from. The chart sets it from resultsUrl.
HTRFLOW_INTERNAL_RESULTS_BASE HTRFLOW_RESULTS_PROXY Where the pod reads progress files. The chart sets it to the results proxy's Service, http://htrflow-results:8082/results.
HTRFLOW_RESULTS_PROXY required The results proxy's Service, http://htrflow-results:8082/results. The web front asks it whether a request's session cookie is valid (an answer is cached for 30 seconds per cookie) and passes /results through to it; every /api/v1 route answers 401 without a session and 502 when the proxy does not answer.
HTRFLOW_NAMESPACES the pod's own namespace (htr-batch outside a cluster) Comma-separated namespaces to list.
HTRFLOW_WEB_STATIC /app/static The built site; missing means API only.
HTRFLOW_WEB_SITE_ONLY unset Serve the site with no cluster: /api/v1/… answers 503.
HTRFLOW_BATCH_VERSION dev Baked into the image from the release tag; shown in the page header.

The generated Configuration page lists every setting. The chart values are in Chart Values.

Content-Security-Policy

Response Policy
Every response frame-ancestors 'none', with X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin.
The SPA's pages Their own meta CSP from the build (script-src 'self' plus the hash of SvelteKit's init script, object-src 'none', base-uri 'self'), and a header adding connect-src 'self' <results base>/: the page may fetch only from the API and the results URL, both on its own origin.
uv.html A policy of its own, chosen by the file served, whatever path reached it: hashed inline script, inline styles, images and manifests from anywhere.
Any other HTML document in the site default-src 'none'; sandbox.

The meta CSP is why configuration arrives as /config.js and never as an inline script. In site-only mode, or with a results base a CSP source cannot express, the SPA gets no connect-src header.