Files
technitium-console/CONVENTIONS.md
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

864 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# How this console is built
Read this through before touching anything. It is short and it saves you the
mistakes that have already been made.
## The rule that rules them all: design only, zero functionality
This console **replaces the Technitium DNS Server interface without changing what
it does**. Same controls, same steps, **same texts**, same validation order. Any
behavioural difference from the upstream console is a bug, even when it looks
like an improvement.
If you catch yourself thinking "while I am here, this could be better": no. That
is a different job.
The exceptions are few, decided and written down: see
[Deliberate deviations from upstream behaviour](#deliberate-deviations-from-upstream-behaviour).
The largest is the Blocking section, which replaces Allowed and Blocked. Those that
add something carry written limits of their own.
## Where the reference is
The console this one replaces is not in this repository. You read it from the
server's own repository, which has to be added as a remote once:
```bash
git remote add upstream https://github.com/TechnitiumSoftware/DnsServer.git
git fetch upstream master
git show upstream/master:DnsServerCore/www/js/zone.js
git show upstream/master:DnsServerCore/www/index.html
```
**The alert texts are contract.** Pull them out of there with
`grep -o 'showAlert("[^"]*", "[^"]*", "[^"]*"'` and copy them literally, without
rewording.
## Do not assume the shape of the responses: check it
There are two disposable instances in `dev/`:
- `dev` at <http://127.0.0.1:5380>: serves our build
- `ref` at <http://127.0.0.1:5381>: the upstream console, untouched
User `admin`, password `technitium-ui-dev`. Bring them up with
`docker compose up -d` from `dev/`.
```bash
T=$(curl -s "http://127.0.0.1:5381/api/user/login?user=admin&pass=technitium-ui-dev&includeInfo=false" | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s "http://127.0.0.1:5381/api/zones/list?token=$T" | python3 -m json.tool | head -40
```
Three assumptions that have already turned out false, so you do not repeat them:
1. **Not everything comes wrapped in `response`.** `user/login`,
`user/session/get` and `status` return the payload **flat**. Everything else
does wrap it. That is why `apiRequest` **unwraps nothing**: it hands over the
JSON as it came.
2. The second-factor literal is **`2fa-required`**, not `two-factor-auth-required`.
3. The statistics are flushed **by the minute**. A `dig` you just ran does not
show on the Dashboard until the next flush; do not conclude something is broken.
## Upstream behaviours discovered along the way
Write down here whatever you find. What is already known:
- **Optional fields that look required.** In `apps/list`, the `updateVersion`,
`updateUrl` and `updateAvailable` fields **only exist** if the app is in the
store catalog and there is a compatible version there; an app installed from
your own zip never brings them, and if the catalog query exhausts its 5 s
timeout the list arrives whole but without them on any app. Typing them as
required is a guaranteed bug. Be suspicious of every field you have only seen
once.
- **A text field can come back as `null`.** `apps/config/get` returns
`config: null` as soon as someone saves an empty configuration.
- **The upload endpoints are POST-only**: by GET the server answers **404**, not
a JSON error.
- **On uploads, the file field name does not matter**: the server takes
`Form.Files[0]`. But **do not set `Content-Type` by hand** or the `boundary`
is lost and the server says the file is missing.
- **Deleting something that does not exist usually answers `ok`**, not an error.
- **There are two different paginations.** `zones/list` paginates on the server
(`pageNumber`, `zonesPerPage`) and its paging fields **only appear if you send
`pageNumber`**. `zones/records/get`, on the other hand, **does not paginate**:
it is asked for with `listZone=true` and paginated on the client.
- **Where an alert comes out is not cosmetic**: in upstream, a modal's alerts
come out inside the modal and a screen's come out on the screen. Honour it.
- **The server can return a domain different from the one you asked for.** When
navigating the cache/allowed/blocked tree, `WebServiceOtherZonesApi.cs`
**walks down on its own** while the node has no records and has exactly one
child. Always draw `response.domain`, **never** the domain you asked for, or
the tree and the table fall out of sync.
- **The same field can have two types depending on the endpoint.** In
`cache/list` the `ttl` is an already-composed string (`"218 (3m38s)"`); in
`allowed/list` and `blocked/list` it is a number with `ttlString` apart. Same
with a SOA's `refresh`, `retry`, `expire` and `minimum`. Typing the records
with a single shape is a guaranteed bug.
- **`0001-01-01T00:00:00` is .NET's `default(DateTime)`**: it means "never", not
year 1.
- **Visibility rules that look the same and are not**: deleting a node is
offered in cache if the node is not the root, and in allowed/blocked if the
node has records. Do not make them uniform without looking.
- **A count may not be where you draw it.** `blocked/list` only reads the
manually blocked zones; the downloaded block lists are counted **only** in
`dashboard/stats/get` (`blockListZones`).
- **Careful with replicating the intent instead of the behaviour.** In
`other-zones.js` there are three `domain.toLowerCase();` **without assigning
the result**: they do nothing. What the code does is replicated, not what it
looks like it wants to do.
- **`settings/get` OMITS the null keys**, it does not send them as `null`:
fields like `temporaryDisableBlockingTill` or `blockListNextUpdatedOn` simply
do not appear on a freshly installed server. Others do arrive as an explicit
`null`. Declaring them required fails against a new install. And
`blockListNextUpdatedOn` is not only a fresh-install gap: it is written only
while a block list update is scheduled (WebServiceSettingsApi.cs:382-386), so
saving an empty list makes it vanish from the answer. Settings › Blocking and
the Lists tab of Blocking both read its absence as "Not Scheduled".
- **`logs/query` filters by ONE `responseType`** (WebServiceLogsApi.cs:182-185),
but the Dashboard's `totalBlocked` adds up three: `Blocked`, `UpstreamBlocked`
and `UpstreamBlockedCached` (StatsManager.cs:329-341). "The latest blocked
queries" is therefore three queries, merged by time: that is what Recently
Blocked does (`api/blocking.ts`).
- **Careful with `\r\n` when replicating a list textarea.** Upstream builds its
textareas with `\r\n`, but the browser normalises to `\n` when reading the
value of a `<textarea>` from the DOM, and its cleanup only substitutes `\n`.
In React there is no intermediate DOM to normalise: copying the literal
`\r\n` sends `forwarders=1.1.1.1%0D,8.8.8.8%0D` to the server. **It bites any
screen with lists in a textarea.**
- **An empty list travels as the string `"false"`**, it is not omitted: it comes
out of concatenating a boolean into the query. With three exceptions that fall
to their default value.
- **A screen with sub-tabs can be ONE SINGLE form.** In Settings, "Save" sends
the fields of all nine sub-tabs wherever you are. Chopping it up per tab would
change what gets saved.
- **An action bar can mix different permissions**: in Settings, saving requires
`Settings.canModify`, flushing the cache `Cache.canDelete` and the backup
`Settings.canDelete`.
- **Asymmetric permissions**: some actions ask for `Delete` where you would
expect `Modify`, and `apps/list` is allowed with read permission on Apps,
Zones **or** Logs. Do not deduce the permission: look at it.
- **Not every endpoint returns JSON.** `logs/download` answers `text/plain` with
the file when it goes well and `application/json` with the usual envelope when
it fails. It cannot go through `apiRequest`: that would do `res.json()` and
turn any log into a network failure. Upstream asks for it with
`isTextResponse` and, if what arrives carries `status`, draws it **formatted
inside the viewer itself**, as if it were the file's content
(logs.js:170-172). The error does not come out as an alert.
- **Two endpoints of the same family can call the same datum differently**: the
log file is asked for with `fileName` in `logs/download` and with `log` in
`logs/delete`. And `logs/list` returns the name **without extension**, which
is the one to send in both.
- **Confirming or not confirming is not symmetric.** In DHCP, disabling a scope
and deleting it both ask; **enabling it asks nothing** (dhcp.js:583 vs 615).
Only the path that cuts the service asks.
- **`dhcp/scopes/set` is a PARTIAL update**: each field is applied only if it
comes in the request (`WebServiceDhcpApi.cs:390-650`), so a body with only
`name` and `newName` renames without touching anything else (checked against a
v15.4 instance). Upstream always sends all 36. And **a scope created with this
endpoint is born enabled**, even though nothing says so.
- **`dhcp/scopes/get` OMITS fifteen optional keys** instead of sending them
`null`: `domainName`, `domainSearchList`, `serverAddress`, `serverHostName`,
`bootFileName`, `routerAddress`, `dnsServers`, `winsServers`, `ntpServers`,
`ntpServerDomainNames`, `staticRoutes`, `vendorInfo`, `capwapAcIpAddresses`,
`tftpServerAddresses`, `genericOptions` and `exclusions`. `reservedLeases` is
the exception: it is always written, even as `[]`. Same for `interfaceAddress`
in `scopes/list`.
- **A field can be omitted on purpose when saving**: with "Use This DNS Server"
checked, upstream **does not send `dnsServers`** (dhcp.js:565). The server
keeps the stored ones and `scopes/get` still returns them, so the list you see
on screen is not the one that was just sent.
- **"Deleting something that does not exist answers `ok`" has exceptions.**
`dhcp/scopes/delete` on a non-existent scope answers `ok`, but
`dhcp/leases/remove` on a non-existent lease answers **error**
(`No lease was found for client identifier: …`).
- **Two alerts that look like the same one and are not.** In Query Logs, the
"the app is missing" one ends in "…from the Apps section." when "Query" fires
it and **does not** when "Export" does (logs.js:391 vs 614). Copying one into
both places changes a text.
- **The last page is asked for with `pageNumber=-1`**: the server resolves it.
That is what the "Last" link of Query Logs does (logs.js:589), verified
against the reference instance.
- **The `ts` cache-buster goes on only two of the six downloads**: the settings
backup (main.js:3100) and `logs/download` (logs.js:196). `logs/export`,
`zones/export`, `allowed/export` and `blocked/export` **do not carry it**. The
server ignores it, but the URL that gets opened is not the same.
- **A form filter can live in `localStorage`**: "Logs Per Page" is stored under
the key `optQueryLogsEntriesPerPage` and re-read on every reset (logs.js:23-26
and 63-65). Careful: the form's default value is **10** and the server's is
**25** (`WebServiceLogsApi.cs:162`); the form's wins because upstream always
sends the parameter.
- **`serializeTableData` decodes TWICE.** It applies `htmlDecode` to a value the
browser had already decoded when parsing the HTML, so typing `&amp;` in a cell
sends `&`. In React there is no intermediate HTML: replicating it would mean
introducing the bug by hand, and that is not done.
- **Quick Select forgets the Bypass List.** Picking a forwarder that carries a
SOCKS5 or HTTP proxy (Tor) ticks the proxy type and enables its address, port,
username and password, but not the Bypass List (main.js:594-603): it stays
disabled until the proxy radio is clicked again. That is an omission, not a
rule, and it is not replicated: here the five fields follow the proxy type.
- **Picking the line already chosen does nothing.** A native `select` fires
`change` only when the value changes, and several stock lists rely on it
(Group Details' "None" empties Members; Quick Add would add a deleted key
back). `ui/Select` does the same, and the Quick Add and Quick Select lists
keep the line chosen, as the stock ones do, until their form loads again.
- **A `set` can return less than its `get`, and what is missing has to be kept.**
`admin/sso/set` does NOT bring `localGroups`: `WriteSsoConfig` only writes them
with `includeGroups` and the `set` calls it with `false`
(WebServiceAuthApi.cs:1790). Upstream survives because it stored them in a
global variable when doing the `get`. Reloading the form with the save
response without keeping them leaves the group-map dropdowns empty.
- **A secret can come back MASKED and has to be sent back that way.**
`admin/sso/get` returns `ssoClientSecret: "************"` as soon as one is
stored, and `SetSsoConfig` ignores that exact value
(WebServiceAuthApi.cs:1738). It is what allows saving the form without typing
the secret again: clearing the field "because it looks like filler" would
delete the real secret.
- **The same action can send different parameters depending on where it is
fired from.** `admin/sessions/delete` ALWAYS travels with `node` from the
Sessions tab (the primary node if the session is an API token, the chosen node
in any other case) and **with no `node` at all** from the user details modal
unless it is an API token (auth.js:1050 vs 1382).
- **A checkbox can change the ENDPOINT, not a parameter.** The cluster's "Force
Remove Node" picks between `primary/deleteSecondary` and
`primary/removeSecondary`; "Force Leave" and "Force Delete" in the same block
really are parameters. They cannot be made uniform.
- **Deleting something that does not exist does NOT always answer `ok`.**
`admin/sessions/delete` with an invented partial token answers `error` with
"No such active session was found for partial token: …". Checked live against
a v15.4; it is the exception to the rule noted above.
- **The group list depends on the endpoint that serves it.**
`admin/permissions/get?includeUsersAndGroups=true` includes `Everyone` and
`admin/groups/list` does not; `admin/sso/get?includeGroups=true` excludes it on
purpose (WebServiceAuthApi.cs:383). Three group lists, three contents.
- **Two sibling endpoints can return different shapes of the same object.**
`admin/groups/create` answers `{name, description}` and `admin/groups/set`
also answers `members`. Same with the user: `users/get` brings `groups` (all
the server's), `users/set` does not bring it even though it does bring
`memberOfGroups` and `sessions`, and `users/list` and `users/create` bring
none of the three. Checked live.
- **Half the response disappears when the cluster is not initialised.**
`admin/cluster/state` on a standalone server is THREE fields: `version`,
`dnsServerDomain` and `clusterInitialized`. `clusterDomain`, the four
intervals and `clusterNodes` only exist with a cluster
(WebServiceClusterApi.cs:60-75). And within a node, `upSince`, `lastSeen` and
`configLastSynced` are **omitted** when they hold `default`, they do not
arrive as `null`.
- **The cluster's "Quick Add" compares by SUBSTRING.** `cluster.js:30` uses
`existingList.indexOf(ip) < 0`, so with `10.0.0.10` already in the list the IP
`10.0.0.1` is never added. It is a bug of theirs and it is replicated: what
the code does is copied, not what it looks like it wants to do.
- **A success alert dismisses itself after 5 seconds.** `showAlert`
(common.js:212) schedules a `hideAlert` for `success` alerts and only for
those. It is replicated here.
- **A whole section may filter NOTHING by permission.** Inside Administration,
upstream checks `Administration.canView` to show or hide the section
(main.js:165 and 240) and from there shows every button, letting the server
reject. Adding client-side gating there would be adding behaviour, not
protecting it.
- **Asymmetric permissions, the concrete Administration case**:
`permissions/set` and `sso/set` ask for `Administration.canDelete`, not
`canModify` (WebServiceAuthApi.cs:1533 and 1692). In the cluster, nearly
everything asks for `canDelete` (including `init`, `initJoin` and `promote`),
but `setOptions`, `resync`, `updatePrimary` and `updateIpAddress` ask for
`canModify`.
- **`zones/list` OMITS `dnssecStatus` and `hasDnssecPrivateKeys` on Catalog and
Forwarder zones**, and the Catalog also omits `catalog`. They are types that
cannot be signed, so the server does not even write the fields. Declaring them
required lies about half the list and in TypeScript it shows late.
- **The same table calls the same column differently.** In
`zones/permissions/get`, a user permission brings `username` and a group one
brings `name`. Treating them as the same shape leaves half the table blank.
- **`records/delete` has no branch for CNAME, DNAME, SOA or APP**: all four fall
to the `default`, which only sends `rdata` if it exists, and it exists for
none of them. The server receives zone+domain+type and nothing else. And
**deleting an NS does not send `glue`, but disabling it does**: same pair of
actions, different set of parameters.
- **Disabling a record reads the expiry TTL FROM THE MODAL, not from the row**
(`updateRecordState`, zone.js:6236). If the modal has never been opened, it
sends the empty string; if it was opened, it sends whatever was left inside.
It is an upstream bug: it is replicated, and that is why the records screen
drags that value along.
- **A record filter starting with `*` looks for the literal wildcard**, it does
not list everything: after converting the glob to a regex, `showEditZonePage`
rewrites a leading `.*\.` to `\*\.`. It serves to find the `*.zone` record,
which in DNS really is called that. It looks like a bug and it is not.
- **The name filter without a wildcard is EXACT**, not "contains": typing `www`
does not find `www.sub`. And it lowercases what you typed but **not** the zone
name.
- **`zones/create` is a POST with the parameters in the QUERY**: the body is
reserved for the optional zone file (`fileImportZone`). Without a file,
upstream sends a POST with no body at all.
- **`zones/import` has TWO ways of sending the file**: uploading it goes as
multipart and pasting it into the textarea goes as **raw plain text** with
`Content-Type: text/plain`. The server tells them apart by that type.
- **The bulk zone delete uses the SAME endpoint** with the parameter in plural
(`zones=`, comma-separated) and returns `deleted` and `failed`. When some
fail, the alert is NOT an error: it is a `warning` counting how many.
- **The pagination window slides backwards on reaching the end**: on the last
page the last ten are visible, not just one. And the last is asked for with
`pageNumber=-1`: the server resolves it.
- **In the zone options, six empty lists travel as the string `"false"` and two
do NOT**: `primaryNameServerAddresses` and `queryAccessNetworkACL` travel
empty as they are. It is not symmetry; it is what `saveZoneOptions` does.
- **A zone that is a member of a catalog inherits its options**, and that
governs the whole interface of `modalZoneOptions`: if the catalog does not let
it override a section, that tab DISAPPEARS; if it does, it appears editable;
and if a secondary catalog administers it as well, it appears read-only.
- **The tab that comes up open in the zone options is not the first**: on a
Catalog it is "Query Access", and on a Primary it depends on whether there are
catalogs available.
- **`convertZone` offers only three destinations** (Primary, Forwarder and
Catalog), and which of them are enabled depends on the source through a table
that follows from nothing: a Primary can only go to Forwarder.
- **The year has to be padded to four digits.** `0001-01-01T00:00:00` is .NET's
`default(DateTime)` and turns up on every unused record; without padding,
`getFullYear()` gives "1-01-01", which is not what moment writes.
- **In "Add Zone", the Catalog type shows NOTHING**: it has no branch in the
visibility `switch`, so only the name and the type remain.
- **"Secondary ROOT Zone" is not a type**: it is a Secondary with the root
server addresses preloaded, `zoneTransferProtocol=Tcp` and `validateZone=true`.
The type that travels is `Secondary`.
- **The labels and the values of the DNSSEC dropdowns do not match**: you see
"SHA256 (default)" and `SHA256` travels; you see "Ed25519 (default)" and
`ED25519` travels in UPPERCASE.
- **In the DNSSEC properties, `isRetiring` switches off every action** of a key,
and the automatic rollover only exists for ZSKs.
- **Since v15.5 a user has a `type`, and a remote user LOSES a field.** `user/login`,
`user/session/get`, `user/profile/get` and every `admin/users/*` bring `type`
(`Local`, `RemoteSSO`, `RemoteLDAP`) next to the obsolete `isSsoUser`, and
**`totpEnabled` is omitted for a `RemoteSSO` user**, not sent as `false`
(WebServiceAuthApi.cs:77-82 and 140-146). In the details, `ssoManagedGroups` is
now written ONLY for SSO users and `remotelyManagedGroups` for every type
(`false` for `Local`, lines 158-172). A screen still reading `ssoManagedGroups`
unlocks the groups of every LDAP user. Checked live against v15.5.1.
- **`admin/ldap/get` on a fresh install is half `null`**: `ldapServer`,
`ldapBindUsername`, `ldapBindPassword`, `ldapSearchBase`, `ldapUserSearchFilter`
and `ldapGroupAttribute` arrive as `null`, `ldapPort` as `389`, and
`ldapAllowSignupOnlyForMappedUsers` is **`true` while `ldapAllowSignup` is
`false`**: the box comes up checked and disabled. `ldap/set` repeats SSO's
lesson and drops `localGroups` (line 2136). Checked live against v15.5.1.
- **"Test Connection" is not a dry run of "Save".** It validates server and port
even with LDAP disabled, ignores the group map and never asks the "Ignore SSL"
confirmation (auth.js:2526-2541). The server fills any missing parameter from
the STORED config and swaps the masked password for the stored one
(WebServiceAuthApi.cs:2146-2160). Its error answers with no `response` key at
all: `{"server", "status": "error", "errorMessage"}`.
- **`zones/export` answers an error with HTTP 200 and JSON**, the file with
`text/plain`: a missing zone gives `{"status":"error","errorMessage":"No such
zone was found: …"}` and a bad token `invalid-token`, both 200. The v15.5 "Edit
Zone File" dialog reads it as text and, like `logs/download`, puts any answer
carrying `status` FORMATTED into its textarea (zone.js:1262-1263 in v15.5.1): no
alert, not even for `invalid-token`. Checked against v15.5.1.
- **`zones/import?overwriteZone=true` replaces the WHOLE zone, NS included.** A
file with only SOA+NS+one A leaves exactly that; an EMPTY `text/plain` body
answers `ok` and leaves the SOA alone. An unparsable file fails before
touching anything, with `errorMessage` ("The zone file parser failed to parse
'rdata' field on line # 1.") AND `innerErrorMessage` ("An invalid IP address was
specified."); upstream shows only the first. With `overwriteSoaSerial=false`
the serial is not kept either: the server bumps it. Checked against v15.5.1.
- **"Edit Zone File" never sends `overwrite`** (zone.js:1293): the server
defaults it to `true` (WebServiceZonesApi.cs:1930). And its textarea is never
cleared on opening, only overwritten when the read succeeds. After a read that
never arrives, "Save" (always enabled) would send what the previous opening
left, possibly another zone's file. Replicated; clearing it instead would send
an empty file, which with `overwriteZone=true` empties the zone.
- **Saving a zone file reloads the zone, never the list** (zone.js:1302-1303):
from the list's row menu nothing is refreshed afterwards.
- **What "Save Settings" sends depends on the node selector** (main.js:1639-1644).
`node=""` sends every block, `node=cluster` only the cluster-wide ones (default
values, EDNS/QPM/advanced, TSIG, Recursion, Blocking, Proxy & Forwarders) and a
node name only that node's own (local parameters, IPv6/socket pool, Web Service,
Optional Protocols, Cache, Logging). A skipped block skips its VALIDATIONS too.
On a standalone server the selector is hidden and holds an empty `<option>`
(cluster.js:1047-1049), so the stock console loads `settings/get?node=` and
saves `node=&…`, never `cluster`, which there would drop every node
parameter. Checked on the stock v15.5.1 of the harness. Flush, backup and
restore take the same selector's node.
- **The envelope's `server` is the domain of the server that ANSWERED**, written
after the handler runs (DnsWebService.cs:2478): after a rename it already
carries the new name, and on a proxied request it is the chosen node's. Upstream
follows the web console to its new address only when it equals the session's
domain, which `updateDnsSettingsDataAndGui` has just rewritten for `node=""` or
the own node (main.js:2208-2217, 3177-3188).
- **After a save or a restore the console may navigate on its own**: 2.5 s later
it opens the new HTTP/HTTPS address in the same tab (`checkForWebConsoleRedirection`,
main.js:2293), unless the LAST LOAD decided a reverse proxy is in front
because the page's port is not the web service's (`checkForReverseProxy`,
main.js:2275). In the harness `ref` is published on 5381 while its web service
listens on 5380, so there the proxy is always "detected" and nothing redirects;
`dev` is 5380 on both sides and does follow a port change.
- **Some notes quote the LOADED settings, not the fields.** The DoH/DoT/DoQ/DoH(S)
addresses and both real-IP header notes are written by `loadDnsSettings`
(main.js:1303-1304, 1356-1357, 1369-1372): typing a new port or header does not
change them until the next load or save. Their initial HTML text
(`localhost:8053`, `tls-certificate-domain:853`) is a placeholder, not contract.
- **Since v15.6 prefetching has its own switch, `enableCachePrefetch`**, true on a
fresh install. A Prefetch Trigger of 0 no longer turns it off; instead, a config
saved by an older server with the trigger below 1 loads with the switch off
(DnsServer.cs, config version 7). A v15.5 server neither sends nor reads the key,
so this console shows it unchecked there and the save is harmless. Checked live
against v15.6.0 and v15.5.1.
- **A cluster node's `version` (v15.6) is not always there.** The node itself
always carries it; another node only once a heartbeat has brought it. Right
after a join the secondary appears without it and in state `Unknown`, and about
30 s later as `Connected` with `15.6`. Checked live on a two-node v15.6.0 cluster.
- **The row menus' jumps carry their arguments as TEXT.** Query Logs writes
`queryDnsServer(…, '<qtype>', '<node>')` into the `onclick` (logs.js:525), so a
row without a type sends the string `"null"` and DNS Client does NOT default it
to "A"; the Dashboard passes a real `null` and does get "A" (main.js:2824,
dnsclient.js:232). Both jumps pick the target's node only when given one that is
not `cluster` (dnsclient.js:243, logs.js:634); from the Dashboard's aggregate
the target keeps the node it had. `allowDomain`/`blockDomain` send no `node` at
all: they always act on the server the console is on.
- **The Dashboard's count cards are links to Query Logs since v15.6.2**
(index.html:205-257, logs.js:21-24). Nine of the eleven: Total Queries with no
filter, the four response codes filling `RCODE` and the four response types
filling `Response Type`; Clients and Dropped are not links. The jump passes no
node and no period, so Query Logs keeps its node and its empty `From`/`To`, even
with the Dashboard on another node or period. Here each card carries the jump
in `dashboard/metrics.ts` (`open`).
- **The Top Stats modal follows the Dashboard's node and keeps its own alerts.**
It reads `optDashboardClusterNode` for `getTop` (main.js:2932), and allowing or
blocking from it reports in `divTopStatsAlert`, inside the modal, while the same
action from the Dashboard's panels reports on the page (main.js:2986 vs 2825).
- **The same action can be worded differently per table.** Opening a user is
"View User Details" in the Sessions row menu (auth.js:938) and "View Details" in
the Users one (auth.js:1191).
- **A blocked answer says why, but only in one setting and never for "allowed".**
With `allowTxtBlockingReport` on (the default), a blocked query carries an
Extended DNS Error with `InfoCode: "Blocked"` and `ExtraText`
`source=blocked-zone; domain=<rule>` (DnsServer.cs:4672) or
`source=block-list-zone; blockListUrl=<url>; domain=<entry>`
(BlockListZoneManager.cs:893), one option per list that carries the name, the
same for every blocking type; `dnsClient/resolve` returns it in
`result.EDNS.Options` and again in the OPT record of `Additional`. `domain` is
the entry that matched, a PARENT of the name asked when a rule or a list covers a
whole zone. With the setting off the answer carries nothing. Nothing marks an
answer as allowed by an allow rule, and an unrelated `Synthesized` option ("Result
from negative cache") can sit in the same list. The client library adds its own
`DnsClientExtendedErrors` (`BlockedByUpstreamDnsServer`), which names no reason.
A name the server refuses (`not a domain`) is an `error` envelope with no
`response` at all. Checked live against v15.6.1 on 2026-10-15
(`screens/blocking/check-domain.real.json`).
## How the code is written
- **Client**: always `apiRequest` from `src/api/client.ts`. Paths **relative and
without a leading slash** (`'zones/list'`), because the server honours
`X-Forwarded-Prefix`.
- **One `src/api/<family>.ts` file per endpoint family**, with its types. It
should return data that is already usable, and `null` or an empty list on
failure, but only where an empty result and a failure cannot be confused. If
the screen would draw them the same, return the whole `ApiOutcome`: saying
"no queries for this period" when the call never arrived is worse than an
error.
- **Primitives** in `src/ui/`: `Button`, `Alert`, `Field`/`LabeledInput`,
`Dialog`. Do not invent loose buttons or fields.
- **Colours always by token** (`var(--acc)`, `var(--ink)`…). Not one `#hex`,
`rgb()`, named colour or colour inside an inline SVG outside
`src/theme/tokens.css`, and inside it only in a theme block
(`:root, [data-theme='dark']`, `[data-theme='light']`), so every colour is one a
theme can redeclare. `color-scheme` too: only a theme block declares it, and
every element inherits it. **`npm run lint:colours` (`dev/check-colour-tokens.mjs`)
enforces it**, and `npm test` runs it too. What genuinely cannot be a token (the
white behind the 2FA QR code) is allowed by file and literal in that script, with
its reason.
- **One CSS module per component** (`X.module.css`).
- **Spacing, type and radii by token too.** In a `*.module.css` you do not write
a px that is not one of the tokens in `theme/tokens.css`. A loose value is
future drift: that is how the console reached 13 text sizes and 25 paddings.
- **Upstream's themes, without Amber.** System (the default), Light and Dark,
picked in `Change Theme` (deviation 1 below). Each theme declares its colours in
its own block of `src/theme/tokens.css`, and `npm run lint:colours` fails if the
light block leaves out a colour the dark one declares. A theme is measured before
it ships: contrast and chart distance against each one, not only against dark,
with `node dev/theme-contrast.mjs` (every text and control token on every
surface it is drawn on) and `node dev/palette-distance.mjs` (the chart series).
- **Everything is in ENGLISH**: the interface, the code, the comments and the
tests. The console is `lang="en"` and the destination is a pull request
upstream. **`npm run lint:language` is what enforces it**, and it exists because
the rule was broken for three weeks without anybody noticing: the visual
redesign left 1,098 pieces of Spanish across 67 files (comments, identifiers and
nineteen file names) while the interface stayed English and every other gate
stayed green. A rule nothing measures is a preference.
- **A name is never cut letter by letter.** `word-break: break-all`,
`word-break: break-word` and `overflow-wrap: anywhere` are refused by
`npm run lint:breaks` (`dev/check-word-breaks.mjs`, also run by `npm test`) outside a short list
of things with no words (keys, signatures, raw output), each with its reason. A
DNS name goes through `ui/DomainName`, a URL through `ui/UrlText`, other text
through `overflow-wrap: break-word`.
- **No `BrowserRouter`**: the server's only `MapFallback` is `/api/{*path}`.
- **No CDN and no fonts in `data:`**: the server's CSP does not declare
`font-src`. Images in `data:` are fine (`img-src 'self' data:`).
## Tests
- `npm test`: vitest. **Do not run `npm run build`** if someone else is
working in the same checkout: it writes into `dist/` and you would step on each other.
- Every screen needs tests for: **the literal alert texts**, the **validation
order**, which endpoint is called and with what body, and the behaviour with
empty data.
- Query by label (`getByLabelText`), not by class.
- **Do not assume a call is the first one**: find it.
`spy.mock.calls.find(c => c[0] === 'zones/list')`.
- With fake clocks, `findBy*` does not work: use
`vi.useFakeTimers({ shouldAdvanceTime: true })` and
`userEvent.setup({ delay: null })`.
- **No apostrophes in a test description written with single quotes.** "the
server's failure" inside `it('…')` does not parse, and it takes the whole file
with it. Reword it: "the failure from the server".
## Deliberate deviations from upstream behaviour
The rule is "zero functionality", with the exceptions below **decided and
written down**. Additional exceptions need the maintainer's authorization.
Security fixes authorized on 2026-10-04 add one transport exception: DNSSEC
signing and private-key import use POST form bodies instead of GET query
parameters. Upstream accepts both methods and reads these fields with
`GetQueryOrForm`. The endpoint, fields and user actions remain the same. Session
revocation and expiry cleanup are bound to the affected token rather than
mutable shared storage. Installer filesystem requirements are recorded in its
contract and SECURITY.md.
1. **Amber is not offered** (Adrián's decision). `Change Theme` is back as
upstream has it: in the account menu, under the same `localStorage` key,
`theme`, as the stock console (same origin, so a choice made in one holds in
the other), with `system`, `light` or `dark`. System is the default and follows
`prefers-color-scheme` live; a radio applies on click; nothing is written on
load. Only the Amber radio is missing: a stored `amber` draws dark, is not
overwritten, and the dialog shows no radio checked until the user picks. It is
still the only one that *removes* something. The rules are in
`src/theme/theme.ts`.
2. **The Blocking section** (`src/screens/blocking/`; what it offers is listed
in the CHANGELOG, v1.2.0). It replaces Allowed and Blocked
with Overview, Rules and Lists. Its Quick Add also offers the console's own
catalogue (`extra-lists.json`) after Technitium's, and a search field.
Its Overview's "Check, block or allow a domain" panel also has **Check**
(`check-domain.ts`, `DomainCheck.tsx`): it tells whether this server blocks a
name and why (which block lists, with the name the Lists tab gives each; which
of your blocked rules; which of your allow rules lets it through; or not
blocked). It asks what DNS Client asks, `dnsClient/resolve` against
`this-server` (type A, UDP, no DNSSEC validation, no import), and reads the
server's own blocking report from the answer; beside it, `settings/get` and
`allowed/export`, each skipped when its permission is missing. It sends no
`node`, as the Block and Allow beside it. Its query is a real one, counted and
logged by the server like any DNS Client query. When the answer cannot prove
the verdict it says so instead of guessing: "Cannot tell" when `Allow TXT
Blocking Report` is off or when the `Blocking Bypass List` covers the server's
own address, and a "Not blocked" lists the reads it did not have. Its words,
all OURS: `Check`, the `Check, ` of the panel title, `Please enter a domain name
to check.`, the verdict titles (`Blocked by a block list`, `Blocked by N block
lists`, `Blocked by your rule`, `Blocked`, `Allowed by your rule`, `Not
blocked`, `Not blocked: blocking is disabled`, `Not blocked: blocking is
paused`, `Cannot tell`), `Listed as`, `Rule`, `Open Lists`, `Open Rules`, the
two "Cannot tell" sentences and the four "not checked" / "Could not read"
lines. Its next step is the panel's own `Block` and `Allow` and the tab that
holds the rule or the list: it adds no action.
The section is the only one that *adds* a screen. It stays inside four limits, and a
change that crosses one is a bug:
- **only endpoints the server already has**, and only ones upstream's console
already calls, save `dashboard/metrics/json` for its live Last Hour
(exception 5). `dev/check-endpoints.mjs` measures the other direction
(upstream's endpoints this console covers), so a new path is checked by
hand against upstream's `www/js`;
- **upstream's actions, sentences and call sequences**: `Allow Domain` is
still `blocked/delete` then `allowed/add` (`api/blocking.ts`);
- **what is ours is in English and says so** in the header comment of its
file;
- **Settings › Blocking is untouched**: the Lists tab edits the same
`blockListUrls`, through the same cleaning (`list-lines.ts` calls
Settings' `cleanList`).
Two behaviours a maintainer would not guess:
- **`/allowed/` and `/blocked/` still exist as folders.** They are legacy
routes (`LEGACY_ROUTES` in `app/static-routes.ts`): `translateLegacyRoute()`
in `app/route.ts` rewrites them, with `replaceState`, to
`blocking/rules/?rule=allowed|blocked` before the route is read, so old
bookmarks land on the filtered Rules tab.
- **`?rule=` belongs to Rules alone.** `Blocking.tsx` strips it on switching to
Overview or Lists and on leaving the section, by replacing the current
history entry, which is the Rules one. So Back after leaving Rules returns
to Rules showing All, not the filter the user had. That is deliberate:
keeping it would mean teaching `writeRoute` about one section's query.
Three additions approved on 2026-10-15, inside the same four limits:
- **Groups in Lists.** A comment line of `blockListUrls` is a group heading:
the lists after it belong to it until the next heading, and the lists before
the first one have no group. Headings can be added, renamed and removed (their
lists stay, joining the group above), a new list can be put into a group, and
a list moves up or down, across headings too. All of it is a change to the
same lines, kept until Save. A heading is saved as `# Name` and refused when
the server would not keep it as one comment line: a comma (the field travels
comma-separated), a newline, more than 253 characters, or a name that reads as
a list URL. Settings › Blocking still shows the headings as the comments they
are. Order changes nothing that is blocked (`list-lines.ts`, GROUPS, with the
server lines that say so).
- **Rules' table deletes without asking, and offers Undo.** Its Delete sends
`blocked/delete` or `allowed/delete` at once and leaves a notice (`ui/Toast`)
whose Undo sends `blocked/add` or `allowed/add` for exactly the domains
deleted, each to its list. Further deletes while it is up join it. Without
`Modify` on the rule's list Undo could not work, so that Delete still asks.
Upstream's confirmation stays everywhere else: the Tree view, Flush and every
other section. This is the only place in the console where a confirmation was
replaced.
- **The sidebar marks Blocking when blocking is off or paused**, with the time
it comes back in the entry's accessible name and title. It reads what the
Overview's status block reads (`settings/get`, on the connected node), never
without `Settings.canView`, and again after any settings write made from this
console, when the browser tab is shown again and a second after a pause ends.
No other sidebar entry carries a mark or a count.
And words of its own in three places, approved on 2026-10-15, all OURS and
declared in the header comment of the file that writes them:
- **a subtitle under each tab's title**, one plain sentence saying what the tab
is for (`OVERVIEW_SUBTITLE`, `RULES_SUBTITLE`, `LISTS_SUBTITLE`, drawn by
`ui/SectionHeader`'s `subtitle`). No other section has one: they keep upstream's
texts only. A sentence that stops being true of its tab changes in the same
commit;
- **Lists' save bar says what changed**: `Unsaved changes to the block list URLs:`
followed by the counts `changesOf` computes (`list-lines.ts`, WHAT CHANGED):
added, removed, switched on, switched off, changed, moved, and groups added,
renamed and removed. Only the non-zero ones are written;
- **the Lists tab carries a dot while there are unsaved changes**, read as
`Lists, unsaved changes` (`ui/SubTabs`'s `marks`). The edits still live only
while the tab is open, as before: leaving it leaves them, and the dot with them.
3. **Settings jumps to the sub-tab of the invalid field.** Upstream focuses a
hidden input and the user sees nothing; with one panel mounted at a time,
without that jump the alert would be impossible to resolve.
And for the same reason (one save sends the fields of all nine panes, wherever
you are), **Settings says what the save would change** (approved on
2026-10-16, `screens/settings/pending.ts`). While a field would be sent with a
value other than the one the server returned, the save bar reads
`Unsaved changes:` followed by the panes that hold one and how many fields each,
`General (2), Cache (1)`, in tab order, and each of those sub-tabs carries the
dot Lists carries, read as `<Tab>, unsaved changes` (`ui/SubTabs`'s `marks`).
Those two phrases are the only new words; the pane names are upstream's tab
labels. It compares what the save would send, with the save's own cleaning, so
a field put back as it was, a blank line in a list and an untouched field never
count; nor does a field the save would not send (outside the chosen node's
scope, or a proxy detail with no proxy type), nor the minutes of `Temporary
Disable Now`, which is not a setting. It only displays: saving, Backup,
Restore, Flush Cache and the blocking buttons behave exactly as before.
4. **The "Enable DNS-over-HTTP/3" checkbox re-enables itself.** In upstream it
stays dead until the page is reloaded because nothing re-evaluates its state:
that is a bug of theirs, and replicating it would mean introducing the fault
on purpose.
5. **Last Hour moves in real time** (Adrián's decision, 2026-10-03), on the
Dashboard and on Blocking › Overview, which read the same `stats/get`.
Between the 60-second reloads the count tiles, the Queries chart and the
Query Response Types doughnut (and on Overview its figures, sparklines, bars
and ring) follow the server's lifetime counters (`dashboard/metrics/json`),
read every 2 seconds (`dashboard/live.ts`, `dashboard/useLive.ts`). Overview
gained the minute reload with it. Live readings keep their timestamps across
reloads: only readings after the last server UTC label are added. Labels name
the end of each closed minute (StatsManager.cs:675-704), so a response that has
not consolidated a pending reading cannot erase it. The history is bounded
to the displayed hour; gaps between measured minutes occupy chart slots.
A polling interval that straddles the server boundary is assigned to its end
until a later consolidated response replaces that approximation.
A chart takes new figures in place and
without animation (`Chart.tsx`), and a legend's switched-off series only come
back with a new response, as upstream's rebuilt chart does. Its limits, and
crossing one is a bug:
- **only that endpoint**, which upstream documents in APIDOCS.md and its
console does not call; `check-endpoints.mjs` counts upstream's endpoints, so
it does not object;
- **Clients, Query Types, Protocol Types, the tops and the Server panel never
move**: the counters do not carry them, and distinct clients do not add up;
- **if the endpoint fails three times in a row, live mode stops** until the
next reload, and the screen is exactly what it was without it;
- **Recently Blocked follows the displayed Blocked count** through its existing
`logs/query` reads. Background refreshes keep the previous rows, serialize
requests and invalidate queued work when token, node or permissions change;
- **a hidden tab reads nothing**, the minute refresh included, and coming
back reloads at once. That pause is the only change to an upstream request.
6. **The login screen has a cover** (Adrián's decision, 2026-10-15): a brand
panel beside the stock form above 960 px, and a header above it below,
compact under 560 px (`screens/LoginCover.tsx`). It changes the layout and
nothing else. Its limits, and crossing one is a bug:
- **the form is upstream's, whole**: every field, text, step (the OTP one
included), validation, alert and behaviour of `Login.tsx` is untouched, and
it stays alone inside `<main>`; the cover is an `<aside>` outside it, and
upstream's `Technitium DNS Server` is still the only `<h1>`;
- **its words are ours and say so** in the header comment of its file: the
name, the headline and the four TXT records. They must not age (no "new")
and each must be true of the console as it ships; a line that stops being
true changes in the same commit. It shows the console's version from the
build and nothing else: no screenshot and no figure, since nobody has
logged in yet and anything that looks like data would be invented;
- **it calls nothing**: no endpoint, no asset beyond the fonts the console
already ships, and its colours are theme tokens measured on `--field` by
`dev/theme-contrast.mjs`. Its only motion is the answer rows settling in,
and `prefers-reduced-motion` removes it.
7. **The Dashboard compares and jumps** (Adrián's decision, 2026-10-15). It adds
two things to the stock Dashboard (`screens/dashboard/compare.ts`, `jump.ts`):
- **each figure's change against the previous period of the same length**, as
an arrow and a percentage on its card, with what it is measured against
written once, in the `Total Queries` card ("vs previous hour"). The
previous period is a second `dashboard/stats/get` with `type=Custom`, whose
instants are worked out from the labels of the answer on screen, so they follow
the server's clock, its node and its grouping. Measured against v15.6.1 and
v15.5.1 on 2026-10-15: Custom over a fixed period's own window gives the same
ten counters, bucket by bucket, for all five fixed periods, and the same
Clients wherever the request is exactly the window. Clients gets no change for
Last Week (seven days by day cannot be asked; eight are, and the counters are
summed over seven), for Last Year (asked by day; the server reaches a distinct
count of months and of days through different merges) and for a minute that
kept only part of its clients (more than 1,000). The other ten compare in
every period;
- **a jump from a chart to Query Logs**: a point of `Queries` opens its minute,
hour, day or month as `From`/`To`; a segment of the `Query Response Types`,
`Query Types` or `Protocol Types` bar opens that value over the period, where
Query Logs has a field for it (`Dropped` and `Others` do not). It is the jump
of the Top lists' "Show Query Logs" (`app/handoff.ts`), with the same node
rule, the same arrival and the same alert when no logging app is installed.
The three breakdowns are stacked bars in one card (the cockpit layout,
Adrián's choice of 2026-10-15, `Breakdown.tsx`). Query Response Types keeps
the stock legend's click-to-hide; on Query Types and Protocol Types there is
no slice to hide, so their legend entries are the jump instead;
- **a week by hour** (`Heatmap.tsx`, `heatmap.ts`): seven days of the browser's
calendar by its 24 hours, each cell the queries of that hour, opening Query
Logs for that hour on click. Custom groups by hour only up to 157 hours, so
the week is asked in two back-to-back pieces and stitched; an hour that comes
twice or not at all leaves the panel saying it could not load, never a double
count or a quiet hole. Checked on 2026-10-15 against v15.6.1: the stitched
hours equal Last Day's 24 and a single 157-hour request, hour by hour. The hour
in progress is left out, as Last Day leaves it. It is asked once per node and
per hour, not on each minute reload.
Its limits, and crossing one is a bug:
- **only `dashboard/stats/get`**, which the stock Dashboard already calls, and
Query Logs' existing fields. A Last Hour reload waits for both reads so the
changes do not blink; other periods ask once more per load; the week asks one
or two more per hour;
- **no change is drawn unless it is exact and current**: never while another
period or node is loading, never after a failed read (of either period),
never for a figure whose previous value is zero, never when the previous
period has no queries at all (a server keeping statistics in memory only, or
one that was not running yet). A previous period the server only partly
recorded (it started inside it, or its stats files were deleted) is compared
as recorded: the API cannot tell a quiet hour from a missing one;
- **upstream's texts are untouched.** What is ours is in English, declared in the
header comment of each file that writes it: "vs", "previous hour",
"previous 24 hours", "previous 7 days", "previous 31 days", "previous 12
months", "previous N minutes/hours/days" (`compare.ts`, `Figures.tsx`,
`Dashboard.tsx`), the Queries hint "Click a point to open Query Logs for that
minute/hour/day/month" (`Dashboard.tsx`), the week's title "Queries per Hour,
Last 7 Days", its weekday names, each cell's "HH:00 to HH:00: N queries", its
hint "Click a cell for that hour's Query Logs" and its scale's "Quiet" and
"Busy" (`Heatmap.tsx`, `heatmap.ts`), and the legend entries' "Show Query Logs"
title, which is upstream's own menu text. The two hints show only when the
click can land;
- **pointer only on the canvas and the bars.** A point of `Queries` and a
segment of a breakdown bar take a click but no keyboard focus; the Query Types
and Protocol Types legend entries are buttons, and the week is one keyboard
stop whose arrows move between its cells.
8. **Query Logs folds its filters below 960 px** (approved on 2026-10-16;
`FOLD_QUERY` in `screens/logs/QueryLogs.tsx`). At 390x844 the fourteen filters
were one column 1,173 px tall and the first result row ended at y=1,905; at 768
the table still started at y=1,001. Below 960 the panel closes to its title,
which becomes the toggle, and `Query` closes it again; the legend keeps its seven
pills and puts their conditions behind its title. Above 960 nothing of this
exists. Its limits, and crossing one is a bug:
- **a folded panel says what is set**: every field that differs from what
`Reset` gives, by its field's own label and with the value the field shows
(`activeFilters`), so a filtered table never reads as the whole log;
- **nothing it sends changes**: the fields stay mounted while folded, and Query,
Export, Reset, Live Update and the pager send exactly what they sent;
- **a validation still lands on its field**: a folded panel opens before
upstream's focus (logs.js:389-424 and 612-625) moves to the field that failed.
Its words: none new. The toggles are the panel's existing title, `Filters`, and
the legend's, `Row colours`; the list is the fields' own labels, each followed by
a colon and its value.
9. **About has two panels for this console** (Adrián's decision, 2026-10-16;
`screens/about/About.tsx`). Upstream's About speaks only of the server, and its
card stays literal. Under the Server panel, `Web Console` gives this console's
version (`__CONSOLE_VERSION__`), its license, one sentence saying it is not
Technitium's, and four destinations as rows: `Source code`, `Release notes`,
`Report a problem` (so a console fault does not go to Technitium's support) and
`Ideas and questions`. `Support the Console` has one sentence, `Star on GitHub`
and the four destinations of `.github/FUNDING.yml` as buttons. Its words are all
ours and follow the README's `Support` and `Licence`.
10. **The console tells you when a newer console is out** (Adrián's decision,
2026-10-18; `app/console-update.ts`, `app/Versions.tsx`, `update-check/`). The
browser cannot ask GitHub (the CSP below has no `connect-src`), so the console
asks its own server, with `dnsClient/resolve`, for the TXT of
`latest.technitium-console.pages.dev` against
`https://technitium-console.pages.dev/dns-query` (`protocol=HTTPS`, no DNSSEC,
no import). That endpoint is this repo's `update-check/`, a Cloudflare Pages
Function that answers `v=1 latest=X.Y.Z server=A.B`. When a newer release is
made for the running server, the *Web Console* row gets the same `Update`
mark as the server's, and a dialog with the two commands from the README,
each with a Copy button (the console cannot run them: no call of the server's
API replaces its files). Its limits, and a change that crosses one is a bug:
- **only that call**, and **at most once a day per browser**: the result,
failures included, is kept in `localStorage` under `consoleUpdateCheck`. A
failed call writes a line in the server's log (`DnsWebService.cs:2556`), so
one a day is the ceiling;
- **only when `user/checkForUpdate` answers `dnsServerEnableCheckForUpdate:
true`** and the session has *DNS Client: View*. The server's *Check for
update* and a silenced notice turn both notices off; there is no setting of
its own;
- **never with `node`**: every node of a cluster serves its own console;
- **silent on any failure**, and nothing from the answer is shown but the
version numbers, each checked against `^\d+\.\d+(\.\d+)?$`;
- **the record's format is frozen**: consoles already installed read `v=1`,
`latest` and `server`, and ignore keys they do not know. Grow it, never
change it;
- **`server` is the oldest Technitium a release is made for**: it is
`technitiumServerMinimum` in `package.json`, read by the endpoint at the
release's tag, and it is bumped together with the CHANGELOG's table. A
console never announces a release made for a newer server than the one
running;
- the endpoint announces a release only once `latest` on GHCR is that
release's image, so that Docker users who pull get it; and a GitHub rate
limit is a failure, not "nothing to announce": the last good record stands.
Its words, all OURS: `Web Console update available` and `DNS Server update
available` (the two marks' accessible names), `New Web Console Version
Available!`, `To update, run the installer again:`, `On Docker:`, `Release
Notes` and the dialog's `Note!`.
11. **A client with no domain shows no "."** (Adrián's decision, 2026-10-21;
issue #22). Upstream writes `.` under a top client whose `domain` comes
empty (`main.js:2799`, `main.js:2955`), the root, which is what every client
with no PTR gets: most IPv6 clients. Users read it as a stray trailing dot on
the address. The Dashboard's Top Clients card and the Top 1000 modal draw no
domain line for it instead; a client with a domain still shows it. Nothing
else changes: the same call, the same rows, the same name and menu.
## Four constraints the server imposes
These are not preferences: break them and the console works in development and
fails in production.
- **`base: './'`.** The server honours `X-Forwarded-Prefix` and mounts a
`PathBase`. With absolute paths the console works locally and 404s behind a
reverse proxy with a prefix. `dev/check-prefix.sh` checks it.
- **Real routes, one folder per route.** The server's only `MapFallback` is
`/api/{*path}`, so a deep route with no file on disk would 404. The build emits
one folder with its own `index.html` for each of the console's 34 routes (11
sections and 23 sub-sections, from `SECTIONS`), plus the two legacy folders
`/allowed/` and `/blocked/`: 36 in all. The URL is real (no `#/`) and F5 brings
you back where you were, without touching a line of C#. See `vite.config.ts` and
`app/static-routes.ts`.
- **Content-Security-Policy**: `default-src 'self'; script-src 'self'
'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self'
data:`. There is no `font-src`, so **the fonts have to be files served from the
same origin**: a font embedded as a `data:` URI inherits `default-src 'self'`
and does not load. No CDN is possible either.
- **`public/` holds the assets the server or the console still need**:
`favicon.ico`, `robots.txt`, `img/` (including `oidc.png`, which the login uses)
and `json/*-builtin.json`. The build emits them again.
## Closing
When you finish, write down which endpoints you covered and **any upstream
behaviour you discovered that was not already noted**. That last part is the
most valuable thing you can contribute.