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]>
57 KiB
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. 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:
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/:
devat http://127.0.0.1:5380: serves our buildrefat 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/.
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:
- Not everything comes wrapped in
response.user/login,user/session/getandstatusreturn the payload flat. Everything else does wrap it. That is whyapiRequestunwraps nothing: it hands over the JSON as it came. - The second-factor literal is
2fa-required, nottwo-factor-auth-required. - The statistics are flushed by the minute. A
digyou 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, theupdateVersion,updateUrlandupdateAvailablefields 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/getreturnsconfig: nullas 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 setContent-Typeby hand or theboundaryis 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/listpaginates on the server (pageNumber,zonesPerPage) and its paging fields only appear if you sendpageNumber.zones/records/get, on the other hand, does not paginate: it is asked for withlistZone=trueand 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.cswalks down on its own while the node has no records and has exactly one child. Always drawresponse.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/listthettlis an already-composed string ("218 (3m38s)"); inallowed/listandblocked/listit is a number withttlStringapart. Same with a SOA'srefresh,retry,expireandminimum. Typing the records with a single shape is a guaranteed bug. 0001-01-01T00:00:00is .NET'sdefault(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/listonly reads the manually blocked zones; the downloaded block lists are counted only indashboard/stats/get(blockListZones). - Careful with replicating the intent instead of the behaviour. In
other-zones.jsthere are threedomain.toLowerCase();without assigning the result: they do nothing. What the code does is replicated, not what it looks like it wants to do. settings/getOMITS the null keys, it does not send them asnull: fields liketemporaryDisableBlockingTillorblockListNextUpdatedOnsimply do not appear on a freshly installed server. Others do arrive as an explicitnull. Declaring them required fails against a new install. AndblockListNextUpdatedOnis 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/queryfilters by ONEresponseType(WebServiceLogsApi.cs:182-185), but the Dashboard'stotalBlockedadds up three:Blocked,UpstreamBlockedandUpstreamBlockedCached(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\nwhen replicating a list textarea. Upstream builds its textareas with\r\n, but the browser normalises to\nwhen 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\nsendsforwarders=1.1.1.1%0D,8.8.8.8%0Dto 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 cacheCache.canDeleteand the backupSettings.canDelete. - Asymmetric permissions: some actions ask for
Deletewhere you would expectModify, andapps/listis allowed with read permission on Apps, Zones or Logs. Do not deduce the permission: look at it. - Not every endpoint returns JSON.
logs/downloadanswerstext/plainwith the file when it goes well andapplication/jsonwith the usual envelope when it fails. It cannot go throughapiRequest: that would dores.json()and turn any log into a network failure. Upstream asks for it withisTextResponseand, if what arrives carriesstatus, 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
fileNameinlogs/downloadand withloginlogs/delete. Andlogs/listreturns 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/setis a PARTIAL update: each field is applied only if it comes in the request (WebServiceDhcpApi.cs:390-650), so a body with onlynameandnewNamerenames 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/getOMITS fifteen optional keys instead of sending themnull:domainName,domainSearchList,serverAddress,serverHostName,bootFileName,routerAddress,dnsServers,winsServers,ntpServers,ntpServerDomainNames,staticRoutes,vendorInfo,capwapAcIpAddresses,tftpServerAddresses,genericOptionsandexclusions.reservedLeasesis the exception: it is always written, even as[]. Same forinterfaceAddressinscopes/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 andscopes/getstill 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/deleteon a non-existent scope answersok, butdhcp/leases/removeon 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
tscache-buster goes on only two of the six downloads: the settings backup (main.js:3100) andlogs/download(logs.js:196).logs/export,zones/export,allowed/exportandblocked/exportdo 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 keyoptQueryLogsEntriesPerPageand 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. serializeTableDatadecodes TWICE. It applieshtmlDecodeto a value the browser had already decoded when parsing the HTML, so typing&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
selectfireschangeonly 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/Selectdoes 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
setcan return less than itsget, and what is missing has to be kept.admin/sso/setdoes NOT bringlocalGroups:WriteSsoConfigonly writes them withincludeGroupsand thesetcalls it withfalse(WebServiceAuthApi.cs:1790). Upstream survives because it stored them in a global variable when doing theget. 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/getreturnsssoClientSecret: "************"as soon as one is stored, andSetSsoConfigignores 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/deleteALWAYS travels withnodefrom the Sessions tab (the primary node if the session is an API token, the chosen node in any other case) and with nonodeat 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/deleteSecondaryandprimary/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/deletewith an invented partial token answerserrorwith "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=trueincludesEveryoneandadmin/groups/listdoes not;admin/sso/get?includeGroups=trueexcludes it on purpose (WebServiceAuthApi.cs:383). Three group lists, three contents. - Two sibling endpoints can return different shapes of the same object.
admin/groups/createanswers{name, description}andadmin/groups/setalso answersmembers. Same with the user:users/getbringsgroups(all the server's),users/setdoes not bring it even though it does bringmemberOfGroupsandsessions, andusers/listandusers/createbring none of the three. Checked live. - Half the response disappears when the cluster is not initialised.
admin/cluster/stateon a standalone server is THREE fields:version,dnsServerDomainandclusterInitialized.clusterDomain, the four intervals andclusterNodesonly exist with a cluster (WebServiceClusterApi.cs:60-75). And within a node,upSince,lastSeenandconfigLastSyncedare omitted when they holddefault, they do not arrive asnull. - The cluster's "Quick Add" compares by SUBSTRING.
cluster.js:30usesexistingList.indexOf(ip) < 0, so with10.0.0.10already in the list the IP10.0.0.1is 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 ahideAlertforsuccessalerts and only for those. It is replicated here. - A whole section may filter NOTHING by permission. Inside Administration,
upstream checks
Administration.canViewto 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/setandsso/setask forAdministration.canDelete, notcanModify(WebServiceAuthApi.cs:1533 and 1692). In the cluster, nearly everything asks forcanDelete(includinginit,initJoinandpromote), butsetOptions,resync,updatePrimaryandupdateIpAddressask forcanModify. zones/listOMITSdnssecStatusandhasDnssecPrivateKeyson Catalog and Forwarder zones, and the Catalog also omitscatalog. 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 bringsusernameand a group one bringsname. Treating them as the same shape leaves half the table blank. records/deletehas no branch for CNAME, DNAME, SOA or APP: all four fall to thedefault, which only sendsrdataif it exists, and it exists for none of them. The server receives zone+domain+type and nothing else. And deleting an NS does not sendglue, 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,showEditZonePagerewrites a leading.*\.to\*\.. It serves to find the*.zonerecord, 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
wwwdoes not findwww.sub. And it lowercases what you typed but not the zone name. zones/createis 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/importhas TWO ways of sending the file: uploading it goes as multipart and pasting it into the textarea goes as raw plain text withContent-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 returnsdeletedandfailed. When some fail, the alert is NOT an error: it is awarningcounting 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:primaryNameServerAddressesandqueryAccessNetworkACLtravel empty as they are. It is not symmetry; it is whatsaveZoneOptionsdoes. - 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.
convertZoneoffers 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:00is .NET'sdefault(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=TcpandvalidateZone=true. The type that travels isSecondary. - The labels and the values of the DNSSEC dropdowns do not match: you see
"SHA256 (default)" and
SHA256travels; you see "Ed25519 (default)" andED25519travels in UPPERCASE. - In the DNSSEC properties,
isRetiringswitches 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/getand everyadmin/users/*bringtype(Local,RemoteSSO,RemoteLDAP) next to the obsoleteisSsoUser, andtotpEnabledis omitted for aRemoteSSOuser, not sent asfalse(WebServiceAuthApi.cs:77-82 and 140-146). In the details,ssoManagedGroupsis now written ONLY for SSO users andremotelyManagedGroupsfor every type (falseforLocal, lines 158-172). A screen still readingssoManagedGroupsunlocks the groups of every LDAP user. Checked live against v15.5.1. admin/ldap/geton a fresh install is halfnull:ldapServer,ldapBindUsername,ldapBindPassword,ldapSearchBase,ldapUserSearchFilterandldapGroupAttributearrive asnull,ldapPortas389, andldapAllowSignupOnlyForMappedUsersistruewhileldapAllowSignupisfalse: the box comes up checked and disabled.ldap/setrepeats SSO's lesson and dropslocalGroups(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
responsekey at all:{"server", "status": "error", "errorMessage"}. zones/exportanswers an error with HTTP 200 and JSON, the file withtext/plain: a missing zone gives{"status":"error","errorMessage":"No such zone was found: …"}and a bad tokeninvalid-token, both 200. The v15.5 "Edit Zone File" dialog reads it as text and, likelogs/download, puts any answer carryingstatusFORMATTED into its textarea (zone.js:1262-1263 in v15.5.1): no alert, not even forinvalid-token. Checked against v15.5.1.zones/import?overwriteZone=truereplaces the WHOLE zone, NS included. A file with only SOA+NS+one A leaves exactly that; an EMPTYtext/plainbody answersokand leaves the SOA alone. An unparsable file fails before touching anything, witherrorMessage("The zone file parser failed to parse 'rdata' field on line # 1.") ANDinnerErrorMessage("An invalid IP address was specified."); upstream shows only the first. WithoverwriteSoaSerial=falsethe 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 totrue(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 withoverwriteZone=trueempties 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=clusteronly 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 loadssettings/get?node=and savesnode=&…, nevercluster, 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
serveris 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, whichupdateDnsSettingsDataAndGuihas just rewritten fornode=""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 harnessrefis published on 5381 while its web service listens on 5380, so there the proxy is always "detected" and nothing redirects;devis 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 stateUnknown, and about 30 s later asConnectedwith15.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 theonclick(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 realnulland does get "A" (main.js:2824, dnsclient.js:232). Both jumps pick the target's node only when given one that is notcluster(dnsclient.js:243, logs.js:634); from the Dashboard's aggregate the target keeps the node it had.allowDomain/blockDomainsend nonodeat 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
RCODEand the four response types fillingResponse Type; Clients and Dropped are not links. The jump passes no node and no period, so Query Logs keeps its node and its emptyFrom/To, even with the Dashboard on another node or period. Here each card carries the jump indashboard/metrics.ts(open). - The Top Stats modal follows the Dashboard's node and keeps its own alerts.
It reads
optDashboardClusterNodeforgetTop(main.js:2932), and allowing or blocking from it reports indivTopStatsAlert, 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
allowTxtBlockingReporton (the default), a blocked query carries an Extended DNS Error withInfoCode: "Blocked"andExtraTextsource=blocked-zone; domain=<rule>(DnsServer.cs:4672) orsource=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/resolvereturns it inresult.EDNS.Optionsand again in the OPT record ofAdditional.domainis 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 unrelatedSynthesizedoption ("Result from negative cache") can sit in the same list. The client library adds its ownDnsClientExtendedErrors(BlockedByUpstreamDnsServer), which names no reason. A name the server refuses (not a domain) is anerrorenvelope with noresponseat all. Checked live against v15.6.1 on 2026-10-15 (screens/blocking/check-domain.real.json).
How the code is written
- Client: always
apiRequestfromsrc/api/client.ts. Paths relative and without a leading slash ('zones/list'), because the server honoursX-Forwarded-Prefix. - One
src/api/<family>.tsfile per endpoint family, with its types. It should return data that is already usable, andnullor 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 wholeApiOutcome: 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 outsidesrc/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-schemetoo: only a theme block declares it, and every element inherits it.npm run lint:colours(dev/check-colour-tokens.mjs) enforces it, andnpm testruns 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.cssyou do not write a px that is not one of the tokens intheme/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 ofsrc/theme/tokens.css, andnpm run lint:coloursfails 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, withnode dev/theme-contrast.mjs(every text and control token on every surface it is drawn on) andnode 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:languageis 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-wordandoverflow-wrap: anywhereare refused bynpm run lint:breaks(dev/check-word-breaks.mjs, also run bynpm test) outside a short list of things with no words (keys, signatures, raw output), each with its reason. A DNS name goes throughui/DomainName, a URL throughui/UrlText, other text throughoverflow-wrap: break-word. - No
BrowserRouter: the server's onlyMapFallbackis/api/{*path}. - No CDN and no fonts in
data:: the server's CSP does not declarefont-src. Images indata:are fine (img-src 'self' data:).
Tests
npm test: vitest. Do not runnpm run buildif someone else is working in the same checkout: it writes intodist/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: usevi.useFakeTimers({ shouldAdvanceTime: true })anduserEvent.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.
-
Amber is not offered (Adrián's decision).
Change Themeis back as upstream has it: in the account menu, under the samelocalStoragekey,theme, as the stock console (same origin, so a choice made in one holds in the other), withsystem,lightordark. System is the default and followsprefers-color-schemelive; a radio applies on click; nothing is written on load. Only the Amber radio is missing: a storedamberdraws 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 insrc/theme/theme.ts. -
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/resolveagainstthis-server(type A, UDP, no DNSSEC validation, no import), and reads the server's own blocking report from the answer; beside it,settings/getandallowed/export, each skipped when its permission is missing. It sends nonode, 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" whenAllow TXT Blocking Reportis off or when theBlocking Bypass Listcovers the server's own address, and a "Not blocked" lists the reads it did not have. Its words, all OURS:Check, theCheck,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 ownBlockandAllowand 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/jsonfor its live Last Hour (exception 5).dev/check-endpoints.mjsmeasures the other direction (upstream's endpoints this console covers), so a new path is checked by hand against upstream'swww/js; - upstream's actions, sentences and call sequences:
Allow Domainis stillblocked/deletethenallowed/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.tscalls Settings'cleanList).
Two behaviours a maintainer would not guess:
/allowed/and/blocked/still exist as folders. They are legacy routes (LEGACY_ROUTESinapp/static-routes.ts):translateLegacyRoute()inapp/route.tsrewrites them, withreplaceState, toblocking/rules/?rule=allowed|blockedbefore the route is read, so old bookmarks land on the filtered Rules tab.?rule=belongs to Rules alone.Blocking.tsxstrips 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 teachingwriteRouteabout one section's query.
Three additions approved on 2026-10-15, inside the same four limits:
- Groups in Lists. A comment line of
blockListUrlsis 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# Nameand 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/deleteorallowed/deleteat once and leaves a notice (ui/Toast) whose Undo sendsblocked/addorallowed/addfor exactly the domains deleted, each to its list. Further deletes while it is up join it. WithoutModifyon 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 withoutSettings.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 byui/SectionHeader'ssubtitle). 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 countschangesOfcomputes (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'smarks). The edits still live only while the tab is open, as before: leaving it leaves them, and the dot with them.
- only endpoints the server already has, and only ones upstream's console
already calls, save
-
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 readsUnsaved 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'smarks). 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 ofTemporary Disable Now, which is not a setting. It only displays: saving, Backup, Restore, Flush Cache and the blocking buttons behave exactly as before. -
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.
-
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.mjscounts 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/queryreads. 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.
- only that endpoint, which upstream documents in APIDOCS.md and its
console does not call;
-
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.tsxis untouched, and it stays alone inside<main>; the cover is an<aside>outside it, and upstream'sTechnitium DNS Serveris 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
--fieldbydev/theme-contrast.mjs. Its only motion is the answer rows settling in, andprefers-reduced-motionremoves it.
- the form is upstream's, whole: every field, text, step (the OTP one
included), validation, alert and behaviour of
-
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 Queriescard ("vs previous hour"). The previous period is a seconddashboard/stats/getwithtype=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
Queriesopens its minute, hour, day or month asFrom/To; a segment of theQuery Response Types,Query TypesorProtocol Typesbar opens that value over the period, where Query Logs has a field for it (DroppedandOthersdo 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
Queriesand 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.
- 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
-
Query Logs folds its filters below 960 px (approved on 2026-10-16;
FOLD_QUERYinscreens/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, andQuerycloses 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
Resetgives, 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. - a folded panel says what is set: every field that differs from what
-
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 Consolegives 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) andIdeas and questions.Support the Consolehas one sentence,Star on GitHuband the four destinations of.github/FUNDING.ymlas buttons. Its words are all ours and follow the README'sSupportandLicence. -
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 noconnect-src), so the console asks its own server, withdnsClient/resolve, for the TXT oflatest.technitium-console.pages.devagainsthttps://technitium-console.pages.dev/dns-query(protocol=HTTPS, no DNSSEC, no import). That endpoint is this repo'supdate-check/, a Cloudflare Pages Function that answersv=1 latest=X.Y.Z server=A.B. When a newer release is made for the running server, the Web Console row gets the sameUpdatemark 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
localStorageunderconsoleUpdateCheck. A failed call writes a line in the server's log (DnsWebService.cs:2556), so one a day is the ceiling; - only when
user/checkForUpdateanswersdnsServerEnableCheckForUpdate: trueand 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,latestandserver, and ignore keys they do not know. Grow it, never change it; serveris the oldest Technitium a release is made for: it istechnitiumServerMinimuminpackage.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
lateston 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 availableandDNS Server update available(the two marks' accessible names),New Web Console Version Available!,To update, run the installer again:,On Docker:,Release Notesand the dialog'sNote!.
- only that call, and at most once a day per browser: the result,
failures included, is kept in
-
A client with no domain shows no "." (Adrián's decision, 2026-10-21; issue #22). Upstream writes
.under a top client whosedomaincomes 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 honoursX-Forwarded-Prefixand mounts aPathBase. With absolute paths the console works locally and 404s behind a reverse proxy with a prefix.dev/check-prefix.shchecks it.- Real routes, one folder per route. The server's only
MapFallbackis/api/{*path}, so a deep route with no file on disk would 404. The build emits one folder with its ownindex.htmlfor each of the console's 34 routes (11 sections and 23 sub-sections, fromSECTIONS), 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#. Seevite.config.tsandapp/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 nofont-src, so the fonts have to be files served from the same origin: a font embedded as adata:URI inheritsdefault-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/(includingoidc.png, which the login uses) andjson/*-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.