Repository navigation
ui: the web interface - #38
Merged
Merged
Conversation
added 30 commits
September 25, 2026 10:03
public/theme.js is added to Biome's ignore list: it is a deliberate ES5 classic script (runs before anything else, in any browser, ahead of the content security policy allowing no inline script at all), not code Biome's ordinary rules should reformat or flag for var/empty-catch.
…pace/re-export imports in the source rules The colour rule missed named colours (fill="white", color: white;) and the four palettes the installed Tailwind version added (mauve, olive, mist, taupe). The runtime-import rule missed a side-effect import and false-positived on a multi-line import type. The Radix rule missed a namespace import and a re-export. The build-output test for a missing or unknown stored theme only exercised the unknown case.
…doned report stop waiting
…idden-tab guard, the footer's closing sentence, and the checking phase after sign-in; say when a session did not stick
…and keep the picker in step with the address
… rules to the summary and chart
…age's stale navigation
…t its own query, and drop stale data after a failure
added 26 commits
September 25, 2026 18:08
…ollows the address
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.
This adds the web interface: a React app the admin service serves on its own host, behind the same host guard, with every screen built on the admin API and nothing on it that a script cannot also do. It also adds the four API pieces the screens needed and could not get from what was there.
The screens
Overview, Links, Clicks, Domains, Settings and Account, each with one
<h1>, in light and dark themes, and usable at 390 pixels wide. A link has its own report page, and its create and edit form covers every setting the API takes. The account screen enrols an authenticator with a QR code, shows recovery codes and a new API key once, and lists the sessions signed in, with the address each came from, the one place the product shows a whole address, because these are the operator's own devices.Every time on screen is in the browser's own zone, chosen over a zone set once for the whole install. A day chart's days still begin on a whole hour of UTC, because the rollups are kept by the hour, so a zone that is not a whole number of hours from UTC has its days begin at 23:30 or 00:30, and the screen says so. That was forced by the rollup grain rather than preferred. Presets and pickers are computed across daylight-saving changes, including zones where a change skips midnight.
Editing a link sends only what changed, so a save cannot overwrite a field someone else changed meanwhile unless it was the same field. The export states its row count, and whether it will reach the cap, before the file starts.
Nothing polls. The numbers update on Refresh, or when the tab comes back into focus after a minute away. A report request that fails with
429 too_many_reportsis retried, and at most two are in flight from one browser.Secrets live in component state and nowhere else. A new key, the recovery codes and the enrolment secret are cleared by their dialog's close event, whatever closed it, and never reach storage, a URL or the console. The
otpauth:URI is never rendered.The security policy, and what it forced
The interface is served with
default-src 'none', scripts, styles, images, fonts and connections from'self'only,frame-ancestors 'none'andbase-uri 'none'. Three choices follow from that and were forced rather than preferred. The alternative was a nonce minted per response, which would make the page uncacheable, for components the platform already provides:<dialog>, not a Radix overlay, because the overlay primitives inject a<style>element at runtime that the policy refuses.<rect>elements, not a data URL, soimg-srcstays'self'.A source-rule test refuses colour in source,
dangerouslySetInnerHTML,eval, runtime imports from service packages and Radix overlay imports. A build-output test refuses inline script and style, and checks that the bundle evaluates and renders. Static assets are immutable under/assets/. The page fallback is narrow: a GET, not under/api,/healthor/assets, and with no dot in the path. Range requests to an asset answer 200, not 206.The build now copies
brand/into the build stage, and.dockerignoreno longer excludes it, because the interface's stylesheet imports the brand's generated tokens. Only the build stage reads it: Vite writes what it takes intopackages/ui/dist, and nothing at runtime readsbrand/.What the API gained
offseton the chart, in whole hours from −12 to 14, which moves where a day bucket begins.linkbreakdown dimension, labelled with each link's domain and slug.GET /api/clicks/count, so the export can state its row count and whether it will reach the cap before the download starts.qsearch, and each link carriescreatedAtandcapUsed.GET /api/status: the newest reported hour, whether reporting is on, IP data from the manifest through a read-only mount, and the alert count. It is what the header's freshness line and the "domains need attention" link read, and is as far as admin: tell the operator when a domain stops verifying #26 goes here: the install surfaces a failing domain on every screen, and still sends nothing.Postgres 009 adds one index,
links (created_at DESC, id DESC), for the list's order.SCHEMA_VERSIONis 9. The migration is notCONCURRENTLY, because the migrator runs each file in a transaction. Its lock blocks writes tolinksonly while it builds, and does not block the redirect's snapshot load.What it does not do yet
The domain list keeps its cap of 500 and says on screen when it is truncated, so paging it is still #22. There is no time zone setting on the install, only the browser's own zone. There are no bulk operations, and no local development mode (#23), so trying the interface needs a real admin host, the same as the API does. A CSV of a report is made in the browser, not streamed by the API. A report the browser abandons still runs to completion on the server and holds its slot until then (#37).
Closes #25
Closes #24
Verification
The gate ran in CI's order:
pnpm lint,pnpm lint:sh,pnpm typecheckandpnpm build, thenpnpm test.pnpm testran 65 files, 1,550 tests for the services and 44 files, 560 tests for the interface, and the interface suite passed five runs in a row. The stack suites passed 86 tests across seven files. That includes a browser suite that runs the built interface in Playwright against the real stack over TLS. It signs in with a second factor it computes, creates a link through the form, clicks it, sees the click arrive, and exports it. It fails on any content-security-policy violation on any page, and on any signed-in screen that scrolls sideways at 390 pixels. WithCLICKMONK_SHOTS=1it writes all 36 screenshots, of every screen in both themes at two widths. The durability suite still loses no click across restarts.The screenshots were read, and they changed things. They showed tables that scrolled the whole page sideways at 390 pixels, a theme toggle whose icon ignored a system change, and native controls drawn light in the dark theme. Each is fixed, and the first is now a failing check. Every pairing the interface draws is measured by the brand's contrast script, which fails rather than asserts. Three pairings were added to
brand/contrast-report.txt. Measuring the colours as drawn found four more: focus rings at half opacity, an active nav item and a pressed chart toggle that differed from their surroundings only by a fill at about 1.2:1. They now use roles that measure 5.9:1 or better, and a source rule refuses a translucent focus ring. Content refreshing in place still dims to half opacity while it reloads.Every guard was mutated individually and caught by exactly its own test. Where a mutation passed, the test was the defect, and it was repaired in its own commit.