Files
fancy-dns/web
developer 70d85eac59
build / build-push (push) Failing after 25s
Bring the console onto the contract the Go side now pins
internal/app/contract_test.go asserts the exact JSON key set of every admin
endpoint, and the console was reading fields that shape does not contain —
which is how a page can be entirely blank while the Go suite is green. The
types file is rewritten against the canonical shapes and every consumer is
fixed onto it.

The page bugs that came out of it:

The cache "Lookups" column read `count` and "Last seen" read `last_seen`; the
cache tracks a hit counter and a byte count per name and sends `hits`, so
there is no last-seen data to show at all. Lookups now reads hits, Last seen
is gone, and the row's bytes are shown. The "Size" card shows bytes_human,
with size as the entry-budget hint it actually is.

The Sources table read `item.enabled`, `item.rules`, `item.bytes`,
`item.updated_at` and `item.err`; those live under `item.meta`, so the table
was blank and the per-row toggle posted `name: undefined`. The refresh toast
counted the names echoed back and said "every source" for an empty list.

The dashboard read `qps` and `total_queries` where the server sends
`queries_per_sec` and `queries_total`, and rendered a `latency`/`p99` block
the dashboard response never contained. The `Partial<Dashboard>`-and-cast
that papered over this is gone. `blocked_pct` arrives as 0..100 from REST and
0..1 over SSE, so formatPercent is 0..100 only and a new formatFraction
handles the SSE frame.

Logs rendered `qtype` as a string; it is a number, with `qtype_name`
alongside, so there is a formatQtype that prefers the name. The Domains page
read `item.list` where the server sends `source`, and showed an Updated column
with no timestamp behind it.

Upstreams treated `healthcheck_interval` and `breaker_cooldown` as numbers of
seconds; they are duration strings now, so the page parses them like the
bootstrap refresh it already handled and the write payload no longer sends the
runtime health fields the server ignores. A group edit stripped the four
server-owned counters before writing, because config.Group would reject them.

`whoami.can_write` and `whoami.read_only` were fetched and never used, so a
read-only API key still showed every Save control. A single whoami fetch now
lives in a session provider behind the app shell, every write control is
gated on it, and a banner says why when the key is read-only. It fails open:
an unreachable probe must not lock an operator out of their own console.

Two server fixes the console work exposed, both pinned by the contract test:

POST /api/blocklists/refresh with a body that omits `names` answered
{"names": null}, and the console counts the names it gets back to build its
toast — so "refresh everything" said "0 sources". A nil slice now becomes an
empty one and the key set does not depend on the request.

blocklist.SourceMeta.duration was a raw time.Duration, so it went out as a
nanosecond count and the console divided by 1e6 to compensate — the only
duration on the wire that was not a duration string. It is a config.Duration
now, and formatDuration parses it like the others.

The contract test grew to cover what the first pass left out: the domain
check in all three of its shapes (match, miss, and an error delivered as 200),
the log clear, the settings validate in both the valid and the malformed case,
the settings save and reload, and the first SSE frame.
2026-09-27 18:53:43 +00:00
..
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 17:17:53 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:20:34 +00:00
2026-09-27 11:34:26 +00:00

The console

Vite + React + TypeScript + Tailwind, built into internal/web/dist and served by the Go binary from an //go:embed. There is no separate deployment: the binary is the console.

Day-to-day

make web-check     # npx tsc -b --noEmit && npm run build  (what CI runs)
make web-dev       # Vite dev server, proxying /api to 127.0.0.1:8080
make web           # production build into internal/web/dist

For make web-dev to show anything, a fancy-dns has to be listening on 127.0.0.1:8080 with the API key the console sends. deploy/config.example.yaml binds there, but on port 53; rewrite the ports (or use make smoke, which does it for you) before starting it.

Two traps that have cost time, both from AGENTS.md:

  • tsc -b replays cached diagnostics from node_modules/.tmp/*.tsbuildinfo. After a large edit rm -f node_modules/.tmp/*.tsbuildinfo, or you will read errors that no longer exist and miss ones that do.
  • npm run build empties internal/web/dist and deletes its .gitkeep. Restore it (git checkout -- internal/web/dist/.gitkeep). The Go build still works while the directory is empty, so a console rebuild in the middle of someone else's go build is not fatal — but the commit is.

The API contract

src/lib/types.ts is a hand-written mirror of the Go json tags in internal/api and internal/app/hooks.go. apiFetch ends in JSON.parse(text) as T, so the compiler checks the console against its own declarations and nothing else: a field that loses its json tag, or a response a struct never had, shows up as an empty table in a browser and nowhere else. That is not hypothetical — a round of this found the Sources table, the ACL group cards and both Save buttons broken exactly that way.

So, when you touch either side:

  • Go: add or keep the json tag. internal/app has contract tests that assert the exact key set of every admin endpoint; they are the spec, and a rename fails them.
  • Console: change the interface in the same commit. If the server is wrong, fix the server — do not "adapt" in the client, and do not paper a mismatch over with as any, an optional field or a ?? 0 default. Those casts are precisely what hides drift.
  • Never rename a field on one side only. The dashboard, the ServiceMonitor queries and the Grafana panels are consumers of the same JSON.

The durable fix is to generate types.ts from the Go structs, or to publish an OpenAPI document from the server and generate from that. It is in the plan at the end of STATUS.md.

What is checked

npx tsc -b --noEmit (strict, with noUnusedLocals) and oxlint (two rules: react/rules-of-hooks and react/only-export-components). There is no test runner and no component test — the console's correctness rests entirely on its types, which are only as good as the mirror above. Adding vitest and a handful of tests for lib/format.ts (the percent/latency/duration formatters, which have already shipped a units bug) is cheap and worth doing.