Repository navigation
feat: a browser dashboard on the admin listener - #9
Merged
Merged
Conversation
The icons move to `assets/` at the root, where both builds can reach them: mkdocs serves only what is under `docs/`, so a hook stages them into `docs/assets/`, and the frontend's `prebuild` copies the favicon into `public/`. `favicon.ico` is generated from a small-size variant of the mark, because the full one collapses into a smudge at 16 pixels. Tracked through git-lfs, except `icon-128.png`: Docker Hub reads it through `raw.githubusercontent.com`, which serves the LFS pointer rather than the bytes -- verified, not assumed -- so the overview image would render broken with nothing in this repository failing to say so.
0002, 0003 and 0004 each added one column and nothing else. Their whole content was ADD COLUMN, plus a matching edit in load.rs, in save.rs, and in two hand-written statements a test existed solely to keep in step. Nothing ever queried admin_host or latency_min in SQL, so a column per field bought nothing and charged a migration for every field the format gained. configurations.settings and proxies.document now hold the documents; admin_tokens and mocks are folded into them. What stays a column is what SQL uses: name and revision for the compare-and-swap, ordinal for proxy order, config for the foreign key. load.rs loses twenty-five per-column helpers, and the rows are parsed by the same Deserialize impls the YAML loader uses, so a stored row is held to exactly the rules a configuration file is held to. The backfill in 0005 is checked by tests/migrate.rs, which plants rows in the old shape, applies the migration, and asserts the result parses back into the same configuration with the same revision. It has to: the revision is a hash of the canonical YAML and every write compare-and-swaps against it, so a backfill that changed one would refuse the next write as a conflict that never happened. One property moves rather than disappears: the unique index on (config, token) is gone, and rule V26 refuses duplicate token values on every write instead.
Two fields the browser dashboard needs: whether to serve it, and what heading to show. Both default to something an existing configuration gets on upgrade without being edited -- on, and "Doppel". Both are Option and skipped when absent, so the canonical YAML of a configuration that mentions neither is unchanged and no stored revision moves. Neither needed a migration, which is the first dividend of storing the document as JSON; the conformance fixture now carries both, so a store that went back to needing a column per field would fail there rather than in production. AdminTitle is a type rather than a String: non-empty, at most 64 characters, no control characters, counted in characters so the bound does not depend on the language the title is written in. It deliberately accepts markup -- escaping belongs where the HTML is written, not in a type that would forbid a legitimate heading to paper over a rendering bug.
The dashboard has to know what the caller may do, because a button for an action the server will refuse is worse than no button: the operator finds out by trying. Nothing else in the API says anything about the caller's rights. Answers 200 for everybody, anonymous included -- an endpoint whose purpose is to report that the caller may do nothing cannot itself demand a right, and it discloses nothing that attempting the six actions would not. Every field is authorize() evaluated, never a second reading of the access blocks: a copy of that decision is a copy that can disagree with the one enforcing it. the_report_agrees_with_what_the_request_actually_answers issues every action for every token and asserts a reported false comes back 401 or 403 and a reported true does not, which is what catches the drift. The per-proxy map is omitted, not emptied, for a caller who may not list: keyed by proxy name it would be a proxy listing by another route, and access.rs is explicit that a caller without read access must not be able to tell a real proxy from an invented one.
Compiled into the binary and served from the admin listener's root: the proxy list, a form over the whole proxy document including its mocks, status and reload. Most of what the admin API does, without curl. Vite, React and Tailwind, embedded by a build script rather than by a crate -- forty lines, no new dependency. A build with no `frontend/dist` still compiles and answers 503 at `/`, so `cargo install` works on a machine with no Node; CI sets `DOPPEL_REQUIRE_DASHBOARD_ASSETS` so that concession cannot apply there. Usable without a token wherever the API is: the dialog opens once and has "Continue without a token", and signing out returns to the anonymous view rather than a wall. Controls for actions the caller lacks are disabled with the reason, not hidden -- the page asks `GET /api/v1/access` what the caller may do instead of guessing. Settings reach the page as the contents of a JSON script element, which is what allows `script-src 'self'` with no `unsafe-inline`; every less-than in that JSON is escaped, because `admin.title` accepts markup on purpose. Not indexable three ways -- header, meta element and `robots.txt` -- because crawlers fail differently. The Playwright suite earned itself on the first run by finding three bugs nothing else could: every rights-gated control disabled for the life of the page (a selector returning a stable reference, so the report's arrival re-rendered nothing), a template table rendering empty cells (the API calls the field `name`, the model called it `file`), and the code editor crashing the built bundle with React error #130 while working on the dev server. Also here: the Makefile, because the assets are embedded at compile time and `npm run build` alone changes nothing about a binary; the npm packages in `THIRD-PARTY.md`; the dashboard's documentation page; and the server's complaint attached to the field it is about rather than only to the banner.
`make image` did not work on a Mac. It cross-compiled for musl on the host, and `ring` builds C, so that needs `aarch64-linux-musl-gcc` -- which nothing installs by default and whose absence arrives as "Compiler family detection failed ... ToolNotFound" from a build script rather than as anything about cross-compiling. The Dockerfile now decides. A binary staged at `dist/<platform>/doppel` or `dist/doppel` is used; when neither is there, one is compiled in a builder stage for the platform being built, and that stage is discarded -- the image stays Alpine plus the binary, 66 MB. So `docker build .` works from a checkout on any host, and the Makefile is back to knowing nothing about Linux binaries: `make image` builds the dashboard and calls docker. Three things the tests of it turned up: - `TARGETPLATFORM` is `linux/arm64/v8` on the classic builder and `linux/arm64` under buildx, so a directory named after the first is a binary nobody finds. `TARGETOS`/`TARGETARCH` are consulted as well. - A glob tolerates a missing file but not a missing parent, so `dist/<platform>/` on a laptop failed the COPY outright. Copying `dist*` and searching inside it is what makes "use it if it is there" work at all -- alongside `.dockerignore` itself, which is the source that always matches. - The release job now passes `BUILDER=alpine:3.24`: it always stages binaries, and without this it would pull a Rust image for both platforms to run a `cp`. Both refusals are deliberate and tested by hand: no dashboard in `frontend/dist` stops the compile rather than shipping a binary whose own root answers 503, and a toolchain-free builder with nothing staged says which argument caused it.
Three defects, all visible in a browser and none of them to any test that existed. **The dark theme rendered a white page.** Every component carried its own dark text colour and nothing coloured the page, so a dark theme put near-white headings on white. The page's colours are its own now, and `color-scheme` follows the choice so the browser's own controls -- a select, a scrollbar, a caret -- stop being light on a dark page. The new scenario measures contrast between painted pixels rather than checking for a class: the failure it guards measured about 1.1:1 against a 4.5:1 threshold. Painted pixels because Tailwind 4 emits oklch(), which neither a regex nor fillStyle will convert -- only a paint does. **"Add" on a header row did nothing.** The rows were derived from the map on every render, and a new row has no key, so the map could not hold it and the next render dropped it. The rows are local state now, reseeded only when the map arrives holding something they do not describe, and adjusted during render rather than in an effect -- so there is no pass showing the old rows and nothing fighting the input. **Notices never went away.** Three reloads left three of them stacked in the corner for the rest of the session. They expire on their own now, a failure lasting longer than a success, timed in the store rather than in the component that renders them: a page that navigates away would otherwise leave one behind forever. Also covers the form the way it is actually used. One scenario fills in every field of a proxy -- including a mock with all three selector maps, a JSON body and response headers -- and reads the document back through the API rather than off the screen. Another loads a proxy and saves it untouched, which must leave the document identical: a control that renders one value and writes another shows up there and nowhere else.
`/status` was both an endpoint and a page. The dashboard has a Status tab at that path, and a reload rather than a click reached the endpoint and rendered its JSON -- which is the conflict this removes: everything the API serves lives under `/api/`, and everything outside it belongs to the page. That division is what makes a client-side route survive a reload. A GET outside `/api/` and `/static/` answers with the page, so `/proxies/alpha` can be bookmarked; a path under `/api/` still answers in the error envelope, so a mistyped endpoint does not hand a client an HTML document to parse; and a missing asset stays a 404, because answering it with the page means a typo'd script tag fails somewhere else entirely. Breaking, and recorded as such: a healthcheck pointed at the old paths will 404.
The proxy table and the edit form were laid out for a narrower page than they get, and the list did not refetch when a token arrived -- so signing in left the anonymous view on screen until something else caused a fetch.
The page's own chrome. `Doppelganger` in two tones and an italic serif when nothing named this Doppel -- two spans rather than an image, so it scales with the heading, needs no asset and no `img-src`, and says the same thing to a screen reader. A deployment that named itself gets its name. The footer is one line: copyright at the left, links at the right. The year comes from the build rather than from the browser's clock, because the page ships inside the binary and the year it was published in is a fact about the build -- `httpdate` turns the stamp into a year, and it is already in the tree under hyper. And the editors: sized, coloured, and the templates form laid out in a grid rather than a flex row that mixed a field carrying a hint with one that did not.
`GET /api/v1/schema` serves the configuration's JSON Schema, generated from the same `ToSchema` impls the OpenAPI document is, cached for ten minutes and unauthenticated: it describes the shape of a configuration, not a configuration. It exists so the page's rules are the binary's rules. Every pattern, bound and enum the form refuses a value by is read from that document at load, so a field cannot drift from what the server will accept -- the failure this replaces was a regex duplicated in TypeScript and then relaxed in Rust. What needs more than one field to judge is still the server's answer on save, mapped back onto the field it is about.
…ntation Three things that make the form readable, and the reference they point at: - Syntax colouring where a value has a syntax: a mock's path pattern as a regex, a proxy document as YAML, and Jinja expressions wherever a value is rendered through the template engine -- including inside a JSON body, which needed the two grammars composed by hand. Prism gives overlapping greedy patterns to whichever starts first, so a single top-level Jinja token silently misses every expression inside a string. - An (i) beside nearly every field, linking to that field's entry in a generated parameter reference, with the version of the running binary in the URL. The page is generated from the schema by `scripts/parameters_doc.py` -- 82 parameters, 73 with an example -- with a `--check` gate so it cannot drift, and `scripts/check_docs_links.py` resolves the links the page builds against the built site. A footnote marker rather than a button: it navigates, and a button that navigates lies to a keyboard. - A YAML editing mode for the whole proxy: one editor with colouring, a Tab that indents rather than leaves the field, formatting on save and on a button, a link to the documentation, and live validation against the running schema -- a message under the editor rather than a save that fails. `@cfworker/json-schema` rather than ajv, which compiles a schema with `new Function` and cannot run under a policy with no `unsafe-eval`; `yaml` rather than `js-yaml`, whose `argparse` is Python-2.0 and outside the dependency licence policy.
`/etc/doppel/main.yaml` as a single-file bind mount cannot be saved to: a save writes a temporary file and renames it over the target, and a rename onto a bind-mounted file is EBUSY. Reproduced in a container before and after -- `mv: can't rename '.probe': Resource busy` -- and the fix is in three places: the compose file and the documentation mount the directory, and the error names the mount and says which to change instead of reporting the errno. `main.example.yaml` leaves `admin.title` out, and says why in a comment.
Six changes from watching the page be used, and one of them is the removal of another: templates were editable in the form, and that was the wrong shape -- a file store inside a form over a document, with a Save of its own that did not wait for the form's, and a rule the page could not enforce (the server refuses an upload no saved mock declares). They stay in the admin API, where `doppel config push` and any script already write them. A mock that answers with a template file shows which file, in a field it will not let you edit, and can be moved onto a text or JSON body instead. The rest: - A proxy can be renamed through an update, and its template directory moves with it. A collision is refused before anything is written, and a rename that moved the files but could not save the document reports both names. - A save that would *remove* something asks first, and names what: each injected header, access override, mock, entry of a mock's four maps, and a template file the answer was switched away from. A changed value is not confirmed -- it is visible in the field the operator is looking at. Deleting a proxy asks too. - Refresh on a refusal, beside the button that enters a token. A form reads once when it opens, so signing in afterwards used to leave the refusal on screen with nothing to press. - The page stopped reporting its own policy violations to the console. `react-simple-code-editor` renders a `<style>` per editor and the policy allowed no inline stylesheet, so an operator reading the console for their own problems found dozens of ours. The policy names that one stylesheet by hash, recomputed by a test so an upgrade of the package fails the build rather than the console. - The wordmark does not take a selection, and the title is a link home.
`docker build .` refused on a fresh checkout: the compile branch needs frontend/dist, because the admin crate embeds whatever is there and an empty directory yields a binary that starts, serves the API and answers 503 at its own root. The refusal said to run `make frontend` first, which is a thing you have to know. It builds the dashboard itself now -- nodejs and npm installed in the builder stage for exactly that, and only on the path that needs them. Building it on the host stays faster and stays what `make image` does; it is no longer required. The context grew by what `vite build` reads: the package files, index.html, the vite and tsconfig, src/, scripts/, and the favicon the prebuild step copies out of assets/. Listed one by one rather than admitting frontend/, because node_modules is larger than everything else in this context together. The one refusal left is the honest one: neither a built dashboard nor the sources to build it, which means the context is not what it should be.
The verification section named the Rust three, from when those were the gate. `make gate` is ten targets now -- it builds the dashboard, runs both Rust suites, jest, the bundle budgets, mkdocs and three generators -- so running it after each task spends minutes re-checking what the task did not touch. Same rule as before, with the table the section was missing: what to run for a crate, for frontend/src, for a browser behaviour, for docs, for the schema, for a dependency. The gate stays where it was: at the end, once.
`authorize()` answers every action as public while `admin.public` is set, so a proxy's `access` block decides nothing there -- the process already says as much among its startup advisories. The form offered the four fields anyway, which is the page disagreeing with the binary serving it: an operator sets `delete` to a group, saves, and the next anonymous request deletes the proxy. The section is left out entirely rather than disabled. There is no state in which those fields could be filled in usefully, and a disabled control invites the question of what would enable it. A proxy whose document still holds overrides -- the state an operator reaches by turning `public: true` on over proxies they had already restricted -- says so in one line where the section was. Without that, the YAML mode shows an `access` block the form has no section for, and nothing accounts for the difference. Nothing is dropped: the document keeps what it held, and a save preserves it. Covered by an e2e test with its own instance, over both answers: `alpha` holds an override and `beta` holds none. Mutation-checked twice -- rendering the section unconditionally, and never rendering the line -- and each failed.
Four screenshots: the proxy list at the top of the page, the token dialog where the access rules are explained, the form beside the paragraph that describes it, and the YAML mode after the document it shows. They live in `docs/screenshots/` rather than `assets/`. The icons are in `assets/` because two builds need them -- mkdocs and the frontend's favicon -- and these are read by mkdocs alone, so the directory that says so is the docs tree. `docs/assets/` was not an option: it is git-ignored, being what the build hook stages the icons into. `*.webp` goes through git-lfs, for the reason the icons do: a screenshot is a new whole file rather than a diff, and every visible change to the page brings one. Every workflow that builds the documentation already checks out with `lfs: true`, which is what stops this from shipping a pointer file as a broken image. The year and the version are blurred out of the footers. They are the two things in a screenshot that date it, and a reader who compares them against the version they are running learns nothing except that the picture is older.
`config/` without a leading slash matches a directory of that name at any depth, so it covered `crates/doppel-core/src/config/` as well as the one the compose file mounts. Nothing broke: git keeps tracking files it already knows, and those eight are tracked -- but every editor that reads `.gitignore` drew the crate's own configuration module as excluded, and a new file added there would have needed `git add -f`. Anchored, along with `main.yaml` beside it, which had the same shape.
…pect Two paths that were answered wrongly, both found by using them rather than by reading them: - `GET /metrics` returned the dashboard. Moving the exposition under `/api/v1/` was recorded as breaking on the grounds that a scrape of the old path would 404 -- it did not, because everything outside `/api/` and `/static/` is answered by the page, so a scraper received `200 text/html` and 1412 bytes of HTML. Measured against a running container. `/metrics` is the exposition again: that path is `metrics_path`'s default in Prometheus and in every agent and annotation that scrapes one, and it cannot collide with a page. - A trailing slash was a 404 everywhere. Axum stopped redirecting between the two spellings in 0.8, so `/metrics/` answered nothing while `/metrics` answered, and a scrape config or a curl carrying a slash got nothing with the endpoint sitting right there. Trimmed before routing, because a route layer runs after routing has already decided there is nothing there. The proxy listener gets none of the second: a proxied path is relayed byte for byte, `/orders/` and `/orders` are two resources upstream, and one of this project's own mocks matches `^/api/v1/resource/9/$`.
A rewritten `Location` was root-relative: an upstream answering `Location: https://api.example.com/v2/orders/7` under that base produced `/orders/7`. Correct, and enough for a client that resolves it against the URL it used -- but nothing else. Anything that logs, stores or prints the header gets a path with no host in it, and the case the relative form cannot express was left escaping altogether: a redirect to the upstream's own host *outside* the proxied path was relayed pointing at the upstream, so the client walked off past every fault and every mock. Both now name Doppel. Outside the base the path is kept as the upstream wrote it, which is what `nginx`'s `proxy_redirect` does; whether Doppel serves that path is a question about the configuration, and one that shows up in the logs rather than as a client quietly talking to the backend. A redirect to another host is still left alone -- Doppel does not proxy it, and naming itself would be a lie. That needs an address Doppel cannot infer, because `Host` is a claim by the caller and building a redirect out of it hands the caller the redirect. So: 1. `DOPPEL_EXTERNAL_URL` 2. `server.external_url` 3. `server.host` and `server.port`, with a wildcard bind read as loopback The third makes the common case work with no configuration at all, and is the one place this can be wrong: behind a port mapping or an ingress the client used neither that address nor that port. Startup logs which address it settled on, so that is one line away from being checked. The variable is read like `DOPPEL_ADMIN_TOKENS` -- validated at startup, never merged into `Config`, because the revision is computed over the document and folding the environment in would make two instances reading one document disagree about it. `ExternalUrl` is held to the same rules as an upstream base through one shared `parse_base`, so the two cannot drift. Five unit tests over the rewriting rules and four over the address resolution, plus two through the built binary -- the wiring is what an operator reported broken, and only an end-to-end test covers it. The shared test upstream learned one redirect to its own authority for them. Mutation-checked by dropping `with_external_url` at the call site: both end-to-end tests fail, one seeing the old relative form.
This is the `mocks` flake, root cause and fix. It failed about one run in twenty-five with `ECONNRESET` on a request the server never logged, the server still alive and still listening -- 6 failures across 540 runs while I measured it. `free_port` bound `127.0.0.1:0`, took the port the kernel offered and closed the listener. `std` sets `SO_REUSEADDR` on every `TcpListener`, and macOS then allows `127.0.0.1:P` to be bound while another process holds `0.0.0.0:P` -- the more specific address wins for connections to it. So the probe could draw a port a server under test was already serving on, shadow it for the microseconds before the listener dropped, and reset whatever connection landed in that window. The `mocks` suite is the one that runs `main.example.yaml` with its `host: 0.0.0.0`, which is why the flake lived there. Proven rather than reasoned: a wildcard listener serving, a second socket bound to `127.0.0.1` on the same port, a request to it, the shadow closed without accepting -- `[Errno 54] Connection reset by peer` at the client, and the real server never saw the connection. That is the fingerprint, down to the missing log line. A wildcard probe cannot do it: the kernel refuses a second wildcard bind on a busy port with `EADDRINUSE`, so a collision becomes the lost-port race `start_with_env` already retries. 500 runs, 0 failures. Also ruled out along the way, each by measurement, so nobody re-treads them: descriptor exhaustion, pipe-buffer exhaustion, ephemeral port exhaustion, and a "wedge" from an earlier session that turned out to be the probe's own single-threaded keep-alive upstream serialising eight forwards.
A `PUT` that changed an upstream answered `200`, wrote the document, and left the proxy forwarding to the old host. Reproduced on a running instance with two upstreams: the API read the new URL back, `/api/v1/status` reported the old revision, and the traffic did not move until `POST /api/v1/config/reload`. The API agreed with the operator while the process disagreed with both, and nothing anywhere said the two had parted. `POST`, `PUT` and `DELETE` now promote the stored configuration before replying, through the same function and the same mutex the reload endpoint uses, so the three cannot swap runtimes in the wrong order. Authorization is not repeated: the caller was authorized for the write, and this is the second half of it rather than a promotion of its own. When the promotion fails the response names both halves -- stored, not running, reload retries -- in the shape `renamed_but_stranded` already uses for the other write that cannot be undone. The write stands, because it is not this process's to take back. Reload keeps the job it is actually for: a configuration changed *behind* the API, which is the one thing no handler can promote for itself -- `main.yaml` edited by hand, `doppel config push`, another instance on the same database. Three tests changed sides, and each says something. `status_reports_what_is_running_not_what_is_stored` still holds, now written straight to the store, which is where that property lives. The one that said a proxy created over HTTP "serves traffic after a reload" says without a reload. And the form's end-to-end scenario had to start reading its own proxy back as `reader`: it writes `access.read: reader` through the form, and that override is now in force by the time the save answers -- root got a 403, which is the override doing exactly what the page said it would. Two new: the runtime's own view after a create, an update and a delete, and the whole chain through the built binary -- two upstreams, both listeners, a `PUT`, and a proxied request that comes back from the new one. Mutation-checked by dropping the call from `update`: both fail, one showing `/api/v1/status` still naming the old upstream.
…ponses `/api/swagger-ui/` answered `303 See Other` with `location: /api/swagger-ui/` -- itself. A browser followed that until it gave up. The trailing-slash rewrite added yesterday was in front of it: `utoipa-swagger-ui` answers the bare path with a redirect to the slashed one, the layer trimmed the slash back off, and the pair became a loop. Measured as exactly that before the fix. `tower-http`'s `NormalizePathLayer` trims unconditionally and cannot exempt a subtree, so the trimming is ours now: fifteen lines, one exception for `/swagger-ui`, and `/` left alone because an empty path matches nothing. The UI is `/swagger-ui/` and the document is `/openapi.json`, both outside `/api/`. Neither is a resource of the API -- one is a page served to a browser, the other describes every version this binary knows -- and both are where the tools that consume them look by default, the way `/metrics` is. It also stops an ingress routing `/api/*` to a JSON service from handing a browser HTML. The listener compresses what it sends, `br` or `gzip` by negotiation, `br` first when a client takes both. The dashboard is 140 KB of JavaScript and CSS and was going out uncompressed on every visit -- embedded as bytes, served as bytes -- and the Swagger UI's own stylesheet is another 150 KB. That is `tower-http` again, which is why it stays a dependency: same crate, different feature, already in the graph through utoipa-swagger-ui. Seven crates join the notices, all inside the licence policy. The proxy listener is deliberately left out: it relays the upstream's own encoding, because that is part of what a client is being tested against. The e2e suite gains the coverage that would have caught the loop, in a real browser: the UI renders and lists an operation out of the document with no request over 400, the bare path reaches it without looping, the exposition is Prometheus text under both spellings, the document names the paths this binary serves, an asset comes back `br`, `gzip` or plain by what was asked for, and the page at `/` carries the *substituted* configuration rather than the placeholder that ships in `index.html`. The harness hands out the proxy URL now, because an exposition with no traffic behind it has no series to assert on.
The one file this listener cannot cache: the assets are content-hashed and cached for a year, and `index.html` is rebuilt per request with the configuration spliced into it, so its comments and indentation were paid for on every page load. 1422 bytes to 748. `html-minifier-terser` in a `transformIndexHtml` hook, build-time only, and deliberately conservative -- two things in that file are load-bearing and would fail quietly if minification touched them. `id="doppel-config"` with double quotes is how `dashboard.rs` finds the element to splice into and what `build.rs` asserts is present, so `removeAttributeQuotes` is off and named in the comment for whoever considers turning it on. `minifyJS` is off as well: the element is `application/json`, and handing JSON to a JS minifier is a way to find out what it does with it. Verified where it would break rather than where it is easy: the binary builds (so `build.rs` still finds the element), 119 jest tests pass, and the browser suite reads the substituted configuration out of the served page -- title, version and `titleIsDefault` from the configuration Doppel was started with, not from the file.
…s exist
Six changes, one theme: a scrape should answer the questions an operator has
during an incident, and answer them from a process that has served nothing yet.
`doppel_proxy_request_duration_seconds` carries `replace`, `loss` and
`upstream_error`, each `1` or `0`. Three independent flags rather than one enum,
because they are not exclusive: a mock can answer a request its own loss roll then
drops, and `upstream_error` covers a transport failure, a timeout and a relayed
5xx. Its buckets now reach 15, 30 and 60 seconds -- `latency` injection is asked
to be slow on purpose, so those are real measurements rather than outliers.
`doppel_admin_request_duration_seconds{route,method,status}` is the admin API's
own latency, labelled by the route template: `/api/v1/proxies/{name}`, never
`/api/v1/proxies/alpha`, so a hundred proxies are one series and a query string is
none. The middleware sits inside routing, which is what makes `MatchedPath`
available, and inside compression, so it measures the work rather than the
deflating. Its ladder stops at five seconds, because nothing here has any business
above that.
Four series exist from startup, before anything happens:
doppel_build_info{version="..."} 1
doppel_dashboard_info{enabled="true"} 1
doppel_proxy_last_error_timestamp_seconds{code=""} 0
doppel_proxy_mocks{proxy="..."} N
A panel and an alert both read a never-recorded metric as "no data", which is
indistinguishable from a process nobody is scraping -- so the facts that are true
at startup are published at startup. Build and dashboard are two info metrics
rather than one: the first describes the artifact and never changes, the second
what this deployment turned on, and folding a flag into build info makes two
builds of one version look different. The last error is a timestamp because the
question is "is this still happening", which `time() - metric` answers at any
scrape interval; a counter needs a rate window to say anything about now.
`doppel_proxy_mocks` follows the configuration in force rather than the one the
process started with: it is published from `RuntimeHolder`, so startup, a reload
and a write through the admin API all go through it. A proxy that leaves the
configuration is set to zero rather than abandoned -- the exporter cannot delete a
series, and a stale one reads as a proxy that still exists with three mocks.
Nine unit tests, one for the middleware, and the e2e suite asserts both halves in
a real browser: that the always-present series are present in a process that has
served nothing, and that two requests to one route with different parameters and a
query string produce one templated series with a count of two. Mutation-checked by
labelling with the raw path, and by leaving a departed proxy's gauge alone.
The field sat against the sentence explaining it, so the dialog read as one block. `mt-3`, the same gap the buttons below already had. Screenshot replaced, with the year and the version blurred out of the footer as on the others. Tokens through the API, both ways round, which is the half the page cannot show: - Private. The header names the caller -- `root`/`admin`, `reader`/`user` -- and the rights report matches what a write then does. An unrecognised token is *equal* to sending nothing, asserted by comparing the two responses, because answering differently would confirm which tokens exist. A refused write is 401 without a token and 403 with one that may not write, each naming the action, and nothing is written either way. - Public. There is no way to set a token from the page, so the remaining route is one this browser kept from when the deployment was private: it must not resurrect the sign-in flow, and it does not -- no dialog, no Sign in, no Sign out, still anonymous, still allowed everything. Through the API a stale token is ignored rather than punished, which is what "unauthenticated" has to mean: a client that keeps sending its old header must not start getting 401s because a deployment went public.
`rewrite_redirects` keeps a client inside Doppel when the upstream answers a redirect. A body does the same thing one layer down and was not covered: a page, a script or a JSON document naming `https://api.example.com/v2/orders` sends the client straight to the backend on its next request -- past every injected fault and every mock, with nothing logged and nothing failing. `nginx` has `sub_filter` for this. `rewrite_urls`, per proxy, on by default. A URL under the proxied path loses that prefix, because that is where Doppel serves it; one on the same host outside the path keeps its own, exactly as a rewritten redirect does; and Doppel's own path prefix survives, so a deployment behind `https://gw.example.com/doppel/` stays behind it. **Only the exact host**, and that is the part worth reading twice. The needle includes the scheme, and a match is rejected when the next character could continue a hostname -- a letter, a digit, a dot or a hyphen. So `https://cdn.api.example.com` is a different host and is left alone, and `https://api.example.com.evil.test` is not turned into Doppel's address with somebody else's suffix glued on, which is what a plain string replacement would have done. Both have tests. Three limits, each relaying the body untouched rather than guessing: text only by content type, uncompressed only, and bounded by the proxy's `body_limit` -- a bigger body streams on from where the buffering stopped, since rewriting needs the whole thing. A body declared as text that is not valid UTF-8 goes out as it came. A rewritten body is not the entity the upstream sent, so `ETag` and the digest headers go with it and `Content-Length` is restated. A conditional request carrying the upstream's validator would otherwise be answered `304` for content the client has never seen. Nine unit tests over the rules, one through the built binary -- an upstream page naming its own host and a `cdn.` sibling that must survive it -- and the form and the generated parameter reference carry the field. Mutation-checked by disabling the rewrite at the call site: the end-to-end test fails with the upstream's own address in the link.
…it Doppel A deployment that names itself got its name in the page's sans, beside a footer that read as Doppel's own. Both now belong to it: - The title is set in the wordmark's italic serif at the same size, split into words and toned like `Doppel` + `ganger` -- the first word strong, the rest in the accent colour. Three boundaries, because a name uses all of them: a space, an underscore or a hyphen, and a case change. Every character survives; `billing_api` reads as `billing_api` with `_api` toned, not as two words with the underscore eaten. An acronym is one word (`APIGateway`), and a single word is not drawn in the accent colour for no reason. - The footer says "Built with Doppel <version>" before the copyright, links to "Doppel Documentation" rather than to "Documentation", and drops the repository link -- on `billing-api (staging)`, "Repository" reads as a link to the billing API's own, which it is not. All of it only when `admin.title` is set. An unnamed Doppel is Doppel's own page and keeps the footer it had, which the e2e suite now asserts from both sides. The token dialog says "Access token": the token is what the API takes from any caller, and `admin` in that heading read as a claim about which token it wants. And the stored token's hour slides. Every request the page makes with a token restarts it, so an operator working through the dashboard is not asked to type it again in the middle -- while one who walked away still loses it an hour after their last request, which is the claim the lifetime was always making. It cannot resurrect an entry that has already aged out: `loadToken` decides that, and `touchToken` asks it first.
CI failed on `a rejected document lands on the field the server complained about`, and the failure was in a helper rather than in the page: `fieldMessage` read `aria-describedby` with a single `getAttribute`, so it held `null` while the server's refusal was still in flight. The assertion after it retries, which is why this was the line that fell over. That run took 34 seconds for a suite that takes 15 on a laptop, and six local repeats of the scenario passed. Both helpers wait now. `paintedRgbAll` had the same shape -- `all()` returns whatever matches at that instant, while the spans it is pointed at are the highlighter's and appear on a React update rather than with the keystroke -- and was found by looking for the pattern rather than by waiting for it to fail.
lorem-dev
force-pushed
the
feat/frontend
branch
from
August 12, 2026 23:20
75fdcab to
c35a6da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A browser dashboard on the admin listener's root, compiled into the binary.
Forty-six commits, readable in order. The two that are not about the frontend are
worth knowing about first.
The database stopped needing a migration per field
0002,0003and0004each added one column and nothing else. Their wholecontent was
ADD COLUMN, plus a matching edit in the loader, the writer, and twohand-written statements a test existed solely to keep in step -- and nothing ever
queried
admin_hostorlatency_minin SQL.0005stores the documents as JSON:configurations.settingsandproxies.document, withadmin_tokensandmocksfolded in.load.rslosestwenty-five per-column helpers, and the rows are parsed by the same code that
parses
main.yaml, so a hand-edited row is held to exactly the rules aconfiguration file is. A proxy is still its own row with its own revision, so the
per-proxy concurrency the API depends on is untouched.
The backfill has a suite of its own:
tests/migrate.rsplants rows in the oldshape, applies the migration, and asserts the configuration and its revision
survive. It has to -- the revision is a hash of the canonical YAML and every write
compare-and-swaps against it, so a backfill that changed one would refuse the next
write as a conflict that never happened. Mutation-checked by dropping
replacefrom the backfill.
A new endpoint:
GET /api/v1/accessReports the calling token's own rights, so a client can disable an action instead
of offering it and being refused. Answers 200 for everybody, anonymous included;
the per-proxy map is withheld from a caller who may not
list, because keyed byproxy name it would be a proxy listing by another route.
Every field is
authorize()evaluated, never a second reading of theaccessblocks.
the_report_agrees_with_what_the_request_actually_answersissues all sixactions for all three callers and asserts a reported
falsecomes back 401 or 403.And a second:
GET /api/v1/schemaThe configuration's JSON Schema, generated from the same
ToSchemaimpls theOpenAPI document is, served from the running process and cached for ten minutes.
It exists so the page's rules are the binary's rules. Every pattern, bound and
enum the form refuses a value by is read from this document at load, which means a
field cannot drift from what the server will accept -- the failure the page used
to have was a regex duplicated in TypeScript and then relaxed in Rust.
Unauthenticated, like the OpenAPI document beside it: it describes the shape of a
configuration, not a configuration.
The dashboard
admin.dashboard(default on) andadmin.title(defaultDoppel). Neitherneeded a migration, which is the first dividend of the change above.
and reload.
shows the file name in a disabled field and can be switched to a body or JSON
from there; nothing in the form uploads, renames or deletes one. Two rounds of
building that section ended in removing it -- a page that edits template files
is a file manager with a proxy form attached, and the documentation now says
templates are unsupported in the dashboard rather than half-offering them.
"Continue without a token", and signing out returns to the anonymous view
instead of a wall. Controls for actions the caller lacks are disabled with the
reason, not hidden.
paused while the tab is hidden.
frontend/distcompiles and answers 503 at/, socargo installstill workswithout Node; CI sets
DOPPEL_REQUIRE_DASHBOARD_ASSETSso that concessioncannot apply there, and the release refuses to publish a binary without the page.
allows
script-src 'self'with nounsafe-inline. Every less-than in that JSONis escaped: mutation-checked, since
admin.titleaccepts markup on purpose.robots.txt-- becausecrawlers fail differently.
What the first round of use asked for
/api/v1/schemasent: patterns, lengths, and the minimum and maximum of every number. The
server's own complaint, when one arrives anyway, is attached to the field it
names rather than shown as a banner.
YAML, and Jinja expressions wherever a value is rendered through the template
engine -- including inside a JSON body, which needed the two grammars composed
by hand: prism gives overlapping greedy patterns to whichever starts first, so a
single top-level Jinja token silently misses every expression inside a string.
generated parameter reference. The page is generated from the schema by
scripts/parameters_doc.py-- 82 parameters, 73 with an example -- with a--checkgate so it cannot drift, and every link the page builds carries theversion of the binary that built it.
scripts/check_docs_links.pyresolves thefrontend's links against the built site, so an anchor cannot go missing quietly.
that indents rather than leaves the field, formatting on save and on a button,
a link to the documentation, and live validation against the running schema --
a message under the editor, not a save that fails.
save asks only when it would remove something, and it names what: each
injected header, access override, mock, entry of a mock's four maps, and a
template file the answer was switched away from. A changed value is not
confirmed -- it is visible in the field the operator is looking at.
with it. A collision is refused before anything is written, and a rename that
moved the files but could not save the document reports both names.
retries it, beside the one that enters a token.
copyright left and links right; the year taken from the build date rather than
the browser's clock, because the page ships inside the binary.
Two fixes that were not the page's
/etc/doppel/main.yamlas a single-file bind mount cannot be saved to. Asave writes a temporary file and renames it over the target, and a rename onto a
bind-mounted file is EBUSY. Reproduced in a container before and after: the
compose file and the documentation now mount the directory, and the error names
the mount and says which to change instead of reporting the errno.
react-simple-code-editorrenders a<style>element per editor, and thepolicy allowed no inline stylesheet, so an operator reading the console for
their own problems found dozens of ours. The policy names that one stylesheet by
hash -- recomputed by a jest test, so an upgrade of the package fails the build
rather than the console -- and an e2e test asserts the console stays empty.
The image
docker build .works on a fresh checkout now. It prefers a staged binary,compiles one when none was staged, and builds the dashboard itself when
frontend/distis not in the context -- verified both ways, including that thebundle built inside the image is byte-identical to
make frontend's. It refusesonly when there is neither a built dashboard nor the sources to build one, which
means the context is not what it should be.
Payload
Gzipped: 73.4 KB entry, 141.5 KB in total, with the code editor (12.7 KB) and the
YAML mode (36.0 KB) in chunks nobody who only lists proxies downloads. Held there
by
bundle-size.test.ts, which also asserts the entry chunk does not containprism -- a size limit alone would let the highlighter hide in the entry's slack
and every visitor would pay for it.
What running it found
Everything above was written before the branch was run against a real deployment.
These are the things that only showed up there, each reported from the outside and
fixed with the test that would have caught it:
/metricsgot the dashboard. Moving the exposition under/api/v1/was recorded as breaking on the grounds that the old path would 404.It did not: everything outside
/api/and/static/is answered by the page, soa scraper received
200 text/htmland 1412 bytes of HTML. The exposition is/metricsagain, and a test asserts the content type there with the dashboardenabled.
slash
utoipa-swagger-uiredirects the bare path to, and the pair became aloop. The trimming is ours now, with one exception for that subtree; the UI is
/swagger-ui/and the document/openapi.json, both outside/api/because apage and a description are not resources of the API.
PUTanswered200, wrote thedocument, and left the proxy forwarding to the old upstream until somebody
reloaded -- the API agreeing with the operator while the process disagreed with
both. Writes promote the stored configuration before replying now, through the
reload endpoint's own path and mutex.
rewritten
Locationhad no host in it at all. Both name Doppel now, fromDOPPEL_EXTERNAL_URL,server.external_url, orserver.host:portwith awildcard read as loopback -- and startup logs which of the three it used, because
the third is a guess behind a port mapping.
rewrite_urls: the same escape one layer down, through a body that names theupstream. Exact host only, text only, uncompressed only, bounded by
body_limit,and a rewritten body loses the validators it invalidated.
mocksflake, root-caused.free_portbound127.0.0.1:0; withSO_REUSEADDR, macOS lets that shadow a wildcard listener on the same port andreset whatever connection arrives in the microseconds before the probe closes.
The suite runs
main.example.yamlwithhost: 0.0.0.0, which is why the flakelived there. 6 failures in 540 runs before, 0 in 500 after.
Metrics, and what a scrape answers now
doppel_proxy_request_duration_secondscarriesreplace,lossandupstream_error, and reaches 60-second buckets becauselatencyinjection is askedto be slow on purpose.
doppel_admin_request_duration_secondsis the admin API'sown latency, labelled by route template so a hundred proxies are one series.
Four series exist before anything happens --
doppel_build_info,doppel_dashboard_info,doppel_proxy_last_error_timestamp_seconds{code=""} 0anddoppel_proxy_mocks-- because a panel and an alert both read a never-recordedmetric as "no data", which is indistinguishable from a process nobody is scraping.
The mock counts follow the configuration in force rather than the one the process
started with.
On the wire
The listener compresses what it sends,
brorgzipby negotiation: the dashboardis 74 KB instead of 140, and the Swagger UI's stylesheet another 150 KB smaller.
index.htmlis minified on the way into the binary -- it is the one file thatcannot be cached, since the configuration is spliced into it per request. Every
admin path accepts a trailing slash; the proxy listener still relays one verbatim,
because
/orders/and/ordersare two resources upstream.Branding
A deployment that names itself gets its name in the wordmark's italic serif, split
on spaces, underscores, hyphens and case changes, toned like
Doppel+ganger.Its footer says "Built with Doppel ", links to "Doppel Documentation", and
drops the repository link, which on
billing-api (staging)would read as a link tothe billing API's own. An unnamed Doppel keeps the footer it had.
Tests
760 Rust, 119 jest, 64 Playwright. The browser suite earned itself on the first
run by finding three bugs nothing else could:
selected the store's
mayfunction, whose reference never changes, so thereport's arrival re-rendered nothing.
name, theTypeScript model called it
file.while working on the dev server -- the package is CommonJS and the production
interop hands over
module.exportswhere the component was expected.Two of those are only reachable through a browser against a built binary.
New dependencies
Runtime, all in
LICENSEandTHIRD-PARTY.md:react,react-domreact-routercreateBrowserRouter, whose loaders and fetchers are unused and cost 16 KB gzippedzustandprismjs,react-simple-code-editor@cfworker/json-schemanew Function, and the page's policy has nounsafe-evalyamljs-yamlpullsargparse, which is Python-2.0 and outside the dependency licence policyBuild-time only, so absent from the notices by design: vite, jest, eslint,
Playwright, tailwind and their tooling. That classification is load-bearing --
tailwind's closure carries Blue Oak and CC-BY, and the wrong fix would have been
to widen the licence policy for code the project never distributes.
No new Rust crates.
Also
Makefile:make help,make gate,make image-rebuild,make docs-serve.It exists for the ordering that is easy to get wrong -- the assets are embedded
at compile time, so
npm run buildalone changes nothing about a binary.assets/at the root, shared by the docs build and thefrontend, staged into
docs/assets/by a mkdocs hook.favicon.icoisgenerated from a small-size variant of the mark, because the full one collapses
into a smudge at 16 pixels. Tracked through git-lfs, except
icon-128.png:Docker Hub reads it through
raw.githubusercontent.com, which serves the LFSpointer rather than the bytes -- verified, not assumed.
THIRD-PARTY.mdnow covers both ecosystems, andscripts/third_party.pyrefuses to run
--checkwithoutnode_modulesrather than comparing againsthalf a graph.
/api/, so one prefix separates the API from thepage and the page's own routes cannot collide with a future endpoint.
AGENTS.mdsays which checks belong to iterating and which to finishing: thegate is ten targets now, and running it after every task spends minutes
re-checking what the task did not touch.
Verification
make gateclean: fmt, clippy with warnings as errors, 795 tests, eslint, bothTypeScript projects, 127 jest tests, the size budgets, a strict docs build, the
schema, the parameter reference, the documentation links and the licence check.
make e2eclean against Chromium, 81 tests. The image built from a context with nofrontend/dist, run, and its assets fetched. The metrics, the Swagger UI, theOpenAPI document, the compression negotiation and the substituted page are asserted
in a real browser, because each of them broke once without failing a test.