Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ae1a189d
|
||
|
|
a0d8af3170
|
||
|
|
3e8cd0ea88
|
||
|
|
f26ee523c2
|
||
|
|
9a99890aea
|
||
|
|
4ad7ad6c32
|
||
|
|
b4ef7b8ed1
|
||
|
|
d9d1c1d60d
|
||
|
|
f35146918b
|
||
|
|
aa8c9e28af
|
||
|
|
ea9f16a989
|
||
|
|
8efe504c51
|
||
|
|
2697adfa27
|
||
|
|
4ef795a8a9
|
@@ -126,6 +126,10 @@ jobs:
|
||||
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
|
||||
# heavy runner's zig and image work instead of queueing behind it.
|
||||
runs-on: ubuntu-24.04-light
|
||||
# The slowest green run took 4 minutes. A stalled Node download leaves setup-node
|
||||
# holding an open socket after its retry succeeds, so the step never exits:
|
||||
# without a limit the job held the runner for over an hour.
|
||||
timeout-minutes: 8
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -49,6 +49,16 @@ That function returns `null` for every input. `fetchAdd` is the only trigger: a
|
||||
|
||||
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
|
||||
|
||||
## Testing the NixOS module
|
||||
|
||||
`nix/module.nix` (`services.nxdns`) has two acceptance checks in `nix/tests.nix`, and no CI job runs them: the VM test needs KVM, and both download a nixpkgs closure that the shared Gitea runner must not fetch on every push. The cut's `nix flake check --no-build` evaluates them, which catches a module that no longer evaluates, but builds nothing. After any change to `nix/module.nix`, `nix/tests.nix`, the package in `flake.nix` or `deploy/systemd/nxdns.service`, build both on a host with KVM before the commit:
|
||||
|
||||
```
|
||||
nix build --no-link -L .#checks.x86_64-linux.nixos-module .#checks.x86_64-linux.nixos-module-rejects-config
|
||||
```
|
||||
|
||||
The checks run the package the flake pins, which is the last release, so a change to `deploy/systemd/nxdns.service` reaches the VM test only after the release that ships it.
|
||||
|
||||
## Regenerating the contract samples
|
||||
|
||||
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
|
||||
|
||||
@@ -4,6 +4,33 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
|
||||
|
||||
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
|
||||
|
||||
## [0.0.24] - 2026-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The admin pages fit a phone.** At 390 px, Activity, Clients, Diagnostics and every configuration table scrolled sideways, and Diagnostics and Clients made the whole page 826 px and 660 px wide, so the phone zoomed out. A table now shows all its columns only where they fit without wrapping. In a narrower table, the first cell carries the other columns' facts on short lines ("Priority 20 · TLS name dns.quad9.net", "Created 19 Oct 2026, 16:15:44"), and long names, URLs, IPv6 addresses and patterns wrap instead of widening the page. In the narrow Sources and Upstreams tables, Edit and Delete stack, which gives the first cell the width. Protection > Sources needs a 1440 px wide table for all its columns, so a 1440 px window now shows its narrow form; in 0.0.23 it made the page 1557 px wide there.
|
||||
- **Overview is no longer taller than the window.** The charts' screen-reader tables extended the document below the app, so the page had a second scroll bar under the main one at desktop widths.
|
||||
- **The Diagnostics count line no longer spreads its words across the page.**
|
||||
- **The Diagnostics health cards no longer spread a short card's lines down its height.**
|
||||
|
||||
## [0.0.23] - 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A blackhole route to an upstream no longer stops failover.** On a route like that, `connect()` fails with an errno the runtime has no name for, and nxdns took it for a fault of its own: the query got SERVFAIL at once, the next upstream was never tried, and nothing was charged to the unreachable one. A connect failure of that kind now counts against that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over.
|
||||
- **The Overview charts no longer draw the current bucket as a fall in traffic.** Every Overview window ends at the end of the bucket that holds the present moment, so its last bucket is still counting. Queries over time draws the curve into it dashed over a fainter fill, and Client activity draws its column faint inside a dashed outline. Both legends name "Still counting", and the tooltip and the chart's data table give that bucket's time as "<time> · still counting".
|
||||
- **The query detail page names the device.** The Client fact showed only the address, while the Activity list showed "iPhone (192.168.50.52)" for the same query. It now uses the list's label. The query log records only the address, so the record's intro says the device name is the one the address has today.
|
||||
|
||||
### Added
|
||||
|
||||
- **A NixOS module.** The flake exports `nixosModules.default`, which adds `services.nxdns` with four options: `enable`, `package`, `configFile` and `openFirewall`. It runs the release's own `nxdns.service` with a drop-in for file mode, checks `configFile` with the pinned binary's `nxdns import` at build time so a configuration that does not parse, validate or reconcile fails the build, and restarts the service when only the configuration changes. The flake's package now also installs `lib/systemd/system/nxdns.service`. See "Install nxdns with Nix".
|
||||
- **`nxdns_upstream_local_resource_total`** counts exchanges that ended on this process's own resources (memory, file descriptors, an unnamed system error) before any upstream answered. Until now, such a SERVFAIL left no record of its cause anywhere.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The compose file names its own release.** `deploy/docker/compose.yaml` defaults its image to the release it is tagged with instead of `:latest`, so `docker compose up -d` with the file from a tag runs that tag's image, and the install and upgrade steps no longer set `NXDNS_VERSION`. The variable still picks another release, and `NXDNS_IMAGE` still names a local image. Compose files from `v0.0.22` and earlier keep the `:latest` default.
|
||||
- **A DoH upstream that answers with an error status says which.** The Diagnostics episode and `/api/health` read `HttpStatus (status 505 HTTP Version Not Supported)` instead of `HttpStatus (cause HttpStatus)`. A 505 is a server that requires HTTP/2, which nxdns does not speak.
|
||||
|
||||
## [0.0.22] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,14 +44,14 @@ cat > etc-nxdns/config.zon <<'EOF'
|
||||
.web = .{ .password = "choose-a-real-password" },
|
||||
}
|
||||
EOF
|
||||
NXDNS_VERSION=<version> docker compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run an image you built yourself, name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
|
||||
The compose file names the release it belongs to, so a checkout of a release tag runs that release's image. `NXDNS_VERSION=<version> docker compose up -d` runs another release. To run an image you built yourself, name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
|
||||
|
||||
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file (<path>)`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
|
||||
|
||||
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
|
||||
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md), [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) and, for NixOS, [docs/how-to/install-with-nix.md](docs/how-to/install-with-nix.md).
|
||||
|
||||
## Building from source
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
@@ -7,6 +8,7 @@ import { createAppRouter } from "@/routes";
|
||||
import type { QueryDetail } from "@/lib/types";
|
||||
import { provenance } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
|
||||
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
||||
return { id, ...provenance(sections) };
|
||||
@@ -282,24 +284,40 @@ async function relatedText(expected: string) {
|
||||
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
|
||||
}
|
||||
|
||||
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
|
||||
/**
|
||||
* The Activity list labels a client "Kids iPad (192.0.2.12)"; the detail page
|
||||
* labels the same query the same way. The address is the recorded fact, and the
|
||||
* intro says once that the device name is today's.
|
||||
*/
|
||||
test("the client is the device name with its address, as in the Activity list", async () => {
|
||||
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
|
||||
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
|
||||
renderDetail(12);
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
|
||||
expect(factValue("Client")).toBe("192.0.2.12");
|
||||
await waitFor(() => expect(factValue("Client")).toBe("Kids iPad (192.0.2.12)"));
|
||||
expect(screen.getByText(/The device name is the one its address has today\./)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
|
||||
test("an address the client list does not know stays a bare address", async () => {
|
||||
responses["/api/queries/14"] = detail(14, { request: { client: "192.0.2.14" } });
|
||||
responses["/api/clients"] = clientList({ ip: "192.0.2.99", name: "Elsewhere", learned_name: "" });
|
||||
renderDetail(14);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await relatedText("These read the current configuration");
|
||||
expect(factValue("Client")).toBe("192.0.2.14");
|
||||
});
|
||||
|
||||
test("a learned name is drawn as learned, as in the Activity list", async () => {
|
||||
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
|
||||
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
|
||||
renderDetail(13);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
|
||||
expect(factValue("Client")).toBe("192.0.2.13");
|
||||
await waitFor(() => expect(factValue("Client")).toBe("printer.lan (192.0.2.13)"));
|
||||
const label = screen.getByText("Client").nextElementSibling?.firstElementChild;
|
||||
expect(label?.className).toBe(stylex.props(shared.learnedName).className);
|
||||
});
|
||||
|
||||
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
|
||||
|
||||
@@ -57,6 +57,12 @@ const PRESETS = [
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
/**
|
||||
* Narrower than the toolbar on one row: the search field's basis, Clients, the
|
||||
* result group, the widest preset's Time label and Clear, with their gaps.
|
||||
*/
|
||||
const ONE_ROW_DOES_NOT_FIT = "@container (max-width: 48rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
@@ -167,13 +173,22 @@ const styles = stylex.create({
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
/** The toolbar's controls wrap by the width the form gets, not by the viewport. */
|
||||
form: {
|
||||
containerType: "inline-size",
|
||||
},
|
||||
/**
|
||||
* Clear keeps its box when there is nothing to clear. It appears the moment a
|
||||
* filter is set, and a control that appeared by widening the row would move
|
||||
* every other control out from under the pointer that was reaching for it.
|
||||
* filter is set, and on the one row the toolbar fits on, a control that
|
||||
* appeared by widening it would shrink the growing search field and move
|
||||
* every other control out from under the pointer reaching for it. A toolbar
|
||||
* too narrow for one row wraps the search field onto a row of its own, so
|
||||
* there a Clear that appears moves nothing, and a kept box would only wrap
|
||||
* onto a row of nothing above the table.
|
||||
*/
|
||||
clearHidden: {
|
||||
visibility: "hidden",
|
||||
display: { default: null, [ONE_ROW_DOES_NOT_FIT]: "none" },
|
||||
},
|
||||
customRow: {
|
||||
marginTop: "0.5rem",
|
||||
@@ -500,7 +515,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={flush}>
|
||||
<form onSubmit={flush} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.searchWrap)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { activityCell } from "./activityCell";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
@@ -185,17 +186,16 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
"Route",
|
||||
"Duration",
|
||||
]);
|
||||
const first = screen.getByText("first.example").closest("tr")!;
|
||||
expect(within(first).getByText("HTTPS")).toBeTruthy();
|
||||
expect(within(first).getByText("NOERROR")).toBeTruthy();
|
||||
expect(within(first).getByText("Cache")).toBeTruthy();
|
||||
expect(within(first).getByText("1.2 ms")).toBeTruthy();
|
||||
expect(activityCell("first.example", "Type").textContent).toBe("HTTPS");
|
||||
expect(activityCell("first.example", "Result").textContent).toBe("NOERROR");
|
||||
expect(activityCell("first.example", "Route").textContent).toBe("Cache");
|
||||
expect(activityCell("first.example", "Duration").textContent).toBe("1.2 ms");
|
||||
|
||||
const blocked = screen.getByText("ads.example").closest("tr")!;
|
||||
// The Result cell says Blocked even though the client saw NOERROR, and the
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(activityCell("ads.example", "Result").textContent).toBe("Blocked");
|
||||
expect(activityCell("ads.example", "Route").textContent).toBe("Blocked");
|
||||
expect(activityCell("ads.example", "Duration").textContent).toBe("—");
|
||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -216,9 +216,10 @@ test("resolves each row's client to its display name, reading the IP out with it
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findAllByText("Kitchen Pi");
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
@@ -227,14 +228,39 @@ test("resolves each row's client to its display name, reading the IP out with it
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with nothing standing in for it.
|
||||
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||
});
|
||||
|
||||
test("the Domain cell carries the hidden columns' facts for a table too narrow for them", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "named.example", { client_ip: "192.0.2.10" })],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findAllByText("Kitchen Pi");
|
||||
|
||||
const domain = activityCell("named.example", "Domain");
|
||||
expect(within(domain).getByText("Kitchen Pi").textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
const facts = [
|
||||
activityCell("named.example", "Time").textContent,
|
||||
`Type ${activityCell("named.example", "Type").textContent}`,
|
||||
activityCell("named.example", "Route").textContent,
|
||||
activityCell("named.example", "Duration").textContent,
|
||||
].join(" · ");
|
||||
expect(domain.textContent).toContain(facts);
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
|
||||
@@ -36,9 +36,8 @@ const styles = stylex.create({
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
/** The shared scrolling wrapper, framed. */
|
||||
framedTable: {
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createAppRouter } from "@/routes";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { activityCell } from "./activityCell";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import LiveActivity from "./LiveActivity";
|
||||
import type { ActivitySearch } from "./search";
|
||||
@@ -187,19 +188,20 @@ test("resolves each row's client to its display name, reading the IP out with it
|
||||
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
|
||||
});
|
||||
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
await screen.findAllByText("Kitchen Pi");
|
||||
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||
@@ -222,11 +224,12 @@ test("rows stream in as bare IPs while the client list is still loading", async
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
|
||||
|
||||
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||
expect(within(activityCell("named.example", "Client")).getByText("192.0.2.10")).toBeTruthy();
|
||||
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||
|
||||
releaseClients();
|
||||
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||
await screen.findAllByText("Kitchen Pi");
|
||||
expect(within(activityCell("named.example", "Client")).getByText("Kitchen Pi")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
|
||||
|
||||
@@ -128,9 +128,8 @@ const styles = stylex.create({
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
/** The shared scrolling wrapper, framed. */
|
||||
framedTable: {
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -351,7 +350,7 @@ export default function LiveActivity({
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { PolicyReason, Provenance } from "@/lib/types";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { ClientName, useClientNames } from "@/features/clients/clientNames";
|
||||
import {
|
||||
policyActionLabel,
|
||||
policyReasonLabel,
|
||||
@@ -178,7 +178,6 @@ interface Props {
|
||||
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||
const { request, group, policy, rewrites, route, response } = provenance;
|
||||
const clientNames = useClientNames();
|
||||
const currentClient = clientLabel(request.client, clientNames);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -192,7 +191,8 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<div {...stylex.props(styles.record)}>
|
||||
<p {...stylex.props(styles.recordNote)}>
|
||||
What was recorded when this query was answered. Group and blocklist names are the ones in force at
|
||||
that moment; they may have been renamed or deleted since.
|
||||
that moment; they may have been renamed or deleted since. The device name is the one its address has
|
||||
today.
|
||||
{persistedId === null &&
|
||||
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||
</p>
|
||||
@@ -203,7 +203,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<Mono>{request.domain}</Mono>
|
||||
</Fact>
|
||||
<Fact label="Client">
|
||||
<Mono>{request.client}</Mono>
|
||||
<ClientName ip={request.client} names={clientNames} />
|
||||
</Fact>
|
||||
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
|
||||
<Fact label="Class">{qclassName(request.qclass)}</Fact>
|
||||
@@ -284,20 +284,6 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<p {...stylex.props(styles.relatedNote)}>
|
||||
These read the current configuration, which may no longer be the one that decided this query.
|
||||
</p>
|
||||
{currentClient !== null && (
|
||||
<p {...stylex.props(styles.relatedNote)}>
|
||||
{currentClient.learned ? (
|
||||
<>
|
||||
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
|
||||
<Mono>{currentClient.text}</Mono>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The client list currently names <Mono>{request.client}</Mono> “{currentClient.text}”.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { ACTIVITY_COLUMNS } from "./cells";
|
||||
|
||||
/**
|
||||
* Test helper: one column's cell in the row that shows `domain`. jsdom applies
|
||||
* no stylesheet, so the compact Domain cell's copy of the other columns' facts
|
||||
* sits in the document beside the wide cells, and a page-wide text query finds
|
||||
* both.
|
||||
*/
|
||||
export function activityCell(domain: string, column: (typeof ACTIVITY_COLUMNS)[number]): HTMLElement {
|
||||
const row = screen.getByText(domain).closest("tr");
|
||||
if (row === null) throw new Error(`no row shows ${domain}`);
|
||||
const cell = within(row).getAllByRole("cell")[ACTIVITY_COLUMNS.indexOf(column)];
|
||||
if (cell === undefined) throw new Error(`the row for ${domain} has no ${column} cell`);
|
||||
return cell;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { QuerySummary } from "@/features/provenance/querySummary";
|
||||
import FactLine from "@/ui/FactLine";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
@@ -28,6 +29,15 @@ const DARK = "@media (prefers-color-scheme: dark)";
|
||||
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
|
||||
const DOMAIN_MAX_WIDTH = "24rem";
|
||||
|
||||
/**
|
||||
* Narrower than the seven columns need with ordinary data, so the table keeps
|
||||
* two: Domain, which takes the other columns' facts as lines under the name,
|
||||
* and Result. Hiding cells keeps a native table; re-laying its rows out as
|
||||
* blocks would put the table semantics at the mercy of each browser engine.
|
||||
* The wrapper that scrolls is the container.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 50rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
@@ -52,6 +62,16 @@ const styles = stylex.create({
|
||||
domainCell: {
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
@@ -93,6 +113,7 @@ const styles = stylex.create({
|
||||
*/
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
@@ -152,6 +173,29 @@ export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" |
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
|
||||
}
|
||||
|
||||
/** The facts the hidden columns hold, in their column order, for the compact Domain cell. */
|
||||
function CompactFacts({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
|
||||
// The label stands in for the hidden Type header: a screen reader says a bare "A" as the article.
|
||||
const qtype = (
|
||||
<>
|
||||
<span {...stylex.props(shared.srOnly)}>Type </span>
|
||||
{qtypeName(row.qtype)}
|
||||
</>
|
||||
);
|
||||
const facts: ReactNode[] = [formatTime(row.ts), qtype, routeLabel(row.route_kind)];
|
||||
if (row.response_time_us !== null) facts.push(formatMicros(row.response_time_us));
|
||||
return (
|
||||
<>
|
||||
<span {...stylex.props(styles.compact)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</span>
|
||||
<span {...stylex.props(styles.compact, shared.tabularNums)}>
|
||||
<FactLine facts={facts} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityCells({
|
||||
row,
|
||||
clientNames,
|
||||
@@ -163,22 +207,23 @@ export function ActivityCells({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td
|
||||
title={row.domain}
|
||||
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
||||
>
|
||||
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted, styles.wide)}>{formatTime(row.ts)}</td>
|
||||
<td title={row.domain} {...stylex.props(styles.cell, styles.small, styles.domainCell)}>
|
||||
{renderDomain(
|
||||
row.id,
|
||||
<span {...stylex.props(styles.domainClamp, styles.breakAll, shared.mono)}>{row.domain}</span>,
|
||||
)}
|
||||
<CompactFacts row={row} clientNames={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, styles.wide)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<ResultCellContent row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{routeLabel(row.route_kind)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
</>
|
||||
@@ -187,12 +232,14 @@ export function ActivityCells({
|
||||
|
||||
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
||||
|
||||
const COMPACT_COLUMNS: ReadonlySet<string> = new Set(["Domain", "Result"]);
|
||||
|
||||
export function ActivityTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
{ACTIVITY_COLUMNS.map((column) => (
|
||||
<th key={column} {...stylex.props(styles.th)}>
|
||||
<th key={column} {...stylex.props(styles.th, !COMPACT_COLUMNS.has(column) && styles.wide)}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -42,21 +42,42 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const CLIENT_COLUMNS = ["Address", "Name", "Group", "First seen", "Last seen", "Actions"] as const;
|
||||
|
||||
/**
|
||||
* One column's cell in the row for `ip`. jsdom applies no stylesheet, so the
|
||||
* compact Address cell's copy of the name and group sits beside the wide cells,
|
||||
* and a page-wide text query finds both.
|
||||
*/
|
||||
function clientCell(ip: string, column: (typeof CLIENT_COLUMNS)[number]): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr")!;
|
||||
return within(row).getAllByRole("cell")[CLIENT_COLUMNS.indexOf(column)]!;
|
||||
}
|
||||
|
||||
test("renders the identity-first table: address, name, group, first and last seen", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
|
||||
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
|
||||
expect(headers).toEqual([...CLIENT_COLUMNS]);
|
||||
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
|
||||
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the Address cell carries the name, group and last seen for a table too narrow for them", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const address = clientCell("192.168.1.10", "Address");
|
||||
expect(within(address).getByText("laptop")).toBeTruthy();
|
||||
expect(address.textContent).toContain("· default");
|
||||
expect(address.textContent).toContain(`Last seen ${clientCell("192.168.1.10", "Last seen").textContent}`);
|
||||
});
|
||||
|
||||
test("a named row shows the typed name and hides the learned one", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
|
||||
expect(screen.queryByText("laptop-1.lan")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -65,7 +86,7 @@ test("an unnamed row shows the learned name with the learned marker", async () =
|
||||
|
||||
// The cell holds the learned name followed by the marker, so the match is on
|
||||
// the containing span rather than on a bare text node.
|
||||
const learned = screen.getByText(
|
||||
const learned = within(clientCell("192.168.1.11", "Name")).getByText(
|
||||
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
|
||||
);
|
||||
// The marker is text, not colour, so a screen reader announces it too.
|
||||
|
||||
@@ -45,6 +45,13 @@ function clientLabel(client: Client): string {
|
||||
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrower than the six columns need, so the table keeps Address, which takes
|
||||
* the name, group and last-seen time as lines under the link, and the actions.
|
||||
* First seen stays on the client's own page. The wrapper is the container.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 50rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
@@ -84,7 +91,6 @@ const styles = stylex.create({
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "48rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -107,9 +113,24 @@ const styles = stylex.create({
|
||||
right: {
|
||||
textAlign: "right",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
compactName: {
|
||||
color: colors.text,
|
||||
},
|
||||
/** A full IPv6 address is 39 characters of mono, wider than a phone's Address column. */
|
||||
addressLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
@@ -190,10 +211,10 @@ export default function ClientsPage() {
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th {...stylex.props(styles.cell)}>Address</th>
|
||||
<th {...stylex.props(styles.cell)}>Name</th>
|
||||
<th {...stylex.props(styles.cell)}>Group</th>
|
||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||
<th {...stylex.props(styles.cell)}>Last seen</th>
|
||||
<th {...stylex.props(styles.cell, styles.wide)}>Name</th>
|
||||
<th {...stylex.props(styles.cell, styles.wide)}>Group</th>
|
||||
<th {...stylex.props(styles.cell, styles.wide)}>First seen</th>
|
||||
<th {...stylex.props(styles.cell, styles.wide)}>Last seen</th>
|
||||
<th {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
@@ -202,23 +223,32 @@ export default function ClientsPage() {
|
||||
<tbody>
|
||||
{rows.map((client) => (
|
||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<Link
|
||||
to="/clients/$id"
|
||||
params={{ id: String(client.id) }}
|
||||
{...stylex.props(styles.addressLink, shared.focusRing)}
|
||||
{...stylex.props(styles.addressLink, shared.mono, shared.focusRing)}
|
||||
>
|
||||
{client.ip}
|
||||
</Link>
|
||||
<span {...stylex.props(styles.compact)}>
|
||||
<span {...stylex.props(styles.compactName)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</span>{" "}
|
||||
· {client.group}
|
||||
</span>
|
||||
<span {...stylex.props(styles.compact)}>
|
||||
Last seen {formatTime(client.last_seen)}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>
|
||||
<span {...stylex.props(styles.name)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
|
||||
@@ -102,11 +102,14 @@ const styles = stylex.create({
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** A full IPv6 prefix, or a group name, can be one unbroken string wider than a phone's column. */
|
||||
unbroken: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -155,8 +158,8 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
<tbody>
|
||||
{prefixes.map((prefix) => (
|
||||
<tr key={prefix.id}>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
|
||||
<td {...stylex.props(shared.td)}>{prefix.group}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, styles.unbroken)}>{prefix.prefix}</td>
|
||||
<td {...stylex.props(shared.td, styles.unbroken)}>{prefix.group}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { ForwardZone, LocalRecord } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { local, RecordFacts, ZoneResolver } from "./localCells";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
/**
|
||||
@@ -26,18 +27,23 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Name</th>
|
||||
<th {...stylex.props(shared.th)}>Type</th>
|
||||
<th {...stylex.props(shared.th)}>Value</th>
|
||||
<th {...stylex.props(shared.th)}>TTL</th>
|
||||
<th {...stylex.props(shared.th, local.wide)}>Type</th>
|
||||
<th {...stylex.props(shared.th, local.wide)}>Value</th>
|
||||
<th {...stylex.props(shared.th, local.wide)}>TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
|
||||
<td {...stylex.props(shared.td)}>{record.rtype}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
|
||||
<td {...stylex.props(shared.td, local.unbroken)}>
|
||||
<span {...stylex.props(shared.mono)}>{record.name}</span>
|
||||
<RecordFacts record={record} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, local.wide)}>{record.rtype}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
|
||||
{record.value}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, local.wide)}>{record.ttl}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Zone</th>
|
||||
<th {...stylex.props(shared.th)}>Resolver</th>
|
||||
<th {...stylex.props(shared.th, local.wide)}>Resolver</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones.map((zone) => (
|
||||
<tr key={zone.id}>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, local.unbroken)}>
|
||||
{zone.zone}
|
||||
<ZoneResolver zone={zone} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
|
||||
{zone.resolver}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import FactLine from "@/ui/FactLine";
|
||||
import DefinitionList from "@/ui/DefinitionList";
|
||||
import Select from "@/ui/Select";
|
||||
import Switch from "@/ui/Switch";
|
||||
@@ -43,6 +44,13 @@ const ACTION_OPTIONS = [
|
||||
{ value: "block", label: "block" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Narrower than a rule's four columns, or a source's name beside its URL: below
|
||||
* it each table keeps its first column and the actions, and the first cell
|
||||
* carries the rest.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 36rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
createForm: {
|
||||
display: "flex",
|
||||
@@ -96,8 +104,27 @@ const styles = stylex.create({
|
||||
block: {
|
||||
color: colors.danger,
|
||||
},
|
||||
/** A pattern or a URL can be one unbroken string as wide as the table. */
|
||||
pattern: {
|
||||
fontWeight: 500,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
unbroken: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
/** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
|
||||
urlText: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
fontWeight: 400,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -275,6 +302,10 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActionLabel({ action }: { action: RuleAction }) {
|
||||
return <span {...stylex.props(action === "allow" ? styles.allow : styles.block)}>{action}</span>;
|
||||
}
|
||||
|
||||
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
|
||||
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
|
||||
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
|
||||
@@ -284,14 +315,17 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Source</th>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assigned.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td {...stylex.props(shared.td)}>{source.name}</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
|
||||
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||
{source.name}
|
||||
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>{source.url}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Pattern</th>
|
||||
<th {...stylex.props(shared.th)}>Kind</th>
|
||||
<th {...stylex.props(shared.th)}>Action</th>
|
||||
<th {...stylex.props(shared.th)}>Created</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Kind</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Action</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Created</th>
|
||||
{editable && (
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
@@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
|
||||
<td {...stylex.props(shared.td)}>{rule.kind}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
|
||||
{rule.action}
|
||||
<td {...stylex.props(shared.td, styles.pattern)}>
|
||||
{rule.pattern}
|
||||
<span {...stylex.props(styles.compact)}>
|
||||
<FactLine
|
||||
facts={[
|
||||
<ActionLabel key="action" action={rule.action} />,
|
||||
rule.kind,
|
||||
`Created ${formatTime(rule.created_at)}`,
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>{rule.kind}</td>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>
|
||||
<ActionLabel action={rule.action} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>{formatTime(rule.created_at)}</td>
|
||||
{editable && (
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,15 @@ function writes(method: string): Call[] {
|
||||
return calls.filter((call) => call.method === method);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first cell of the row that shows `text`. jsdom applies no stylesheet,
|
||||
* so a narrow table's copy of the hidden columns sits in that cell beside the
|
||||
* columns themselves.
|
||||
*/
|
||||
function firstCell(text: string): HTMLElement {
|
||||
return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
|
||||
}
|
||||
|
||||
test("the group list is the master, and the selected group is the detail", async () => {
|
||||
await openProtection();
|
||||
|
||||
@@ -122,6 +132,15 @@ test("only the selected group's rules are listed", async () => {
|
||||
expect(screen.queryByText("ads.example.com")).toBeNull();
|
||||
});
|
||||
|
||||
test("a rule's Pattern cell carries its action, kind and creation time for a table too narrow for them", async () => {
|
||||
await openProtection(2);
|
||||
|
||||
await screen.findByText("*.social.example");
|
||||
expect(firstCell("*.social.example").textContent).toBe(
|
||||
`*.social.example${["block", "wildcard", `Created ${formatTime(1700000100)}`].join(" · ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
|
||||
@@ -244,6 +263,23 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a source's Name cell carries its URL, counts and last update for a table too narrow for them", async () => {
|
||||
calls = stubApi(DATABASE);
|
||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||
await screen.findByText("Trackers");
|
||||
|
||||
const trackers = firstCell("Trackers").textContent;
|
||||
expect(trackers).toContain("https://example.com/trackers.txt");
|
||||
expect(trackers).toContain("50 domains · 0 wildcards · 0 exceptions");
|
||||
expect(trackers).toContain("3 skipped regex · 4 skipped unsupported");
|
||||
expect(trackers).toContain(`Updated ${formatTime(1700000000)}`);
|
||||
// One of each reads in the singular.
|
||||
expect(firstCell("Ads").textContent).toContain("100 domains · 2 wildcards · 1 exception");
|
||||
expect(firstCell("Ads").textContent).toContain("Never updated");
|
||||
// Two zero counts say nothing a reader needs to parse.
|
||||
expect(firstCell("Ads").textContent).toContain("Nothing skipped");
|
||||
});
|
||||
|
||||
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||
calls = stubApi(DATABASE);
|
||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import FactLine from "@/ui/FactLine";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
@@ -22,17 +23,24 @@ import QueryPanel from "./QueryPanel";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const SKIPPED_NOTE =
|
||||
"Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
|
||||
"Both skipped counts are lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
|
||||
"only from you — adopt one you trust as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate " +
|
||||
"into a DNS decision: cosmetic element hiding, browser-only modifiers. A skipped unsupported count that dwarfs the " +
|
||||
"domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " +
|
||||
"more here.";
|
||||
|
||||
/**
|
||||
* Narrower than the ten columns side by side: below it the table keeps Name,
|
||||
* Enabled and the actions, and the Name cell carries the rest.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 90rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
badge: {
|
||||
whiteSpace: "nowrap",
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: metrics.radius,
|
||||
backgroundColor: colors.border,
|
||||
@@ -49,15 +57,72 @@ const styles = stylex.create({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
unbroken: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
/** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
|
||||
urlText: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
/** A URL or a name can be one unbroken string as wide as the table. */
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
/** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
flexDirection: { default: null, [COMPACT]: "column" },
|
||||
alignItems: { default: null, [COMPACT]: "flex-start" },
|
||||
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
function counted(count: number, one: string, many: string): string {
|
||||
return `${formatCount(count)} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** The hidden columns' facts, in their column order, for the compact Name cell. */
|
||||
function SourceFacts({ source }: { source: Blocklist }) {
|
||||
return (
|
||||
<>
|
||||
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
|
||||
<span {...stylex.props(styles.compact, shared.tabularNums)}>
|
||||
<FactLine
|
||||
facts={[
|
||||
counted(source.domain_count, "domain", "domains"),
|
||||
counted(source.wildcard_count, "wildcard", "wildcards"),
|
||||
counted(source.exception_count, "exception", "exceptions"),
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
<span {...stylex.props(styles.compact, shared.tabularNums)}>
|
||||
{source.skipped_regex_count === 0 && source.skipped_unsupported_count === 0 ? (
|
||||
"Nothing skipped"
|
||||
) : (
|
||||
<FactLine
|
||||
facts={[
|
||||
`${formatCount(source.skipped_regex_count)} skipped regex`,
|
||||
`${formatCount(source.skipped_unsupported_count)} skipped unsupported`,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.compact)}>
|
||||
{source.last_updated === null ? "Never updated" : `Updated ${formatTime(source.last_updated)}`}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime action: it re-downloads the sources the running process already
|
||||
* knows about, so it stays enabled under file authority and while
|
||||
@@ -142,41 +207,44 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Name</th>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>Domains</th>
|
||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocklists.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||
<SourceFacts source={b} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>
|
||||
{b.url}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
|
||||
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Name</th>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>Domains</th>
|
||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
@@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
<tbody>
|
||||
{blocklists.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||
<SourceFacts source={b} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>
|
||||
<span {...stylex.props(styles.url)} title={b.url}>
|
||||
{b.url}
|
||||
</span>
|
||||
@@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
onChange={() => toggleEnabled(b)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.wide)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import InlineError from "@/lib/InlineError";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import { local, RecordFacts } from "./localCells";
|
||||
import QueryPanel from "./QueryPanel";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -230,13 +231,13 @@ export default function RecordsTab() {
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||
Value
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||
TTL
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||
@@ -266,10 +267,22 @@ export default function RecordsTab() {
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
||||
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
||||
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
||||
<td {...stylex.props(styles.cell, local.unbroken)}>
|
||||
<span {...stylex.props(shared.mono)}>{record.name}</span>
|
||||
<RecordFacts record={record} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, local.wide)}>{record.rtype}</td>
|
||||
<td
|
||||
{...stylex.props(
|
||||
styles.cell,
|
||||
shared.mono,
|
||||
local.unbroken,
|
||||
local.wide,
|
||||
)}
|
||||
>
|
||||
{record.value}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, local.wide)}>{record.ttl}</td>
|
||||
<td {...stylex.props(styles.actionCell)}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
import { DATABASE, MANAGED_FILE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
|
||||
/**
|
||||
* Resolution in database mode: the upstream pool, the local records and the
|
||||
@@ -22,6 +22,15 @@ async function openResolution(tab?: string, options: Parameters<typeof stubApi>[
|
||||
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
|
||||
}
|
||||
|
||||
/**
|
||||
* The first cell of the row that shows `text`. jsdom applies no stylesheet,
|
||||
* so a narrow table's copy of the hidden columns sits in that cell beside the
|
||||
* columns themselves.
|
||||
*/
|
||||
function firstCell(text: string): HTMLElement {
|
||||
return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
|
||||
}
|
||||
|
||||
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
|
||||
async function openDeleteDialog(index = 0) {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
|
||||
@@ -40,6 +49,15 @@ test("the upstream pool is the default tab and lists every field", async () => {
|
||||
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an upstream's URL cell carries its priority and TLS name for a table too narrow for them", async () => {
|
||||
await openResolution();
|
||||
await screen.findByText("tls://9.9.9.9:853");
|
||||
|
||||
expect(firstCell("tls://9.9.9.9:853").textContent).toBe("tls://9.9.9.9:853Priority 200 · TLS name dns.quad9.net");
|
||||
// An empty TLS name has nothing to say in a line of facts.
|
||||
expect(firstCell("udp://1.1.1.1:53").textContent).toBe("udp://1.1.1.1:53Priority 100");
|
||||
});
|
||||
|
||||
test("adding an upstream posts every field", async () => {
|
||||
await openResolution();
|
||||
await screen.findByRole("heading", { name: "Add upstream" });
|
||||
@@ -236,6 +254,31 @@ test("the record delete dialog names the record and only deletes on confirm", as
|
||||
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
|
||||
});
|
||||
|
||||
test("a record's Name cell carries its answer and TTL for a table too narrow for them", async () => {
|
||||
await openResolution("records");
|
||||
await screen.findByText("nas.lan.home");
|
||||
|
||||
expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
|
||||
});
|
||||
|
||||
test("a zone's cell carries its resolver for a table too narrow for it", async () => {
|
||||
await openResolution("zones");
|
||||
await screen.findByText("lan.home");
|
||||
|
||||
expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
|
||||
});
|
||||
|
||||
test("under file authority the records and zones tables carry the same narrow form", async () => {
|
||||
calls = stubApi(MANAGED_FILE);
|
||||
await renderPage("/configuration/resolution?tab=records", "Resolution");
|
||||
await screen.findByText("nas.lan.home");
|
||||
expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
|
||||
await screen.findByText("lan.home");
|
||||
expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
|
||||
});
|
||||
|
||||
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
|
||||
await openResolution("zones");
|
||||
await screen.findByText("lan.home");
|
||||
|
||||
@@ -5,6 +5,7 @@ import InlineError from "@/lib/InlineError";
|
||||
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import FactLine from "@/ui/FactLine";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
@@ -16,18 +17,41 @@ import { styles as config } from "./styles";
|
||||
|
||||
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
|
||||
|
||||
/**
|
||||
* Narrower than the four columns side by side: below it the table keeps URL,
|
||||
* Enabled and the actions, and the URL cell carries the rest.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 42rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Cut to one line beside the other columns; whole, wrapping, where it is the row's only text column. */
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
whiteSpace: { default: "nowrap", [COMPACT]: "normal" },
|
||||
overflowWrap: "anywhere",
|
||||
fontWeight: 500,
|
||||
},
|
||||
unbroken: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
/** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
flexDirection: { default: null, [COMPACT]: "column" },
|
||||
alignItems: { default: null, [COMPACT]: "flex-start" },
|
||||
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
@@ -37,6 +61,17 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
/** The hidden columns' facts for the compact URL cell; an empty TLS name has nothing to say there. */
|
||||
function UpstreamFacts({ upstream }: { upstream: Upstream }) {
|
||||
const facts = [`Priority ${upstream.priority}`];
|
||||
if (upstream.tls_name !== "") facts.push(`TLS name ${upstream.tls_name}`);
|
||||
return (
|
||||
<span {...stylex.props(styles.compact, shared.tabularNums)}>
|
||||
<FactLine facts={facts} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpstreamsTab() {
|
||||
const upstreams = useQuery(upstreamsQuery());
|
||||
return (
|
||||
@@ -78,18 +113,21 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Priority</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>TLS name</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upstreams.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td, styles.unbroken)}>
|
||||
<span {...stylex.props(shared.mono)}>{u.url}</span>
|
||||
<UpstreamFacts upstream={u} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>{String(u.enabled)}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
|
||||
{u.tls_name === "" ? (
|
||||
<span {...stylex.props(styles.absent)}>empty</span>
|
||||
) : (
|
||||
@@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Priority</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>TLS name</th>
|
||||
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
@@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
<span {...stylex.props(styles.url)} title={u.url}>
|
||||
{u.url}
|
||||
</span>
|
||||
<UpstreamFacts upstream={u} />
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<Switch
|
||||
aria-label={`${u.url} enabled`}
|
||||
@@ -176,7 +215,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
onChange={() => toggleEnabled(u)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
|
||||
{u.tls_name === "" ? "—" : u.tls_name}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import { local, ZoneResolver } from "./localCells";
|
||||
import QueryPanel from "./QueryPanel";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -200,7 +201,7 @@ export default function ZonesTab() {
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Zone
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
|
||||
Resolver
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||
@@ -230,8 +231,20 @@ export default function ZonesTab() {
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
||||
<td {...stylex.props(styles.cell, shared.mono, local.unbroken)}>
|
||||
{zone.zone}
|
||||
<ZoneResolver zone={zone} />
|
||||
</td>
|
||||
<td
|
||||
{...stylex.props(
|
||||
styles.cell,
|
||||
shared.mono,
|
||||
local.unbroken,
|
||||
local.wide,
|
||||
)}
|
||||
>
|
||||
{zone.resolver}
|
||||
</td>
|
||||
<td {...stylex.props(styles.actionCell)}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { ForwardZone, LocalRecord } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
/**
|
||||
* The compact forms of the local-records and forward-zones tables, for both
|
||||
* the editable tabs and their file-mode renderings. Below the width a record's
|
||||
* four columns, or a zone beside its resolver, need, a table keeps its first
|
||||
* column and the actions, and the first cell carries the rest. The scrolling
|
||||
* wrapper is the container.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 28rem)";
|
||||
|
||||
export const local = stylex.create({
|
||||
/** A name, a value or a resolver can be one unbroken string as wide as the table. */
|
||||
unbroken: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The answer, then how long it is cached. The labels stand in for the hidden
|
||||
* headers: a screen reader says a bare "A" as the article, and a CNAME's value
|
||||
* is a second name beside the record's own.
|
||||
*/
|
||||
export function RecordFacts({ record }: { record: LocalRecord }) {
|
||||
return (
|
||||
<>
|
||||
<span {...stylex.props(local.compact)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Type </span>
|
||||
{record.rtype} <span {...stylex.props(shared.srOnly)}>value </span>
|
||||
<span {...stylex.props(shared.mono)}>{record.value}</span>
|
||||
</span>
|
||||
<span {...stylex.props(local.compact, shared.tabularNums)}>TTL {record.ttl}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZoneResolver({ zone }: { zone: ForwardZone }) {
|
||||
return (
|
||||
<span {...stylex.props(local.compact)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Resolver </span>
|
||||
<span {...stylex.props(shared.mono)}>{zone.resolver}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,6 @@ export const styles = stylex.create({
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -102,8 +101,10 @@ export const styles = stylex.create({
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** A group name can be one unbroken string wider than a phone's line. */
|
||||
masterLink: {
|
||||
display: "block",
|
||||
overflowWrap: "anywhere",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
|
||||
@@ -44,7 +44,13 @@ const ACTIVE = page([
|
||||
]);
|
||||
|
||||
const RESOLVED = page([
|
||||
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
|
||||
event(30, {
|
||||
code: "disk.space",
|
||||
component: "disk",
|
||||
subject: "data",
|
||||
first_seen: NOW_S - 9000,
|
||||
resolved_at: NOW_S - 7200,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -165,6 +171,16 @@ test("active episodes come first, each with its title, subject, age and count",
|
||||
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the Event cell carries the severity, subject and timing for a table too narrow for them", async () => {
|
||||
renderRoute();
|
||||
|
||||
const row = (await screen.findByText("Disk space low")).closest("tr")!;
|
||||
const event = within(row).getAllByRole("cell")[1]!;
|
||||
expect(within(event).getByText("Warning")).toBeTruthy();
|
||||
expect(within(event).getByText("data")).toBeTruthy();
|
||||
expect(event.textContent).toMatch(/Resolved .+ · lasted 30m · 3 occurrences/);
|
||||
});
|
||||
|
||||
test("nothing open reads as good news, not as a broken page", async () => {
|
||||
responses["/api/diagnostics?state=active"] = page([]);
|
||||
renderRoute();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatCount, formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import FactLine from "@/ui/FactLine";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
@@ -41,6 +42,13 @@ const COMPONENT_OPTIONS = [
|
||||
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
|
||||
];
|
||||
|
||||
/**
|
||||
* Narrower than the seven history columns need, so the table keeps Event, which
|
||||
* takes the severity, the subject and the timing as lines around the link, as
|
||||
* an active card does, and the Purge action. The wrapper is the container.
|
||||
*/
|
||||
const COMPACT = "@container (max-width: 56rem)";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
@@ -161,9 +169,9 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
tableWrap: {
|
||||
/** The shared scrolling wrapper, framed. */
|
||||
framedTable: {
|
||||
marginTop: "0.75rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -197,6 +205,17 @@ const styles = stylex.create({
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
wide: {
|
||||
display: { default: null, [COMPACT]: "none" },
|
||||
},
|
||||
compactBadge: {
|
||||
display: { default: "none", [COMPACT]: "inline-block" },
|
||||
marginInlineEnd: "0.5rem",
|
||||
},
|
||||
compact: {
|
||||
display: { default: "none", [COMPACT]: "block" },
|
||||
marginTop: "0.125rem",
|
||||
},
|
||||
rowLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
@@ -207,6 +226,9 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
count: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -340,10 +362,13 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
||||
const copy = copyFor(event.code);
|
||||
return (
|
||||
<tr {...stylex.props(styles.row)}>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>
|
||||
<SeverityBadge severity={event.severity} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(styles.compactBadge)}>
|
||||
<SeverityBadge severity={event.severity} />
|
||||
</span>
|
||||
<Link
|
||||
to="/diagnostics/$id"
|
||||
params={{ id: String(event.id) }}
|
||||
@@ -351,13 +376,31 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
||||
>
|
||||
{copy.title}
|
||||
</Link>
|
||||
<span {...stylex.props(styles.compact, styles.subject)}>{event.subject}</span>
|
||||
<span {...stylex.props(styles.compact, styles.meta, shared.tabularNums)}>
|
||||
<FactLine
|
||||
facts={[
|
||||
...(event.resolved_at === null
|
||||
? [`Started ${formatTime(event.first_seen)}`]
|
||||
: [
|
||||
`Resolved ${formatTime(event.resolved_at)}`,
|
||||
`lasted ${formatDuration(event.resolved_at - event.first_seen)}`,
|
||||
]),
|
||||
occurrenceText(event.occurrences),
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
<td {...stylex.props(styles.cell, styles.wide)}>{event.subject}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||
{formatTime(event.first_seen)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatCount(event.occurrences)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
|
||||
{formatCount(event.occurrences)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -495,16 +538,16 @@ export default function DiagnosticsPage() {
|
||||
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
|
||||
) : (
|
||||
<>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
<th {...stylex.props(styles.th)}>Severity</th>
|
||||
<th {...stylex.props(styles.th, styles.wide)}>Severity</th>
|
||||
<th {...stylex.props(styles.th)}>Event</th>
|
||||
<th {...stylex.props(styles.th)}>Subject</th>
|
||||
<th {...stylex.props(styles.th)}>Started</th>
|
||||
<th {...stylex.props(styles.th)}>Resolved</th>
|
||||
<th {...stylex.props(styles.th)}>Occurrences</th>
|
||||
<th {...stylex.props(styles.th, styles.wide)}>Subject</th>
|
||||
<th {...stylex.props(styles.th, styles.wide)}>Started</th>
|
||||
<th {...stylex.props(styles.th, styles.wide)}>Resolved</th>
|
||||
<th {...stylex.props(styles.th, styles.wide)}>Occurrences</th>
|
||||
<th {...stylex.props(styles.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
@@ -522,7 +565,7 @@ export default function DiagnosticsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footer, styles.note)}>
|
||||
<p {...stylex.props(styles.count, styles.note)}>
|
||||
Showing{" "}
|
||||
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
|
||||
resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||
|
||||
@@ -38,11 +38,11 @@ const styles = stylex.create({
|
||||
gap: "0.5rem",
|
||||
// Five is prime, so every count between one and five leaves a short last
|
||||
// row; three columns made it 3 then 2, which reads as a layout that ran out
|
||||
// of room rather than one that chose. So the strip goes from one column
|
||||
// straight to two and then to a single row of five, and is never ragged.
|
||||
// of room rather than one that chose. Two columns, then a single row of
|
||||
// five. One column on a phone stacked five cards over the episodes the
|
||||
// page exists to show.
|
||||
gridTemplateColumns: {
|
||||
default: "minmax(0, 1fr)",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
default: "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
listStyleType: "none",
|
||||
@@ -51,6 +51,8 @@ const styles = stylex.create({
|
||||
fact: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
// A card stretches to the tallest card in its row; the lines stay at the top.
|
||||
alignContent: "flex-start",
|
||||
alignItems: "baseline",
|
||||
gap: "0.375rem",
|
||||
borderRadius: metrics.radius,
|
||||
@@ -73,8 +75,10 @@ const styles = stylex.create({
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** Below 640px a value always starts its own line, so every card has the same shape. */
|
||||
value: {
|
||||
fontWeight: 500,
|
||||
flexBasis: { default: null, "@media (max-width: 639px)": "100%" },
|
||||
},
|
||||
detail: {
|
||||
flexBasis: "100%",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createQueryClient } from "@/lib/queryClient";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import ClientChart, { type ClientChartData } from "./ClientChart";
|
||||
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
import { STILL_COUNTING_OPACITY } from "./chartKit";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
const BUCKET = 1800;
|
||||
@@ -73,7 +74,10 @@ test("the columns are timestamped from this response's own window", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
|
||||
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
formatTime(SINCE),
|
||||
`${formatTime(SINCE + BUCKET)} · still counting`,
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -100,7 +104,7 @@ test("the series are coloured by rank, and Other in its own gray", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
const fills = Array.from(container.querySelectorAll("rect:not([data-still-counting])"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
|
||||
@@ -209,8 +213,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
// No dimming is left; the last column stays faint because it is still counting.
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
|
||||
});
|
||||
|
||||
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
|
||||
@@ -261,7 +266,7 @@ test("pointing at a bucket names its busiest clients, then Other, then the total
|
||||
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(0.55 * STILL_COUNTING_OPACITY)]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -294,8 +299,9 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
// No dimming is left; the last column stays faint because it is still counting.
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -310,6 +316,7 @@ test("a window where Other counted nothing keeps it in the legend and the table,
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
"Still counting",
|
||||
]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
@@ -326,3 +333,28 @@ test("the hidden table groups its counts like every other figure on the page", (
|
||||
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
|
||||
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The API ends every window at the end of the bucket that holds "now", so the
|
||||
* last column is still filling. Drawn like the others it reads as a collapse in
|
||||
* traffic; it is drawn faint inside a dashed outline, and every text says so.
|
||||
*/
|
||||
test("the last column is still counting, and says so in the drawing, the tooltip and the table", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
const outlines = container.querySelectorAll("rect[data-still-counting]");
|
||||
expect(outlines).toHaveLength(1);
|
||||
expect(outlines[0].getAttribute("stroke-dasharray")).toBeTruthy();
|
||||
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[1]);
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + BUCKET)} · still counting`);
|
||||
});
|
||||
|
||||
test("a last column with nothing in it yet draws no outline", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 0] }], [2, 0]));
|
||||
|
||||
expect(container.querySelectorAll("rect[data-still-counting]")).toHaveLength(0);
|
||||
expect(Array.from(container.querySelectorAll("ul li")).at(-1)?.textContent).toBe("Still counting");
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { OverviewClientSeries } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
@@ -25,8 +26,11 @@ import {
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STACK_BLEED,
|
||||
STILL_COUNTING_DASH,
|
||||
STILL_COUNTING_OPACITY,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
bucketTitle,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
@@ -38,6 +42,15 @@ import {
|
||||
} from "./chartKit";
|
||||
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** The column's outline, over its faint fill: the bucket that is still filling. */
|
||||
stillCounting: {
|
||||
fill: "none",
|
||||
stroke: colors.textSecondary,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
|
||||
const TOOLTIP_CLIENTS = 4;
|
||||
|
||||
@@ -110,6 +123,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
const yTicks = valueTicks(yScale);
|
||||
const colorOf = new Map(series.map((one) => [one.key, one.color]));
|
||||
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
|
||||
const last = bucketCount - 1;
|
||||
|
||||
/**
|
||||
* The bucket's busiest clients, then Other, then the total — eight named
|
||||
@@ -131,7 +145,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
value: formatCount(column[one.key]),
|
||||
}));
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
title: bucketTitle(column.ts, index === last),
|
||||
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
|
||||
};
|
||||
}
|
||||
@@ -140,7 +154,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
|
||||
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series, the last still counting`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -166,7 +180,10 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
opacity={
|
||||
(hovered.index === null || hovered.index === index ? 1 : 0.55) *
|
||||
(index === last ? STILL_COUNTING_OPACITY : 1)
|
||||
}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
@@ -187,6 +204,17 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
{totals[last] > 0 && (
|
||||
<rect
|
||||
data-still-counting=""
|
||||
x={xScale(columns[last].ts) ?? plot.x}
|
||||
y={yScale(totals[last])}
|
||||
width={xScale.bandwidth()}
|
||||
height={plot.bottom - yScale(totals[last])}
|
||||
strokeDasharray={STILL_COUNTING_DASH}
|
||||
{...stylex.props(styles.stillCounting)}
|
||||
/>
|
||||
)}
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
@@ -207,9 +235,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
{columns.map((column, index) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
|
||||
{series.map((one) => (
|
||||
<td key={one.key}>{formatCount(column[one.key])}</td>
|
||||
))}
|
||||
|
||||
@@ -450,7 +450,7 @@ test("the legend names the two curves, and the table adds the remainder", () =>
|
||||
);
|
||||
|
||||
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Total queries", "Blocked"]);
|
||||
expect(legend).toEqual(["Total queries", "Blocked", "Still counting"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
@@ -470,3 +470,42 @@ test("the hidden table groups its counts like every other figure on the page", (
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["12,345", "1,234", "11,111"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The API ends every window at the end of the bucket that holds "now", so the
|
||||
* last bucket is still filling, and a curve falling into it read as a collapse
|
||||
* in traffic. The stretch into the last point is drawn dashed over a fainter
|
||||
* fill, the settled curve stops where it starts, and every text says so.
|
||||
*/
|
||||
test("the curve into the last bucket is drawn as still counting", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 900, blocked: 90, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 800, blocked: 80, cached: 0 },
|
||||
{ ts: SINCE + 3600, queries: 130, blocked: 13, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const counting = container.querySelector('g[data-series="queries"] g[data-still-counting]') as SVGGElement;
|
||||
const [area, line] = Array.from(counting.querySelectorAll("path"));
|
||||
expect(line.getAttribute("stroke-dasharray")).toBeTruthy();
|
||||
expect(Number(area.getAttribute("fill-opacity"))).toBeLessThan(0.13);
|
||||
expect(seriesPaths(container, "queries").line.getAttribute("stroke-dasharray")).toBeNull();
|
||||
|
||||
// The two clips meet at the second-to-last point, where the settled curve ends.
|
||||
const middleX = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[3][0];
|
||||
const clips = Array.from(container.querySelectorAll("defs rect"));
|
||||
expect(clips).toHaveLength(2);
|
||||
expect(clips[0].getAttribute("width")).toBe(clips[1].getAttribute("x"));
|
||||
expect(Number(clips[0].getAttribute("width"))).toBeCloseTo(middleX, 0);
|
||||
|
||||
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
|
||||
fireEvent.mouseOver(overlayRects(container)[2]);
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
|
||||
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
|
||||
expect(rows.at(-1)?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
|
||||
expect(rows[0].textContent).toBe(formatTime(SINCE));
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* the reader is looking at.
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -19,11 +20,15 @@ import {
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STILL_COUNTING_DASH,
|
||||
STILL_COUNTING_OPACITY,
|
||||
areaPath,
|
||||
bucketTitle,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
pointScale,
|
||||
smoothPath,
|
||||
svgId,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
@@ -75,9 +80,9 @@ function columnsOf(buckets: Bucket[]): Column[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function tooltipOf(column: Column): TooltipContent {
|
||||
function tooltipOf(column: Column, stillCounting: boolean): TooltipContent {
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
title: bucketTitle(column.ts, stillCounting),
|
||||
rows: [
|
||||
...SERIES.map((series) => ({
|
||||
key: series.key,
|
||||
@@ -105,6 +110,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
|
||||
const clipId = svgId(useId());
|
||||
|
||||
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
@@ -120,12 +126,16 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
|
||||
const pointsOf = (key: SeriesKey): [number, number][] =>
|
||||
columns.map((column, i) => [centers[i], yScale(column[key])]);
|
||||
const last = columns.length - 1;
|
||||
// The curve into the last point belongs to the bucket that is still
|
||||
// filling, so everything right of the point before it is drawn as such.
|
||||
const settledEdge = last > 0 ? centers[last - 1] : plot.x;
|
||||
|
||||
return (
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`}
|
||||
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket, the last still counting`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -139,23 +149,45 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<defs>
|
||||
<clipPath id={`${clipId}-settled`}>
|
||||
<rect x={0} y={0} width={settledEdge} height={CHART_HEIGHT} />
|
||||
</clipPath>
|
||||
<clipPath id={`${clipId}-counting`}>
|
||||
<rect x={settledEdge} y={0} width={Math.max(0, width - settledEdge)} height={CHART_HEIGHT} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{SERIES.map((series) => {
|
||||
const points = pointsOf(series.key);
|
||||
const line = smoothPath(points, plot.y, plot.bottom);
|
||||
const area = areaPath(line, points, plot);
|
||||
return (
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<path
|
||||
d={areaPath(line, points, plot)}
|
||||
fill={series.color}
|
||||
fillOpacity={series.fillOpacity}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g clipPath={`url(#${clipId}-settled)`}>
|
||||
<path d={area} fill={series.color} fillOpacity={series.fillOpacity} />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clipPath={`url(#${clipId}-counting)`} data-still-counting="">
|
||||
<path
|
||||
d={area}
|
||||
fill={series.color}
|
||||
fillOpacity={series.fillOpacity * STILL_COUNTING_OPACITY}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={STILL_COUNTING_DASH}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -185,7 +217,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
content={tooltipOf(columns[hovered.index], hovered.index === last)}
|
||||
left={centers[hovered.index]}
|
||||
top={yScale(columns[hovered.index].queries)}
|
||||
beside
|
||||
@@ -206,9 +238,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
{columns.map((column, index) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
|
||||
<td>{formatCount(column.queries)}</td>
|
||||
<td>{formatCount(column.blocked)}</td>
|
||||
<td>{formatCount(column.allowed)}</td>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { formatBucketTime, formatCount } from "@/lib/format";
|
||||
import { formatBucketTime, formatCount, formatTime } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, layers, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
@@ -405,6 +405,30 @@ export function StackSegment({
|
||||
/** The amount one upper segment overlaps the one under it, in plot units. */
|
||||
export const STACK_BLEED = 0.5;
|
||||
|
||||
/**
|
||||
* The name for the last bucket of every window. The API ends a window at the
|
||||
* end of the bucket that holds "now" (overview.zig `window`), so that bucket is
|
||||
* still filling, and drawn like the others it reads as a collapse in traffic.
|
||||
* Both charts draw it faint and dashed, and the legend says what that means, so
|
||||
* the meaning does not depend on a hover.
|
||||
*/
|
||||
export const STILL_COUNTING = "Still counting";
|
||||
|
||||
/** Faint enough to read as unfinished beside the settled buckets, not as absent. */
|
||||
export const STILL_COUNTING_OPACITY = 0.45;
|
||||
|
||||
export const STILL_COUNTING_DASH = "4 3";
|
||||
|
||||
/** A tooltip or table title for a bucket, naming the one that is still filling. */
|
||||
export function bucketTitle(ts: number, stillCounting: boolean): string {
|
||||
return stillCounting ? `${formatTime(ts)} · ${STILL_COUNTING.toLowerCase()}` : formatTime(ts);
|
||||
}
|
||||
|
||||
/** `useId` output made safe inside an SVG `url(#…)` reference. */
|
||||
export function svgId(reactId: string): string {
|
||||
return reactId.replace(/[^A-Za-z0-9_-]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The transparent hit targets: one per bucket, spanning the whole plot height so
|
||||
* that pointing at the empty space above a short column or a low point still
|
||||
@@ -607,6 +631,11 @@ const legendStyles = stylex.create({
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
swatchCounting: {
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
|
||||
@@ -622,6 +651,10 @@ export function ChartLegend({ entries }: { entries: { key: string; label: string
|
||||
{entry.label}
|
||||
</li>
|
||||
))}
|
||||
<li {...stylex.props(legendStyles.item)}>
|
||||
<span aria-hidden="true" {...stylex.props(legendStyles.swatch, legendStyles.swatchCounting)} />
|
||||
{STILL_COUNTING}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,7 +213,14 @@ const styles = stylex.create({
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* Positioned, so it is the containing block of every `srOnly` box on a page,
|
||||
* such as a chart's data table. A scroll container clips only the absolutely
|
||||
* positioned boxes it contains; the others extend the document past the
|
||||
* shell, a second scroll under the one the page already has.
|
||||
*/
|
||||
main: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
|
||||
@@ -32,8 +32,15 @@
|
||||
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
|
||||
* has to reach a font that does before it falls back to a substitute box.
|
||||
*/
|
||||
/*
|
||||
* `overflow-wrap: break-word` breaks a word only where it cannot fit its line:
|
||||
* a group or source name is the user's text and can be one unbroken string
|
||||
* wider than a phone. It leaves min-content alone, so a table cell or a flex
|
||||
* item that holds such a name still needs `anywhere` of its own.
|
||||
*/
|
||||
html {
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
|
||||
color-scheme: light dark;
|
||||
|
||||
@@ -55,7 +55,8 @@ const styles = stylex.create({
|
||||
/**
|
||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||
* floor the dialog's Close button already sets, on both axes. The text is
|
||||
* 20px tall, and a one-word source name is narrower than 44px again.
|
||||
* 20px tall, and a one-word source name is narrower than 44px again. A
|
||||
* source name can also be one unbroken string wider than a phone's line.
|
||||
*/
|
||||
label: {
|
||||
minHeight: 44,
|
||||
@@ -65,6 +66,7 @@ const styles = stylex.create({
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
box: {
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
fact: {
|
||||
display: "inline-block",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Short facts joined by " · ". A narrow line breaks after a separator, and a
|
||||
* fact moves to the next line whole: "1" and "occurrence" on two lines read as
|
||||
* two facts. Only a fact wider than the whole line breaks inside itself.
|
||||
*/
|
||||
export default function FactLine({ facts }: { facts: readonly ReactNode[] }) {
|
||||
return facts.map((fact, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && " "}
|
||||
<span {...stylex.props(styles.fact)}>
|
||||
{fact}
|
||||
{index < facts.length - 1 && " ·"}
|
||||
</span>
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
@@ -264,10 +264,21 @@ export const styles = stylex.create({
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
|
||||
/**
|
||||
* Focusable: a table whose data is wider than its compact form still
|
||||
* scrolls sideways, and a keyboard must be able to reach the scroll. It is
|
||||
* also the size container each table's compact form queries.
|
||||
*
|
||||
* Positioned, so it is the containing block of the `srOnly` labels in its
|
||||
* header cells. A scroll container clips only the absolutely positioned
|
||||
* boxes it contains; an unclipped label at the far end of a wide table
|
||||
* widens the whole document, and a phone then zooms the page out to fit it.
|
||||
*/
|
||||
tableWrap: {
|
||||
position: "relative",
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
containerType: "inline-size",
|
||||
},
|
||||
/**
|
||||
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.22",
|
||||
.version = "0.0.24",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
services:
|
||||
nxdns:
|
||||
# Published multi-architecture image. Pin an exact version in production:
|
||||
# NXDNS_VERSION=0.0.1 docker compose up -d. To run a locally built image
|
||||
# instead, build it first (see deploy/docker/Dockerfile) and set
|
||||
# NXDNS_IMAGE=nxdns.
|
||||
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
|
||||
# Published multi-architecture image. The default is the release this file
|
||||
# is tagged with: `zig build cut` moves it in the bump commit, so the file
|
||||
# from a tag runs that tag's image. NXDNS_VERSION picks another release. To
|
||||
# run a locally built image instead, build it first (see
|
||||
# deploy/docker/Dockerfile) and set NXDNS_IMAGE=nxdns.
|
||||
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.24}}
|
||||
restart: unless-stopped
|
||||
# `run --config` makes the file the sole source of configuration: nxdns
|
||||
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
|
||||
|
||||
@@ -22,7 +22,7 @@ Steps for a goal you already have. They assume you know what nxdns is.
|
||||
- [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove.
|
||||
- [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary.
|
||||
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file.
|
||||
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, and Renovate for tag bumps.
|
||||
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, the `services.nxdns` NixOS module, and Renovate for tag bumps.
|
||||
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state.
|
||||
- [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start.
|
||||
- [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates.
|
||||
|
||||
@@ -132,17 +132,17 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
## 3. Run it
|
||||
|
||||
```sh
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
docker compose -f deploy/docker/compose.yaml up -d
|
||||
docker compose -f deploy/docker/compose.yaml logs -f
|
||||
```
|
||||
|
||||
`compose.yaml` reads the image from two variables: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set `NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does.
|
||||
`compose.yaml` names the release it was tagged with: its image is `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<release>}}`, and `zig build cut` writes `<release>` in the same commit that bumps `build.zig.zon`, so the file from the `v$VERSION` tag runs `$VERSION` with no variable set. Set `NXDNS_VERSION` to run a different release with the same file; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does. Compose files from `v0.0.22` and earlier default to `:latest` instead, so with one of those, set `NXDNS_VERSION=$VERSION` on every command.
|
||||
|
||||
> Verified on this host with `docker compose -f deploy/docker/compose.yaml
|
||||
> config`, which resolves the variables without contacting a registry: no
|
||||
> variables gives `git.mial.net/mokhtar/nxdns:latest`, `NXDNS_VERSION=0.0.1`
|
||||
> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives
|
||||
> `nxdns`.
|
||||
> Verified on 2026-10-20 with `docker compose -f deploy/docker/compose.yaml
|
||||
> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves
|
||||
> the variables without contacting a registry: no variables gives
|
||||
> `git.mial.net/mokhtar/nxdns:0.0.22`, `NXDNS_VERSION=0.0.21` gives
|
||||
> `git.mial.net/mokhtar/nxdns:0.0.21`, and `NXDNS_IMAGE=nxdns` gives `nxdns`.
|
||||
|
||||
Every block on this page runs from the repository root, and none of them change directory, so they can be pasted in order. `-f` is what makes that work: Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns` bind mount — against the directory holding the file, not against your shell, and it takes the project name `docker` from that directory either way, which is why the container is `docker-nxdns-1`.
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Install nxdns with Nix
|
||||
|
||||
Adds nxdns to a NixOS machine as a flake input pinned to a release tag. At the end `pkgs`-style references to `inputs.nxdns.packages.${system}.default` resolve to the published release binary, and a Renovate custom manager opens a pull request when a new tag appears.
|
||||
Adds nxdns to a NixOS machine as a flake input pinned to a release tag and runs it with the flake's `services.nxdns` module. At the end the machine serves DNS on port 53 from a configuration file that the pinned binary checked at build time, and a Renovate custom manager opens a pull request when a new tag appears.
|
||||
|
||||
nxdns publishes its own `flake.nix`. Its packages do not build nxdns from source: each one fetches the release tarball for the target and pins its SHA-256 hash, so a changed byte fails the build. The two supported systems are `aarch64-linux` and `x86_64-linux`, both static musl builds that need nothing on the host.
|
||||
|
||||
For the signature and checksum checks a human does once, see [verify a release](verify-a-release.md). For what each configuration field means, see [the configuration reference](../reference/configuration.md).
|
||||
|
||||
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. The consumer snippets in step 3 and step 4 are copied from the shape `rpi.mial.net` already uses for another flake input of the same author; they were not evaluated from this checkout, which is not a NixOS configuration.
|
||||
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. Step 3 was verified on 2026-10-20 by the flake's own checks, built with Nix 2.35.3 on an x86_64 host with KVM and the module running the 0.0.22 release: `checks.x86_64-linux.nixos-module` boots a NixOS VM with the module and `openFirewall`, gets `nas.lan` from `dig @127.0.0.1`, finds the packaged unit with its drop-in in `systemctl cat`, finds port 53 open over UDP and TCP, and switches to a configuration-only change that restarts the service and changes the answer; `checks.x86_64-linux.nixos-module-rejects-config` fails a system build on a bad regex with `FAIL rules[0].pattern: '(' is not a valid regex pattern`. The Renovate snippet in step 4 is copied from the shape `rpi.mial.net` already uses for another flake input of the same author.
|
||||
|
||||
## 1. Add the input, pinned to a tag
|
||||
|
||||
@@ -42,18 +42,56 @@ nix eval .#packages.x86_64-linux.default.outPath
|
||||
|
||||
Evaluation does not fetch the tarball. The download happens at build time, and the hash in nxdns's flake is what the build asserts the bytes against.
|
||||
|
||||
## 3. Use the package in a NixOS configuration
|
||||
## 3. Run the service
|
||||
|
||||
Pass the flake inputs to the module system, then reference the package:
|
||||
`nixosModules.default` exists from the first release after `v0.0.22`; pin that tag or a later one. A tag up to `v0.0.22` has the package only.
|
||||
|
||||
```nix
|
||||
{ inputs, pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = [ inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default ];
|
||||
Write the configuration next to the NixOS configuration that uses it. The smallest file that starts is one group named `default` and one enabled upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
}
|
||||
```
|
||||
|
||||
The derivation installs `bin/nxdns`, plus `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary. Run it as a service with the unit from [install with systemd](install-with-systemd.md), or write your own module around it.
|
||||
Pass the flake inputs to the module system, import the module, and enable it:
|
||||
|
||||
```nix
|
||||
{ inputs, ... }:
|
||||
{
|
||||
imports = [ inputs.nxdns.nixosModules.default ];
|
||||
|
||||
services.nxdns = {
|
||||
enable = true;
|
||||
configFile = ./nxdns-config.zon;
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Then `nixos-rebuild switch`. The module has four options:
|
||||
|
||||
| Option | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `enable` | `false` | Runs nxdns as `nxdns.service`, as the `nxdns` system user. |
|
||||
| `package` | the release of the pinned tag | The nxdns release to run. |
|
||||
| `configFile` | none, required | The configuration, in ZON. It is installed at `/etc/nxdns/config.zon`. |
|
||||
| `openFirewall` | `false` | Opens port 53 over UDP and TCP. The web interface on port 8080 and the DoH and DoT ports stay closed; open them yourself if you want them reachable from the network. |
|
||||
|
||||
`configFile` is copied into the Nix store, which every local user can read. Do not put a plaintext `web.password` in it: put the `web.password_hash` from an export there instead, as [set up admin authentication](set-up-admin-authentication.md) describes.
|
||||
|
||||
What the module does with them:
|
||||
|
||||
- **The build checks the configuration.** `nxdns import` from the pinned `package` reads `configFile` into a throwaway database during the build. A file that `import` rejects, because it does not parse, breaks a field rule or does not reconcile, fails `nixos-rebuild` with the binary's own reason, as in `FAIL rules[0].pattern: ...`, and nothing is deployed. `nxdns check` is not used, because it queries every upstream and the build sandbox has no network. So the build does not load the DoH and DoT certificate and key files, and does not reach the upstreams: a missing certificate still stops the service at start. The build also says nothing about the database already on the machine; see the rollback note below.
|
||||
- **nxdns runs in file mode.** The service runs `nxdns run --config=/etc/nxdns/config.zon`: every start reconciles the database onto the file, and the web interface refuses configuration edits. Change the file and switch to change anything. A switch that changes only the configuration restarts the service.
|
||||
- **The unit is the packaged one.** The package installs `deploy/systemd/nxdns.service` from the release tarball, with its sandboxing and its `RestartPreventExitStatus=2 64`, and the module adds a drop-in that changes only the command line and leaves `/etc/nxdns` to NixOS. `systemctl cat nxdns.service` shows both.
|
||||
|
||||
The module does not touch the machine's own resolver. If you point `networking.nameservers` at `127.0.0.1`, the machine loses name resolution whenever nxdns is down, including while you repair it; a resolver outside the machine avoids that.
|
||||
|
||||
**Rolling back.** A `nixos-rebuild --rollback` or an older generation puts an older binary in front of a database that the newer one may have migrated. That binary refuses the database with `SchemaTooNew`, exit 2, and the unit stops instead of restarting. [Upgrade](upgrade.md#what-happens-to-the-database) describes the recovery.
|
||||
|
||||
The package alone is also available, for a machine that runs nxdns some other way: `inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default` installs `bin/nxdns`, `lib/systemd/system/nxdns.service`, and `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary.
|
||||
|
||||
## 4. Let Renovate bump the tag
|
||||
|
||||
|
||||
@@ -273,6 +273,28 @@ curl -s http://127.0.0.1:8080/metrics | grep nxdns_upstream_
|
||||
|
||||
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream helps only with saturation, not with latency: failover is strict priority, not load spreading. A query does take the next upstream when the first one's slots are all busy — priority orders the candidates that can be admitted right now — but once every eligible upstream is full it blocks on the highest-priority one, and it still sends one query to one upstream at a time. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
|
||||
|
||||
## SERVFAIL rows that name no upstream
|
||||
|
||||
**Symptom.** The query log shows SERVFAIL rows whose `upstream` is empty, and `/api/health` shows every upstream healthy.
|
||||
|
||||
**Diagnosis.** A failed exchange names no upstream only when no attributable attempt happened, and two pool-wide counters say why:
|
||||
|
||||
```sh
|
||||
curl -s http://127.0.0.1:8080/metrics | grep -E 'nxdns_upstream_(budget_exhausted|local_resource)_total'
|
||||
```
|
||||
|
||||
`nxdns_upstream_budget_exhausted_total` is a query whose own budget ran out first; the previous section covers it. `nxdns_upstream_local_resource_total` is an exchange this process ended on its own resources: out of memory, out of file descriptors, or a system error the runtime has no name for. Neither is charged to an upstream, because no upstream caused it, and a local failure does not fail over, because the next upstream would hit the same wall. Both count exchanges, not rows without an upstream: when an earlier upstream had already failed in the same exchange, the row names that one.
|
||||
|
||||
**Fix.** For `local_resource_total`, look at the process's limits: `grep -E 'Max open files' /proc/$(pidof nxdns)/limits` against `ls /proc/$(pidof nxdns)/fd | wc -l`, and `VmRSS` in `/proc/$(pidof nxdns)/status` against the memory the host has. A connect that fails for a reason the runtime cannot name — a blackhole route to the upstream is one — is not counted here: it is charged to that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over.
|
||||
|
||||
## A DoH upstream fails with `HttpStatus (status 505 HTTP Version Not Supported)`
|
||||
|
||||
**Symptom.** Diagnostics shows the upstream failing with that cause, and no query ever succeeds against it.
|
||||
|
||||
**Diagnosis.** The server refuses HTTP/1.1. nxdns speaks DoH over HTTP/1.1 only, and some public resolvers (Quad9's `https://dns.quad9.net/dns-query`, for one) require HTTP/2. Other statuses name themselves the same way: a 403 or 404 is usually a wrong URL path.
|
||||
|
||||
**Fix.** Use that provider's DoT endpoint instead (for Quad9, `tls://9.9.9.9:853` with `tls_name = "dns.quad9.net"`), or a DoH upstream that accepts HTTP/1.1.
|
||||
|
||||
## The disk is filling up
|
||||
|
||||
**Symptom.** Writes stop but DNS keeps answering. The journal shows the transition:
|
||||
|
||||
@@ -168,20 +168,36 @@ journalctl -u nxdns -f
|
||||
|
||||
### Docker
|
||||
|
||||
The compose file names the release it belongs to, so upgrading the image is upgrading the file. A new release's compose file can change more than the image — the `command:` line or a port — so both ways below replace yours: if you edited it by hand, carry your edits over to the new one. To keep your own file as it is, skip the `curl` or the `git checkout` and set `NXDNS_VERSION=$VERSION` on the `pull` and `up` lines instead: it overrides the release the file names. A compose file from `v0.0.22` or earlier defaults to `:latest`, so with one of those the variable is needed on both lines, or `pull` and `up` can land on different images if a release happens between them.
|
||||
|
||||
With the single downloaded file of [Install with Docker](install-with-docker.md), fetch the new release's copy over it in its directory, then pull and recreate:
|
||||
|
||||
```sh
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
cd ~/nxdns
|
||||
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
With a checkout of the repository, fetch the tag and move to it, from the repository root:
|
||||
|
||||
```sh
|
||||
git fetch origin tag "v$VERSION"
|
||||
git checkout "v$VERSION"
|
||||
docker compose -f deploy/docker/compose.yaml pull
|
||||
docker compose -f deploy/docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
Compose recreates the container against the same `nxdns-data` volume. What happens to the file in `etc-nxdns` depends on the `command:` in your compose file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the configuration and the restart reconciles onto it; without it, the database in the volume is the configuration and the file is read by nothing.
|
||||
|
||||
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file falls back to `:latest`, and `pull` and `up` could then land on different images if a release happens between them.
|
||||
|
||||
> Not run on this host: `pull` needs a published image, and there is none.
|
||||
> What was run is `docker compose -f deploy/docker/compose.yaml config`, which
|
||||
> resolves the variables without contacting a registry: `NXDNS_VERSION=0.0.1`
|
||||
> gave `image: git.mial.net/mokhtar/nxdns:0.0.1`, and no variable at all gave
|
||||
> `:latest`.
|
||||
> Not run on this host: `pull` and `up` were not run against a new release.
|
||||
> What was run on 2026-10-20 is `docker compose -f deploy/docker/compose.yaml
|
||||
> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves
|
||||
> the variables without contacting a registry: no variable gave
|
||||
> `image: git.mial.net/mokhtar/nxdns:0.0.22`, and `NXDNS_VERSION=0.0.21` gave
|
||||
> `image: git.mial.net/mokhtar/nxdns:0.0.21`.
|
||||
> The fetch half was run the same day against `v0.0.22`: the `curl` URL
|
||||
> returned 200, and `git fetch --dry-run origin tag v0.0.22` exited 0.
|
||||
|
||||
## 4. Confirm the upgrade
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
|
||||
```
|
||||
|
||||
> Verified: the key is published, and that exact `curl | gpg --import` reported
|
||||
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
|
||||
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <…>"
|
||||
> imported` into a clean `GNUPGHOME`.
|
||||
|
||||
Then verify:
|
||||
@@ -109,7 +109,7 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
||||
```
|
||||
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||
gpg: Good signature from "Mokhtar Mial (pc) <…>" [unknown]
|
||||
gpg: WARNING: This key is not certified with a trusted signature!
|
||||
gpg: There is no indication that the signature belongs to the owner.
|
||||
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||
@@ -134,7 +134,7 @@ A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
||||
```
|
||||
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||
gpg: BAD signature from "Mokhtar Mial (pc) <…>" [unknown]
|
||||
```
|
||||
|
||||
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
|
||||
|
||||
@@ -207,6 +207,8 @@ A `tls://` upstream's host must be an IP literal. nxdns does not resolve an upst
|
||||
|
||||
A DoH upstream is not affected: `https://` goes through the HTTP client, which resolves normally, so a hostname there is the usual form.
|
||||
|
||||
The DoH client speaks HTTP/1.1 only (HTTP/2 is out of scope), so a DoH server that requires HTTP/2 fails every exchange. Quad9 is one: `https://dns.quad9.net/dns-query` answers HTTP/1.1 with `505` (checked 2026-10-19). Reach Quad9 over DoT instead, as in the example above.
|
||||
|
||||
### clients
|
||||
|
||||
Known clients with a fixed group assignment.
|
||||
@@ -425,7 +427,7 @@ The smallest file that passes validation: a `default` group and one enabled upst
|
||||
```zon
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -483,7 +485,7 @@ The smallest file that passes validation: a `default` group and one enabled upst
|
||||
// tls_name is needed when a tls:// upstream is written as an IP
|
||||
// literal, so certificate verification has a DNS name to match.
|
||||
.upstreams = .{
|
||||
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
|
||||
.{ .url = "https://cloudflare-dns.com/dns-query", .priority = 10 },
|
||||
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
|
||||
},
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, ... }:
|
||||
{ self, nixpkgs, ... }:
|
||||
let
|
||||
# `zig build cut` rewrites the lines between the delimiters by text replacement.
|
||||
# BEGIN GENERATED BY zig build cut
|
||||
version = "0.0.22";
|
||||
version = "0.0.24";
|
||||
hashes = {
|
||||
"aarch64-linux" = "sha256-XzhqMazdf4YZyTSb6KSRuJZxLZUGsq/7Z9ckez7ZxcA=";
|
||||
"x86_64-linux" = "sha256-i5rvzs7Q5vs2HkpnC6H7el1FJ7APHwIS7wbW/5DCC80=";
|
||||
"aarch64-linux" = "sha256-nsjFtsFlUJHp3AJj7ccrXmgBq1QK2tRAvmdofeTqc2E=";
|
||||
"x86_64-linux" = "sha256-VxDq+UiOWPO4Wyhwdgky1X0QVAqeDgAqD7KeqbTLOJ8=";
|
||||
};
|
||||
# END GENERATED BY zig build cut
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 nxdns $out/bin/nxdns
|
||||
install -Dm644 nxdns.service $out/lib/systemd/system/nxdns.service
|
||||
substituteInPlace $out/lib/systemd/system/nxdns.service \
|
||||
--replace-fail "ExecStart=/usr/local/bin/nxdns run" "ExecStart=$out/bin/nxdns run"
|
||||
install -Dm644 LICENSE $out/share/doc/nxdns/LICENSE
|
||||
install -Dm644 THIRD-PARTY-NOTICES $out/share/doc/nxdns/THIRD-PARTY-NOTICES
|
||||
|
||||
@@ -110,5 +113,12 @@
|
||||
packages = nixpkgs.lib.genAttrs systems (system: {
|
||||
default = package system;
|
||||
});
|
||||
|
||||
nixosModules.default = import ./nix/module.nix { inherit self; };
|
||||
|
||||
checks.x86_64-linux = import ./nix/tests.nix {
|
||||
inherit self;
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# `services.nxdns`: the packaged unit, run in file mode against a configuration
|
||||
# that the pinned binary accepted at build time.
|
||||
#
|
||||
# The unit is `deploy/systemd/nxdns.service` from the release tarball, installed
|
||||
# by the package and picked up through `systemd.packages`. This module only adds
|
||||
# a drop-in, so the hardening lives in that one file and a change to it reaches
|
||||
# NixOS with the release that ships it.
|
||||
{ self }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.nxdns;
|
||||
|
||||
# Nix treats the ZON as opaque data, so without this a configuration the
|
||||
# pinned binary rejects evaluates, builds and deploys, and fails only when
|
||||
# the service starts. `import` into a throwaway data directory is the offline
|
||||
# validator: it shares the parser, the validator and the reconcile engine
|
||||
# with `run`, and its delete gate is inert on an empty database. `check` is
|
||||
# not usable here: it probes every upstream, and the build sandbox has no
|
||||
# network.
|
||||
validatedConfig =
|
||||
pkgs.runCommand "nxdns-config.zon"
|
||||
{
|
||||
nativeBuildInputs = [ cfg.package ];
|
||||
src = cfg.configFile;
|
||||
}
|
||||
''
|
||||
mkdir -p "$TMPDIR/validate"
|
||||
nxdns import --data-dir "$TMPDIR/validate" "$src"
|
||||
cp "$src" "$out"
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.services.nxdns = {
|
||||
enable = lib.mkEnableOption "nxdns, a DNS sinkhole for a household network";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "nxdns.packages.\${pkgs.stdenv.hostPlatform.system}.default";
|
||||
description = "The nxdns release to run. The default is the release of the flake revision this module comes from.";
|
||||
};
|
||||
|
||||
configFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
example = lib.literalExpression "./nxdns-config.zon";
|
||||
description = ''
|
||||
The nxdns configuration, in ZON. nxdns runs in file mode: every start
|
||||
reconciles the database onto this file, and the web interface refuses
|
||||
configuration edits. The build fails when `nxdns import` from
|
||||
`package` rejects the file.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open port 53 over UDP and TCP. The web interface port and the DoH and DoT ports stay closed.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.etc."nxdns/config.zon".source = validatedConfig;
|
||||
|
||||
users.users.nxdns = {
|
||||
isSystemUser = true;
|
||||
group = "nxdns";
|
||||
description = "nxdns DNS sinkhole";
|
||||
};
|
||||
users.groups.nxdns = { };
|
||||
|
||||
systemd.packages = [ cfg.package ];
|
||||
|
||||
systemd.services.nxdns = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
# The unit file does not change when only the configuration does, so
|
||||
# without this a configuration-only switch restarts nothing and the old
|
||||
# configuration keeps serving with no error anywhere.
|
||||
restartTriggers = [ validatedConfig ];
|
||||
|
||||
serviceConfig = {
|
||||
# The empty entry clears the packaged ExecStart: a drop-in cannot
|
||||
# replace it otherwise.
|
||||
ExecStart = [
|
||||
""
|
||||
"${lib.getExe cfg.package} run --config=/etc/nxdns/config.zon"
|
||||
];
|
||||
# `environment.etc` creates /etc/nxdns and owns what is in it. A
|
||||
# ConfigurationDirectory would make systemd adjust the same directory
|
||||
# at every start.
|
||||
ConfigurationDirectory = "";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||
allowedUDPPorts = [ 53 ];
|
||||
allowedTCPPorts = [ 53 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# The acceptance checks for `services.nxdns`. Neither runs in CI: the VM test
|
||||
# needs KVM, and both need a nixpkgs closure the shared runner should not
|
||||
# download on every push. AGENTS.md says when to run them.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
lib = pkgs.lib;
|
||||
|
||||
# A local record answers without any upstream, which the test VM cannot reach.
|
||||
configWith =
|
||||
address:
|
||||
pkgs.writeText "nxdns-test.zon" ''
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
|
||||
.local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "${address}" } },
|
||||
}
|
||||
'';
|
||||
|
||||
rejected = pkgs.writeText "nxdns-rejected.zon" ''
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
|
||||
.rules = .{ .{ .group = "default", .pattern = "(", .kind = .regex, .action = .block } },
|
||||
}
|
||||
'';
|
||||
|
||||
machineWith = configFile: {
|
||||
imports = [ self.nixosModules.default ];
|
||||
services.nxdns = {
|
||||
enable = true;
|
||||
inherit configFile;
|
||||
};
|
||||
boot.loader.grub.enable = false;
|
||||
fileSystems."/" = {
|
||||
device = "none";
|
||||
fsType = "tmpfs";
|
||||
};
|
||||
system.stateVersion = lib.trivial.release;
|
||||
};
|
||||
in
|
||||
{
|
||||
nixos-module = pkgs.testers.runNixOSTest {
|
||||
name = "nxdns-module";
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
services.nxdns = {
|
||||
enable = true;
|
||||
configFile = configWith "192.0.2.5";
|
||||
openFirewall = true;
|
||||
};
|
||||
environment.systemPackages = [ pkgs.dnsutils ];
|
||||
specialisation.changed.configuration.services.nxdns.configFile = lib.mkForce (
|
||||
configWith "192.0.2.6"
|
||||
);
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
machine.wait_for_unit("nxdns.service")
|
||||
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.5")
|
||||
|
||||
# The packaged unit runs, with the module's drop-in on top of it.
|
||||
unit = machine.succeed("systemctl cat nxdns.service")
|
||||
assert "/lib/systemd/system/nxdns.service" in unit, unit
|
||||
assert "ProtectSystem=strict" in unit, unit
|
||||
assert "run --config=/etc/nxdns/config.zon" in unit, unit
|
||||
machine.succeed("systemctl show -p User --value nxdns.service | grep -x nxdns")
|
||||
machine.succeed("test -z \"$(systemctl show -p ConfigurationDirectory --value nxdns.service)\"")
|
||||
machine.succeed("systemctl show -p ReadOnlyPaths --value nxdns.service | tr ' ' '\\n' | grep -Fx /etc/nxdns")
|
||||
machine.succeed("stat -c %U /etc/nxdns | grep -x root")
|
||||
machine.succeed("iptables -S nixos-fw | grep -- '-p udp .*--dport 53 '")
|
||||
machine.succeed("iptables -S nixos-fw | grep -- '-p tcp .*--dport 53 '")
|
||||
|
||||
# A switch that changes only the configuration restarts the service.
|
||||
before = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
|
||||
machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test")
|
||||
machine.wait_for_unit("nxdns.service")
|
||||
after = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
|
||||
assert before != after, "a configuration-only switch did not restart nxdns"
|
||||
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.6")
|
||||
'';
|
||||
};
|
||||
|
||||
# A configuration the pinned binary rejects must fail the system build, and
|
||||
# the build log must carry the binary's own reason.
|
||||
nixos-module-rejects-config =
|
||||
let
|
||||
system = pkgs.nixos (machineWith rejected);
|
||||
failure = pkgs.testers.testBuildFailure system.config.environment.etc."nxdns/config.zon".source;
|
||||
in
|
||||
pkgs.runCommand "nxdns-module-rejects-config" { } ''
|
||||
grep -F 'FAIL rules[0].pattern' ${failure}/testBuildFailure.log
|
||||
touch "$out"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# NixOS module: `services.nxdns`
|
||||
|
||||
Owner ruling (2026-10-20): ship `nixosModules.default` from the flake, with the options `enable`, `package`, `configFile` and `openFirewall`, file mode only, no `settings` attrset, and the shipped `nxdns.service` reused through `systemd.packages` plus a drop-in. The owner left Nix in CI, the VM test and a distinct exit code for `SchemaTooNew` to the implementer, within one constraint: the self-hosted Gitea runner (`../mial.net/manifests/gitea/`) is shared and must not be monopolized.
|
||||
|
||||
## Why
|
||||
|
||||
The flake already shipped a package and had one consumer, `rpi.mial.net`, which wrote its own module. Four parts of that module are not specific to the Pi, and two encode incidents: the build-time `nxdns import` validation, and `restartTriggers`, without which a configuration-only deploy restarted nothing (2026-08-16). A package with no service is half an install on NixOS.
|
||||
|
||||
## What ships
|
||||
|
||||
- `nix/module.nix`. `configFile` goes through `nxdns import --data-dir $TMPDIR/validate` from `package` in a `runCommand`, and the result is `/etc/nxdns/config.zon`. The `nxdns` system user and group. `systemd.packages = [ package ]`, and a drop-in that clears `ExecStart` and sets `run --config=/etc/nxdns/config.zon`, clears `ConfigurationDirectory` because `environment.etc` owns `/etc/nxdns`, adds `wantedBy` and `restartTriggers`. `openFirewall` opens 53/udp and 53/tcp and nothing else.
|
||||
- The package installs the tarball's `nxdns.service` to `lib/systemd/system/` with `ExecStart` pointed at its own binary. `--replace-fail` makes a changed `ExecStart` line in the unit a package build failure, not a silent mismatch.
|
||||
- Not shipped: database mode, a Nix-to-ZON renderer, and the Pi's choices about its own resolver, which stay in `rpi.mial.net`.
|
||||
|
||||
## Decisions left to the implementer
|
||||
|
||||
- **No Nix in CI.** No job installs Nix today. The checks need a nixpkgs closure and, for the VM test, KVM; downloading that on every push costs the shared runner disk and network, and the runner's cache already caused a disk-pressure eviction on 2026-08-23. The cut's existing `nix flake check --no-build` evaluates both checks at every release, which catches a module that no longer evaluates.
|
||||
- **The VM test runs on the developer's host.** `checks.x86_64-linux.nixos-module` and `checks.x86_64-linux.nixos-module-rejects-config` are built locally, with KVM, after any change to the module, the checks, the package or the unit. AGENTS.md says so. Whether the runner node exposes `/dev/kvm` was not checked, and nothing here depends on it.
|
||||
- **No new exit code.** `SchemaTooNew` is already in `config/faults.zig`, so `run` exits 2 for it and the unit's `RestartPreventExitStatus=2 64` stops instead of restart-looping (`src/app.zig` tests the mapping). A NixOS rollback onto a migrated database therefore stops the unit, and `docs/how-to/upgrade.md` describes the recovery. The exit-1 behaviour in the Pi module's notes dates from 0.0.2.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `nix flake check --no-build` passes.
|
||||
- `nixos-module`: a VM with the module and `openFirewall` answers `nas.lan` from a local record; `systemctl cat` shows the packaged unit and the drop-in; the service runs as `nxdns`; the effective `ConfigurationDirectory` is empty, `ReadOnlyPaths` still holds `/etc/nxdns`, and `/etc/nxdns` belongs to root; port 53 is open over UDP and TCP; `switch-to-configuration test` into a specialisation that changes only `configFile` gives a new `InvocationID` and the new answer. Without `restartTriggers` the test fails on the restart assertion, and without the `ConfigurationDirectory` clear it fails on that one (each checked once by removing the line).
|
||||
- `nixos-module-rejects-config`: a system build with an invalid regex rule fails, and its log carries `FAIL rules[0].pattern`.
|
||||
- `docs/how-to/install-with-nix.md` documents the module, the options, the store-readability of `configFile`, and rollback.
|
||||
|
||||
## After the release
|
||||
|
||||
`rpi.mial.net` replaces its module with `services.nxdns` and keeps its Pi-only parts, through the rpi-nixos-iac peer.
|
||||
@@ -102,3 +102,13 @@ On a resumed cut — the bump commit exists and its tag does not — the pin sta
|
||||
### Tests
|
||||
|
||||
The `gates.yml` env parser against a fixture and against the real file; the environment builder, asserting the nine keys and nothing else; and the `sh` wrapper, asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The stage's remaining steps are process spawns of `npm`, `zig` and `nix`, which are not mocked — the same rule the rest of this file follows.
|
||||
|
||||
## Addendum: the compose file names its release (post-0.0.22)
|
||||
|
||||
Owner question (2026-10-20), on the site's `NXDNS_VERSION=0.0.22 docker compose up -d`: "why do we need the NXDNS_VERSION to pull up a docker compose file? shouldnt the version be specified in the file already??" Until 0.0.22, `deploy/docker/compose.yaml` read `${NXDNS_VERSION:-latest}`, so the file fetched from a tag ran whatever `:latest` was that day, and every install and upgrade command had to carry the variable.
|
||||
|
||||
The image default is now the release itself: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<version>}}`. `NXDNS_VERSION` and `NXDNS_IMAGE` stay as overrides.
|
||||
|
||||
- **The bump moves it.** This amends step 2 of the pin stage: the cut reads the compose file and computes its rewrite before anything is written, so a file without exactly one `${NXDNS_VERSION:-…}` default is a refusal on a clean tree. Then `build.zig.zon` and the compose file are both written atomically and read back. Step 5's commit takes `build.zig.zon`, `flake.nix` and `deploy/docker/compose.yaml`, and the working-tree diff is asserted to be a subset of those three paths.
|
||||
- **One guard, on every commit.** A cut-tool test reads the real `build.zig.zon` and `deploy/docker/compose.yaml` and asserts that the default equals the declared version. It runs in `zig build test`, so a hand edit of either file fails CI on that commit. The tag's tree is the bump commit's tree, which passed CI before the tag was made, so `verify-dist` gets no second copy of the check.
|
||||
- **The docs drop the variable from the plain flow.** README, install-with-docker and upgrade run `docker compose up -d`; the variables are documented as overrides, and the pages say that a compose file from `v0.0.22` or earlier still defaults to `:latest`.
|
||||
|
||||
@@ -123,7 +123,11 @@ pub const DohClient = struct {
|
||||
// is an error before the location is ever read.
|
||||
var resp = req.receiveHead(&.{}) catch |err| return fault(headCause(&req, err), .receive);
|
||||
|
||||
if (resp.head.status != .ok) return peerFault(error.HttpStatus, error.HttpStatus);
|
||||
if (resp.head.status != .ok) return .{ .fault = .{
|
||||
.kind = error.HttpStatus,
|
||||
.cause = error.HttpStatus,
|
||||
.http_status = resp.head.status,
|
||||
} };
|
||||
// `head.content_type` points into memory that `resp.reader` invalidates,
|
||||
// so the check happens before the body stream starts.
|
||||
if (!contentTypeOk(resp.head.content_type)) return peerFault(error.HttpContentType, error.HttpContentType);
|
||||
@@ -175,7 +179,19 @@ const Phase = enum { connect, send, receive };
|
||||
/// arrive here as themselves. Without them a decode error or a bad record MAC
|
||||
/// would be reported as a plain receive failure.
|
||||
fn fault(err: anyerror, phase: Phase) transport.LeafError!transport.Outcome {
|
||||
if (transport.mapLocal(err)) |local| return local;
|
||||
// `.connect` is the whole of `http.request`: the name lookup, the TCP
|
||||
// connect and the handshake. The handshake's only `error.Unexpected` is the
|
||||
// errno the socket stream stored (std/http/Client.zig:1466; the TLS
|
||||
// client's `InitError` has none), and a CA bundle scan fails as
|
||||
// `CertificateBundleLoadFailure`. The lookup's resolver socket can fail
|
||||
// locally, but that still leaves this upstream unreachable while a DoT
|
||||
// upstream, always an IP literal, needs no lookup, so the fault belongs to
|
||||
// this endpoint and the pool is right to move on.
|
||||
const local = switch (phase) {
|
||||
.connect => transport.mapLocalConnect(err),
|
||||
.send, .receive => transport.mapLocal(err),
|
||||
};
|
||||
if (local) |claimed| return claimed;
|
||||
return peerFault(kindOf(err, phase), err);
|
||||
}
|
||||
|
||||
@@ -331,6 +347,12 @@ test "a local cause stays an error instead of becoming a fault" {
|
||||
try testing.expectError(error.Unexpected, fault(error.Unexpected, .send));
|
||||
}
|
||||
|
||||
test "an unnamed connect errno is a connect fault, so the pool fails over" {
|
||||
const outcome = try fault(error.Unexpected, .connect);
|
||||
try testing.expectEqual(transport.PeerFault.ConnectFailed, outcome.fault.kind);
|
||||
try testing.expectEqual(error.Unexpected, outcome.fault.cause);
|
||||
}
|
||||
|
||||
test "kindOf maps the collapsed tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, kindOf(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, kindOf(error.TlsInitializationFailed, .receive));
|
||||
|
||||
@@ -279,7 +279,8 @@ pub const DotClient = struct {
|
||||
|
||||
const stream = address.connect(io, .{ .mode = .stream }) catch |err| {
|
||||
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
|
||||
return try transport.faultOrLocal(err, error.ConnectFailed);
|
||||
if (transport.mapLocalConnect(err)) |local| return local;
|
||||
return .{ .kind = error.ConnectFailed, .cause = err };
|
||||
};
|
||||
|
||||
// Emplaced before the handshake, never built beside it and copied in:
|
||||
|
||||
@@ -56,10 +56,10 @@ comptime {
|
||||
pub const window_len = 32;
|
||||
|
||||
/// Bytes kept of a rendered `transport.Fault`, truncated to fit. Sized so the
|
||||
/// widest classification and the widest concrete cause name fit whole; the
|
||||
/// comptime-driven test below recomputes that worst case from the std error
|
||||
/// sets, so a wider name std adds fails the test run rather than silently
|
||||
/// truncating an operator's only diagnostic.
|
||||
/// widest classification and the widest concrete cause name fit whole, and so
|
||||
/// does the widest HTTP status with its phrase; the comptime-driven test below
|
||||
/// recomputes that worst case from std, so a wider name std adds fails the test
|
||||
/// run rather than silently truncating an operator's only diagnostic.
|
||||
pub const error_name_capacity = 64;
|
||||
|
||||
/// The shift is capped so `base_backoff_ms << shift` cannot run away; by then
|
||||
@@ -552,7 +552,7 @@ test "lastError truncates a cause too wide for the buffer instead of overflowing
|
||||
try testing.expectEqualStrings(whole[0..error_name_capacity], state.lastError());
|
||||
}
|
||||
|
||||
test "error_name_capacity holds the widest classification and cause whole" {
|
||||
test "error_name_capacity holds the widest classification, cause and status whole" {
|
||||
// The widest concrete cause an upstream unwrap can produce. The three sets
|
||||
// are the ones `transport.zig`'s unwraps return a member of; a wider name
|
||||
// std adds fails here rather than truncating an operator's diagnostic.
|
||||
@@ -579,6 +579,18 @@ test "error_name_capacity holds the widest classification and cause whole" {
|
||||
|
||||
try testing.expectEqualStrings("DetectingNetworkConfigurationFailed", widest_cause);
|
||||
try testing.expect(widest_kind.len + " (cause ".len + widest_cause.len + ")".len <= error_name_capacity);
|
||||
|
||||
const widest_phrase = comptime blk: {
|
||||
var widest: []const u8 = "";
|
||||
for (@typeInfo(std.http.Status).@"enum".fields) |field| {
|
||||
const phrase = @as(std.http.Status, @enumFromInt(field.value)).phrase() orelse continue;
|
||||
if (phrase.len > widest.len) widest = phrase;
|
||||
}
|
||||
break :blk widest;
|
||||
};
|
||||
// `std.http.Status` is a u10, so a code has at most four digits.
|
||||
const widest_status = "HttpStatus (status ".len + 4 + " ".len + widest_phrase.len + ")".len;
|
||||
try testing.expect(widest_status <= error_name_capacity);
|
||||
}
|
||||
|
||||
/// The fault of a `tripped` effect, or an error when the effect says `clear`.
|
||||
|
||||
@@ -382,6 +382,13 @@ pub const Pool = struct {
|
||||
/// removes. Read through `budgetExhaustedTotal`, since `Snapshot` is
|
||||
/// per-entry and has nowhere to put it.
|
||||
budget_exhausted_total: std.atomic.Value(u64) = .init(0),
|
||||
/// Exchanges this process ended on its own resources (memory, descriptors,
|
||||
/// an undersized buffer, an errno std does not name outside the connect
|
||||
/// phase), counted once each by `exchange`. Pool-wide for the reason
|
||||
/// `budget_exhausted_total` is: no endpoint caused it, so the pool records
|
||||
/// it against none, and the SERVFAIL it becomes names no upstream. Read
|
||||
/// through `localResourceTotal`.
|
||||
local_resource_total: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
/// The failover loop tracks which entries it has already spent in a bitset
|
||||
/// on its stack, so the number of upstreams one pool may hold is bounded.
|
||||
@@ -461,11 +468,13 @@ pub const Pool = struct {
|
||||
) transport.ExchangeError![]u8 {
|
||||
const deadline: std.Io.Clock.Timestamp = .fromNow(io, self.timeouts.total);
|
||||
return self.failover(io, query, response_buf, selected, deadline) catch |err| {
|
||||
// Once per exhausted exchange, wherever the exhaustion was
|
||||
// detected — the loop's own waits and attempts, or a leaf that
|
||||
// reported one of its own.
|
||||
if (err == error.BudgetExhausted) {
|
||||
_ = self.budget_exhausted_total.fetchAdd(1, .monotonic);
|
||||
// Once per failed exchange, wherever the failure was detected —
|
||||
// the loop's own waits and attempts, or a leaf that reported one of
|
||||
// its own.
|
||||
switch (transport.group(err)) {
|
||||
.budget_exhausted => _ = self.budget_exhausted_total.fetchAdd(1, .monotonic),
|
||||
.local_resource => _ = self.local_resource_total.fetchAdd(1, .monotonic),
|
||||
.peer_fault, .cancellation => {},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
@@ -518,6 +527,11 @@ pub const Pool = struct {
|
||||
return self.budget_exhausted_total.load(.acquire);
|
||||
}
|
||||
|
||||
/// Exchanges that ended on this process's own resources, pool-wide.
|
||||
pub fn localResourceTotal(self: *const Pool) u64 {
|
||||
return self.local_resource_total.load(.acquire);
|
||||
}
|
||||
|
||||
/// The two-pass failover loop.
|
||||
///
|
||||
/// Pass one sweeps the entries health says are available, in priority
|
||||
@@ -1431,6 +1445,11 @@ test "a local resource error short-circuits and records nothing" {
|
||||
try testing.expectEqual(@as(usize, 0), good.calls.load(.acquire));
|
||||
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
|
||||
try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures);
|
||||
// Recorded against no endpoint, but still counted: the SERVFAIL it becomes
|
||||
// names no upstream, and this is the only trace of why.
|
||||
try testing.expectEqual(@as(u64, 1), pool.localResourceTotal());
|
||||
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
|
||||
try testing.expect(selected == null);
|
||||
}
|
||||
|
||||
test "a cancellation short-circuits and records nothing" {
|
||||
@@ -1453,6 +1472,7 @@ test "a cancellation short-circuits and records nothing" {
|
||||
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
|
||||
// A torn-down task is not a budget that ran out, and it names no endpoint.
|
||||
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
|
||||
try testing.expectEqual(@as(u64, 0), pool.localResourceTotal());
|
||||
try testing.expect(selected == null);
|
||||
}
|
||||
|
||||
@@ -1722,6 +1742,7 @@ test "budget_exhausted_total counts exhausted exchanges once each" {
|
||||
stalling.behavior = .{ .reply = response_bytes };
|
||||
_ = try pool.exchange(io, query_bytes, &buf, &selected);
|
||||
try testing.expectEqual(@as(u64, 3), pool.budgetExhaustedTotal());
|
||||
try testing.expectEqual(@as(u64, 0), pool.localResourceTotal());
|
||||
}
|
||||
|
||||
test "two stalling upstreams cost the total budget, not one budget each" {
|
||||
|
||||
@@ -263,6 +263,20 @@ pub fn mapPhase(err: anyerror, phase: PeerFault) ExchangeError {
|
||||
return mapLocal(err) orelse phase;
|
||||
}
|
||||
|
||||
/// `mapLocal` for the connect phase, where `error.Unexpected` belongs to the
|
||||
/// peer.
|
||||
///
|
||||
/// zig 0.16.0's `connect()` names no `EINVAL` (std/Io/Threaded.zig:11909), and
|
||||
/// `EINVAL` is what a blackhole route to the peer returns, so a route fault
|
||||
/// arrives as `error.Unexpected`. Claimed as local, it would end the exchange
|
||||
/// without failover and record nothing against the endpoint. In this phase an
|
||||
/// errno std does not name is about the path to this peer, so it stays a peer
|
||||
/// fault and the pool moves on to the next endpoint.
|
||||
pub fn mapLocalConnect(err: anyerror) ?LeafError {
|
||||
if (err == error.Unexpected) return null;
|
||||
return mapLocal(err);
|
||||
}
|
||||
|
||||
/// Closes `target` with cancellation blocked.
|
||||
///
|
||||
/// A transport's close runs from a `defer` chain that a lost timeout race is
|
||||
@@ -458,10 +472,19 @@ pub const Client = struct {
|
||||
pub const Fault = struct {
|
||||
kind: PeerFault,
|
||||
cause: anyerror,
|
||||
/// The status the peer answered with, when that is the fault. An error
|
||||
/// name cannot carry it, and it is what says why the peer refused: a 505
|
||||
/// is a DoH server that will not speak HTTP/1.1.
|
||||
http_status: ?std.http.Status = null,
|
||||
|
||||
/// The one text every surface prints. `<Kind> (cause <Cause>)`.
|
||||
/// The one text every surface prints: `<Kind> (cause <Cause>)`, or
|
||||
/// `<Kind> (status <code> <phrase>)` for a peer that answered with a status.
|
||||
pub fn format(self: Fault, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
try w.print("{t} (cause {s})", .{ self.kind, @errorName(self.cause) });
|
||||
const status = self.http_status orelse
|
||||
return w.print("{t} (cause {s})", .{ self.kind, @errorName(self.cause) });
|
||||
try w.print("{t} (status {d}", .{ self.kind, @intFromEnum(status) });
|
||||
if (status.phrase()) |phrase| try w.print(" {s}", .{phrase});
|
||||
try w.writeByte(')');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -776,6 +799,37 @@ test "mapPhase names the phase unless the error is this process's own" {
|
||||
);
|
||||
}
|
||||
|
||||
test "mapLocalConnect leaves an unnamed connect errno to the peer" {
|
||||
try testing.expectEqual(@as(?ExchangeError, null), mapLocalConnect(error.Unexpected));
|
||||
try testing.expectEqual(@as(?ExchangeError, error.OutOfMemory), mapLocalConnect(error.OutOfMemory));
|
||||
try testing.expectEqual(
|
||||
@as(?ExchangeError, error.ProcessFdQuotaExceeded),
|
||||
mapLocalConnect(error.ProcessFdQuotaExceeded),
|
||||
);
|
||||
try testing.expectEqual(@as(?ExchangeError, error.Canceled), mapLocalConnect(error.Canceled));
|
||||
try testing.expectEqual(@as(?ExchangeError, null), mapLocalConnect(error.NetworkUnreachable));
|
||||
}
|
||||
|
||||
test "a fault with a status prints the code and its phrase" {
|
||||
var buf: [64]u8 = undefined;
|
||||
const refused: Fault = .{
|
||||
.kind = error.HttpStatus,
|
||||
.cause = error.HttpStatus,
|
||||
.http_status = .http_version_not_supported,
|
||||
};
|
||||
try testing.expectEqualStrings(
|
||||
"HttpStatus (status 505 HTTP Version Not Supported)",
|
||||
try std.fmt.bufPrint(&buf, "{f}", .{refused}),
|
||||
);
|
||||
|
||||
// A code std has no name for still prints, without a phrase.
|
||||
const unnamed: Fault = .{ .kind = error.HttpStatus, .cause = error.HttpStatus, .http_status = @enumFromInt(599) };
|
||||
try testing.expectEqualStrings("HttpStatus (status 599)", try std.fmt.bufPrint(&buf, "{f}", .{unnamed}));
|
||||
|
||||
const plain: Fault = .{ .kind = error.ConnectFailed, .cause = error.Unexpected };
|
||||
try testing.expectEqualStrings("ConnectFailed (cause Unexpected)", try std.fmt.bufPrint(&buf, "{f}", .{plain}));
|
||||
}
|
||||
|
||||
/// Stands in for whatever the pool or the forward client races. The variants
|
||||
/// are the three ways such a task ends: in time, too late, or with a failure of
|
||||
/// its own.
|
||||
|
||||
@@ -186,6 +186,9 @@ pub const Sample = struct {
|
||||
/// label and cannot live in `UpstreamSample`. Absent while no pool is
|
||||
/// wired, like every other collaborator.
|
||||
upstream_budget_exhausted_total: ?u64 = null,
|
||||
/// Exchanges the pool ended on this process's own resources. Pool-wide for
|
||||
/// the same reason as `upstream_budget_exhausted_total`.
|
||||
upstream_local_resource_total: ?u64 = null,
|
||||
peaks: PeakSample = .{},
|
||||
};
|
||||
|
||||
@@ -300,6 +303,7 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
if (generation.pool) |pool| {
|
||||
sample.upstreams = try upstreams(pool, io, arena);
|
||||
sample.upstream_budget_exhausted_total = pool.budgetExhaustedTotal();
|
||||
sample.upstream_local_resource_total = pool.localResourceTotal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +516,15 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
);
|
||||
try w.print("nxdns_upstream_budget_exhausted_total {d}\n", .{total});
|
||||
}
|
||||
if (sample.upstream_local_resource_total) |total| {
|
||||
try labeledHead(
|
||||
w,
|
||||
"nxdns_upstream_local_resource_total",
|
||||
"Exchanges this process ended on its own resources (memory, descriptors, an unnamed system error) before any upstream answered.",
|
||||
"counter",
|
||||
);
|
||||
try w.print("nxdns_upstream_local_resource_total {d}\n", .{total});
|
||||
}
|
||||
}
|
||||
|
||||
fn renderPeaks(w: *std.Io.Writer, peaks: PeakSample) std.Io.Writer.Error!void {
|
||||
@@ -1788,3 +1801,20 @@ test "the budget-exhausted counter renders pool-wide, without a url label" {
|
||||
defer testing.allocator.free(absent);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_budget_exhausted_total"));
|
||||
}
|
||||
|
||||
test "the local-resource counter renders pool-wide, without a url label" {
|
||||
const text = try renderToString(testing.allocator, .{ .upstream_local_resource_total = 4 });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"# TYPE nxdns_upstream_local_resource_total counter\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_local_resource_total 4\n"));
|
||||
|
||||
const absent = try renderToString(testing.allocator, .{});
|
||||
defer testing.allocator.free(absent);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_local_resource_total"));
|
||||
}
|
||||
|
||||
@@ -447,6 +447,45 @@ fn rewriteZonVersion(arena: Allocator, source: []const u8, version: []const u8)
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/// The compose file a user fetches from a release tag. Its image default names
|
||||
/// that release, so `docker compose up -d` from the tag runs the tag's image and
|
||||
/// `NXDNS_VERSION` stays an override, never a requirement.
|
||||
const compose_path = "deploy/docker/compose.yaml";
|
||||
|
||||
const compose_image_prefix = "image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-";
|
||||
|
||||
/// The byte range of `<version>` in the image line
|
||||
/// `image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<version>}}`.
|
||||
/// Only a line that starts with that text, after its indentation, is the image:
|
||||
/// the same text in a comment or after another key pins nothing. The file must
|
||||
/// hold exactly one such line, or the rewrite would have to guess.
|
||||
fn findComposeVersion(source: []const u8) error{ NoComposeVersion, ManyComposeVersions }!VersionSpan {
|
||||
var found: ?VersionSpan = null;
|
||||
var offset: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, source, '\n');
|
||||
while (lines.next()) |line| {
|
||||
const line_start = offset;
|
||||
offset += line.len + 1;
|
||||
|
||||
const indent = line.len - std.mem.trimStart(u8, line, " ").len;
|
||||
if (!std.mem.startsWith(u8, line[indent..], compose_image_prefix)) continue;
|
||||
if (found != null) return error.ManyComposeVersions;
|
||||
|
||||
const start = indent + compose_image_prefix.len;
|
||||
const rest = std.mem.trimEnd(u8, line[start..], " \r");
|
||||
if (rest.len <= "}}".len or !std.mem.endsWith(u8, rest, "}}")) return error.NoComposeVersion;
|
||||
const version = rest[0 .. rest.len - "}}".len];
|
||||
if (std.mem.indexOfAny(u8, version, "{}$ ") != null) return error.NoComposeVersion;
|
||||
found = .{ .start = line_start + start, .end = line_start + start + version.len };
|
||||
}
|
||||
return found orelse error.NoComposeVersion;
|
||||
}
|
||||
|
||||
fn rewriteComposeVersion(arena: Allocator, source: []const u8, version: []const u8) ![]u8 {
|
||||
const at = try findComposeVersion(source);
|
||||
return std.mem.concat(arena, u8, &.{ source[0..at.start], version, source[at.end..] });
|
||||
}
|
||||
|
||||
/// The primary certificate fingerprint of a `git verify-tag --raw` VALIDSIG
|
||||
/// line: its LAST field. The line is
|
||||
///
|
||||
@@ -2243,9 +2282,9 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
|
||||
const token = checked.authorization orelse return CheckFailed;
|
||||
|
||||
// The one commit this cut makes is assembled in three moves, in this order
|
||||
// and no other: the manifest declares the version, the pin stage builds that
|
||||
// version and writes its hashes into `flake.nix`, and the commit takes both
|
||||
// files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
|
||||
// and no other: the manifest and the compose file declare the version, the
|
||||
// pin stage builds that version and writes its hashes into `flake.nix`, and
|
||||
// the commit takes all three files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
|
||||
// with the manifest, so the pins cannot be computed before the bump; and the
|
||||
// pins belong to the commit, so the commit cannot be made before the pins.
|
||||
// Both of these run before the manifest is rewritten: they can refuse, and
|
||||
@@ -2258,7 +2297,11 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
|
||||
};
|
||||
defer environ.deinit();
|
||||
|
||||
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
|
||||
if (bump_needed) {
|
||||
const compose = try composeWithVersion(ctx, version);
|
||||
try writeZonVersion(ctx, version, zon_source);
|
||||
try writeComposeVersion(ctx, version, compose);
|
||||
}
|
||||
try pinStage(ctx, version, &environ, zig_exe);
|
||||
try commitBump(ctx, version, bump_needed);
|
||||
|
||||
@@ -2942,11 +2985,50 @@ fn verifyOriginTag(ctx: *Ctx, tag: []const u8, tag_ref: []const u8, commit: []co
|
||||
ctx.pass("origin-tag", "{s} ({s}) on origin is this tool's own signed tag at {s}", .{ tag, object, commit });
|
||||
}
|
||||
|
||||
/// Rewrites `build.zig.zon` and commits it, and nothing else.
|
||||
/// The two files the bump commit may touch, and the only two. `flake.nix` joins
|
||||
/// The files the bump commit may touch, and the only ones. `flake.nix` joins
|
||||
/// `build.zig.zon` because the pin stage writes the release hashes into it
|
||||
/// between the manifest write and the commit.
|
||||
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix" };
|
||||
/// between the manifest write and the commit; the compose file joins because
|
||||
/// its image default is the release a user of the tag gets.
|
||||
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix", compose_path };
|
||||
|
||||
/// Replaces `path` with `bytes` atomically: a cut interrupted mid-write must not
|
||||
/// leave a half-written file that neither zig, compose nor a re-run can parse.
|
||||
fn replaceFile(ctx: *Ctx, path: []const u8, bytes: []const u8) !void {
|
||||
var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, path, .{ .replace = true }) catch |err| {
|
||||
ctx.soft("bump", "cannot open {s} for an atomic replace: {t}", .{ path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
defer atomic.deinit(ctx.io);
|
||||
atomic.file.writeStreamingAll(ctx.io, bytes) catch |err| {
|
||||
ctx.soft("bump", "cannot write {s}: {t}", .{ path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
atomic.file.sync(ctx.io) catch |err| {
|
||||
ctx.soft("bump", "cannot flush {s} to disk: {t}", .{ path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
atomic.replace(ctx.io) catch |err| {
|
||||
ctx.soft("bump", "cannot replace {s}: {t}", .{ path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
}
|
||||
|
||||
/// Read back from disk rather than trusting the buffer: this is the file the
|
||||
/// rest of the release reads, and a rewrite that produced something its grammar
|
||||
/// rejects must be found now.
|
||||
fn readBack(ctx: *Ctx, path: []const u8) ![:0]u8 {
|
||||
return Io.Dir.cwd().readFileAllocOptions(
|
||||
ctx.io,
|
||||
path,
|
||||
ctx.arena,
|
||||
.limited(max_input_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |err| {
|
||||
ctx.soft("bump", "cannot read {s} back: {t}", .{ path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
}
|
||||
|
||||
/// Writes the new version into `build.zig.zon` and proves the file still parses.
|
||||
///
|
||||
@@ -2959,41 +3041,9 @@ fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
|
||||
ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
try replaceFile(ctx, "build.zig.zon", rewritten);
|
||||
|
||||
// Atomic: a cut interrupted mid-write must not leave a half-written
|
||||
// manifest that neither zig nor a re-run can parse.
|
||||
var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, "build.zig.zon", .{ .replace = true }) catch |err| {
|
||||
ctx.soft("bump", "cannot open build.zig.zon for an atomic replace: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
defer atomic.deinit(ctx.io);
|
||||
atomic.file.writeStreamingAll(ctx.io, rewritten) catch |err| {
|
||||
ctx.soft("bump", "cannot write build.zig.zon: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
atomic.file.sync(ctx.io) catch |err| {
|
||||
ctx.soft("bump", "cannot flush build.zig.zon to disk: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
atomic.replace(ctx.io) catch |err| {
|
||||
ctx.soft("bump", "cannot replace build.zig.zon: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
|
||||
// Read back from disk rather than trusting the buffer: this is the file the
|
||||
// rest of the release reads, and a rewrite that produced something the zon
|
||||
// grammar rejects must be found now.
|
||||
const written = Io.Dir.cwd().readFileAllocOptions(
|
||||
ctx.io,
|
||||
"build.zig.zon",
|
||||
ctx.arena,
|
||||
.limited(max_input_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |err| {
|
||||
ctx.soft("bump", "cannot read build.zig.zon back: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
const written = try readBack(ctx, "build.zig.zon");
|
||||
const reparsed = parseZonVersion(ctx.arena, written) catch |err| {
|
||||
ctx.soft("bump", "the rewritten build.zig.zon does not parse: {t}", .{err});
|
||||
return CheckFailed;
|
||||
@@ -3005,8 +3055,39 @@ fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
|
||||
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
|
||||
}
|
||||
|
||||
/// Commits the manifest bump and the pins as one commit, after asserting the
|
||||
/// working tree holds those two files and nothing else.
|
||||
/// The compose file with its image default moved to `version`, computed before
|
||||
/// anything is written: a compose file the rewrite cannot read is a refusal
|
||||
/// while the tree is still clean.
|
||||
fn composeWithVersion(ctx: *Ctx, version: []const u8) ![]u8 {
|
||||
const source = Io.Dir.cwd().readFileAlloc(ctx.io, compose_path, ctx.arena, .limited(max_input_bytes)) catch |err| {
|
||||
ctx.soft("bump", "cannot read {s}: {t}", .{ compose_path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
return rewriteComposeVersion(ctx.arena, source, version) catch |err| {
|
||||
ctx.soft("bump", "cannot rewrite the image default of {s}: {t}", .{ compose_path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
}
|
||||
|
||||
fn writeComposeVersion(ctx: *Ctx, version: []const u8, rewritten: []const u8) !void {
|
||||
try replaceFile(ctx, compose_path, rewritten);
|
||||
|
||||
const written = try readBack(ctx, compose_path);
|
||||
const at = findComposeVersion(written) catch |err| {
|
||||
ctx.soft("bump", "the rewritten {s} has no single image default: {t}", .{ compose_path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
if (!std.mem.eql(u8, written[at.start..at.end], version)) {
|
||||
ctx.soft("bump", "the rewritten {s} defaults to '{s}', expected '{s}'", .{
|
||||
compose_path, written[at.start..at.end], version,
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
ctx.pass("bump", "{s} defaults to {s}", .{ compose_path, version });
|
||||
}
|
||||
|
||||
/// Commits the manifest bump, the compose default and the pins as one commit,
|
||||
/// after asserting the working tree holds those files and nothing else.
|
||||
///
|
||||
/// `bump_needed` is false on a resumed cut: the commit already exists, so the
|
||||
/// pin stage has just recomputed pins that are already in it. An empty diff is
|
||||
@@ -3036,8 +3117,8 @@ fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
|
||||
for (bump_paths) |allowed| {
|
||||
if (std.mem.eql(u8, path, allowed)) break;
|
||||
} else {
|
||||
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s} and {s} and nothing else", .{
|
||||
path, bump_paths[0], bump_paths[1],
|
||||
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s}, {s} and {s} and nothing else", .{
|
||||
path, bump_paths[0], bump_paths[1], bump_paths[2],
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
@@ -3067,9 +3148,10 @@ fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
|
||||
"-S", "-m",
|
||||
ctx.fmt("build: bump version to {s}", .{version}), "--",
|
||||
bump_paths[0], bump_paths[1],
|
||||
bump_paths[2],
|
||||
});
|
||||
ctx.pass("bump", "one commit: {s} declares {s} and {s} pins its release hashes", .{
|
||||
bump_paths[0], version, bump_paths[1],
|
||||
ctx.pass("bump", "one commit: {s} declares {s}, {s} defaults to it and {s} pins its release hashes", .{
|
||||
bump_paths[0], version, bump_paths[2], bump_paths[1],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3698,6 +3780,56 @@ test "the zon version survives a rewrite round trip" {
|
||||
try testing.expectError(error.NoVersionField, findVersionValue("// .version = \"9.9.9\""));
|
||||
}
|
||||
|
||||
test "the compose image default survives a rewrite round trip" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const source =
|
||||
\\services:
|
||||
\\ nxdns:
|
||||
\\ # NXDNS_VERSION=0.0.1 docker compose up -d runs another release.
|
||||
\\ image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.7}}
|
||||
\\ restart: unless-stopped
|
||||
;
|
||||
const at = try findComposeVersion(source);
|
||||
try testing.expectEqualStrings("0.0.7", source[at.start..at.end]);
|
||||
|
||||
const rewritten = try rewriteComposeVersion(arena, source, "0.0.8");
|
||||
const moved = try findComposeVersion(rewritten);
|
||||
try testing.expectEqualStrings("0.0.8", rewritten[moved.start..moved.end]);
|
||||
// Only the default moved: the comment naming another version and the
|
||||
// outer `NXDNS_IMAGE` default are untouched.
|
||||
try testing.expect(std.mem.indexOf(u8, rewritten, "# NXDNS_VERSION=0.0.1 docker") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, rewritten, "${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:") != null);
|
||||
try testing.expectEqual(source.len, rewritten.len);
|
||||
|
||||
const image = " image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-";
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(" image: nxdns:latest"));
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "}}"));
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "0.0.7}"));
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(image ++ "${OTHER}}}"));
|
||||
// The text in a comment, or after any other key, is not the image.
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(" # " ++ image[4..] ++ "0.0.7}}"));
|
||||
try testing.expectError(error.NoComposeVersion, findComposeVersion(" x-image: ${NXDNS_VERSION:-0.0.7}"));
|
||||
try testing.expectError(error.ManyComposeVersions, findComposeVersion(image ++ "0.0.7}}\n" ++ image ++ "0.0.7}}"));
|
||||
}
|
||||
|
||||
test "this repository's compose file defaults to the version build.zig.zon declares" {
|
||||
// The bump commit moves both together; this catches a hand edit of either
|
||||
// between two cuts, on every CI run rather than at the next release.
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const zon = try Io.Dir.cwd().readFileAllocOptions(threaded.io(), "build.zig.zon", arena, .limited(max_input_bytes), .of(u8), 0);
|
||||
const compose = try Io.Dir.cwd().readFileAlloc(threaded.io(), compose_path, arena, .limited(max_input_bytes));
|
||||
const at = try findComposeVersion(compose);
|
||||
try testing.expectEqualStrings(try parseZonVersion(arena, zon), compose[at.start..at.end]);
|
||||
}
|
||||
|
||||
test "the changelog section must exist, be dated and say something" {
|
||||
const good =
|
||||
\\# Changelog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# nxdns.sh — marketing and docs website plan
|
||||
|
||||
Agreed in an interview on 2026-08-15, revised on 2026-10-02 and 2026-10-19 (screenshots in, section order set, the personal story moved to the owner's blog). This directory will hold the site's source.
|
||||
Agreed in an interview on 2026-08-15, revised on 2026-10-02, 2026-10-19 (screenshots in, section order set, the personal story moved to the owner's blog) and 2026-10-20 (install tabs, phone and light/dark screenshots). This directory will hold the site's source.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -21,8 +21,8 @@ Convince, then teach. A marketing front page sells the project; the docs sit one
|
||||
|
||||
Four sections, in this order:
|
||||
|
||||
1. Hero: the README pitch — "A DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection." — and a copyable install command in two tabs: Docker compose first, the signed tarball second. The registry allows anonymous pulls (verified 2026-10-02 against v0.0.21), so the Docker tab works without an account.
|
||||
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity (with a query's provenance), Clients, Diagnostics.
|
||||
1. Hero: the README pitch — "A DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection." — with the README's flow diagram animated. The install commands sit in their own section near the end, in three tabs: docker, tarball, nix. Each tab is a complete path or names the guide that finishes it; the docker tab writes the smallest config that starts, so nobody faces an empty file. Commands use no shell variables and plain `curl -O`. The registry allows anonymous pulls (verified 2026-10-02 against v0.0.21), so the Docker tab works without an account. The site goes live only with a release that contains aa8c9e2 (the compose file pins its own version, so the tab ends in a plain `docker compose up -d`) and f351469 (the `services.nxdns` NixOS module the nix tab uses): 0.0.23 or later.
|
||||
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity, Query detail, Clients, Diagnostics. Each comes in the admin UI's light and dark theme, behind a light/dark switch with light first. Phones get their own set, captured at a 390px viewport where the admin UI switches to its drawer layout, served through `<picture>`, so a phone shows readable screens instead of a shrunken desktop. All twenty images come from one session on the release the site launches with; the current set is 0.0.21 desktop-only.
|
||||
3. Feature grid: filtering, config-as-code, DoH/DoT, per-client groups, metrics, query log.
|
||||
4. Comparison table vs Pi-hole, AdGuard Home, Technitium — only to help people choose. Seven rows: how it ships; config as a file in git that the tool converges onto; encrypted upstreams built in; serves DoH/DoT to clients; signed, reproducible releases; DHCP server; DNSSEC validation. The last two are rows nxdns loses, kept because a table that only lists wins does not help anyone choose. Competitor cells were checked against Pi-hole 6.5 (FTL 6.8), AdGuard Home 0.107.79 and Technitium 15.6.2 on 2026-10-19.
|
||||
|
||||
@@ -36,10 +36,16 @@ The pitch leads with what nxdns does, not with attacks on the incumbents. The pa
|
||||
|
||||
## Look
|
||||
|
||||
- Clean technical. Light default with a dark toggle.
|
||||
- Orange/amber accent.
|
||||
- Brand is the lowercase `nxdns` wordmark in a distinctive typeface. No drawn logo.
|
||||
- Copy tone: the honest, direct voice the docs already use, about the tool. The personal story is the blog's.
|
||||
Approved on 2026-10-20 from a local mock (concept 2), after one rejected direction that copied Wispr Flow's landing page too closely. The mock is the reference for the build.
|
||||
|
||||
- Light only. No dark mode, no toggle. Warm paper, ink and amber: paper `#f5f2ea`, card `#fbf9f4`, ink `#1c1b18`, muted `#5f5b52`, amber `#f2a93b`, amber text `#a14d0a`. Every text pair passes 4.5:1. Two sections (screenshots, install) sit on rounded ink slabs.
|
||||
- Type: Fraunces for headings and the wordmark, Figtree for body text, JetBrains Mono for labels and code. All three self-hosted and subset; no request leaves for Google Fonts.
|
||||
- Brand is the lowercase `nxdns` wordmark in Fraunces with an amber `x`. No drawn logo.
|
||||
- Section labels are dig-style comments: `;; ANSWER SECTION`, `;; VERIFY`, `;; FEATURES`. The footer ends with a zone-file line: `nxdns.sh. 3600 IN TXT "…"`.
|
||||
- The hero is the README's flow diagram, animated: one query at a time travels from the devices to nxdns, a tag shows the decision, and the packet goes on to the upstream or the sinkhole and back. Slow enough to read. A still frame for `prefers-reduced-motion`.
|
||||
- No illustrations, unless an image model makes them.
|
||||
- Design fundamentals from the emil-design-engineering rules, not from any one site's look: no layout shift (tab panels share one grid cell), 44px tap targets, hover only under `(hover: hover)`, copy buttons on code, grey/black/white focus rings.
|
||||
- Copy tone: the honest, direct voice the docs already use, about the tool. The personal story is the blog's. No personal email address on the site.
|
||||
|
||||
## Links
|
||||
|
||||
@@ -47,5 +53,4 @@ Source links point to `git.mial.net/mokhtar/nxdns` only. No GitHub mirror.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Wordmark typeface — resolve in the design pass.
|
||||
2. Re-check the comparison cells against the then-current competitor versions before publication. AdGuard Home had a 1.0 beta on 2026-09-30, so its cells are the most likely to move.
|
||||
1. Re-check the comparison cells against the then-current competitor versions before publication. AdGuard Home had a 1.0 beta on 2026-09-30, so its cells are the most likely to move.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
shots/
|
||||
screenshots.html
|
||||
.cookies
|
||||
@@ -0,0 +1,81 @@
|
||||
# Screenshot demo
|
||||
|
||||
A synthetic household that produces the admin screenshots in `../src/assets/screenshots/`. Six devices on a docker LAN send real DNS traffic through the published nxdns image, real public blocklists decide what is blocked, and the bulk of the traffic is then spread over the previous 24 hours so the Overview shows a believable day. Nothing here is real household data, and no device carries a person's name.
|
||||
|
||||
| Device | Address | Group |
|
||||
| --- | --- | --- |
|
||||
| nxdns | 192.168.50.2 | — |
|
||||
| Living room TV | 192.168.50.41 | default |
|
||||
| iPhone | 192.168.50.52 | default |
|
||||
| Pixel | 192.168.50.53 | default |
|
||||
| Kids' phone | 192.168.50.54 | kids (safe search, gambling and NSFW lists) |
|
||||
| Desktop PC | 192.168.50.20 | default |
|
||||
| MacBook Air | 192.168.50.21 | default |
|
||||
|
||||
The LAN is 192.168.50.0/24 because the machine that first ran this uses 192.168.1.0/24 for its real network. The admin UI is on <http://127.0.0.1:18080>, password `nxdns-demo`. Upstreams are Cloudflare DoH with Quad9 DoT as failover; Quad9's DoH endpoint requires HTTP/2, which nxdns does not speak.
|
||||
|
||||
Run every command from this directory. It needs docker, Node.js and Python 3.
|
||||
|
||||
## Generate the household
|
||||
|
||||
`NXDNS_IMAGE` picks the image; it defaults to the version the committed screenshots show. Set it to the release the new screenshots are for.
|
||||
|
||||
```sh
|
||||
NXDNS_IMAGE=git.mial.net/mokhtar/nxdns:<version> ./start.sh
|
||||
```
|
||||
|
||||
Wait until `/api/blocklists` reports all four lists loaded. Then shrink the cache for the compressed bulk run, so the cache-hit share stays near what a real day produces, and send the bulk traffic:
|
||||
|
||||
```sh
|
||||
curl -s -c .cookies -H 'content-type: application/json' -d '{"password":"nxdns-demo"}' http://127.0.0.1:18080/api/auth/login
|
||||
curl -s -b .cookies -X PUT -H 'content-type: application/json' -d '{"cache":{"size":48}}' http://127.0.0.1:18080/api/settings
|
||||
./devices.sh bulk
|
||||
```
|
||||
|
||||
The bulk run sends about 18,000 queries. Wait until all six device containers have exited, then restore the cache and rewrite the history while nxdns is stopped. `check_coherence.py` runs the repository's projection oracle and must print no `FAIL`:
|
||||
|
||||
```sh
|
||||
curl -s -b .cookies -X PUT -H 'content-type: application/json' -d '{"cache":{"size":10000}}' http://127.0.0.1:18080/api/settings
|
||||
docker stop nxdemo-nxdns
|
||||
./db.sh /gen/rewrite_history.py /data
|
||||
./db.sh /gen/check_coherence.py /data
|
||||
docker start nxdemo-nxdns
|
||||
./devices.sh live
|
||||
```
|
||||
|
||||
`gen/check_profiles.py` prints each device profile's expected blocked share through `/api/lookup`. Use it when you change `gen/profiles.py`.
|
||||
|
||||
## Produce a Diagnostics episode
|
||||
|
||||
Cut the primary DoH upstream off inside nxdns's network namespace, wait about two minutes, and restore it. nxdns opens an "Upstream failing" episode and closes it on its own after the routes come back. Check that the addresses are still the ones `cloudflare-dns.com` resolves to.
|
||||
|
||||
```sh
|
||||
docker run --rm --net container:nxdemo-nxdns --cap-add NET_ADMIN python:3-alpine ip route add blackhole 104.16.248.249/32
|
||||
docker run --rm --net container:nxdemo-nxdns --cap-add NET_ADMIN python:3-alpine ip route add blackhole 104.16.249.249/32
|
||||
docker run --rm --net container:nxdemo-nxdns --cap-add NET_ADMIN python:3-alpine ip route del blackhole 104.16.248.249/32
|
||||
docker run --rm --net container:nxdemo-nxdns --cap-add NET_ADMIN python:3-alpine ip route del blackhole 104.16.249.249/32
|
||||
```
|
||||
|
||||
## Take the screenshots
|
||||
|
||||
The first time, install the pinned Playwright and its browser:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
Then shoot all five pages in light and dark at 1440×900, 2× scale, and build a contact sheet to review them:
|
||||
|
||||
```sh
|
||||
node gen/shoot.mjs shots "$(./db.sh /gen/blocked_query_id.py /data)"
|
||||
python3 -I gen/contact_sheet.py shots screenshots.html
|
||||
```
|
||||
|
||||
Review the sheet before anything replaces the files in `../src/assets/screenshots/`.
|
||||
|
||||
## Tear down
|
||||
|
||||
```sh
|
||||
./teardown.sh
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# usage: db.sh /gen/<script>.py /data [args]
|
||||
# Runs a gen/ script against the nxdemo-data volume as nxdns's uid. TZ is Europe/Paris written as a
|
||||
# POSIX rule, because alpine ships no zoneinfo; rewrite_history.py shapes the day in local time.
|
||||
set -eu
|
||||
DEMO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec docker run --rm -i --user 65532:65532 -e TZ=CET-1CEST,M3.5.0,M10.5.0/3 \
|
||||
-v nxdemo-data:/data -v "$DEMO/gen:/gen:ro" python:3-alpine python -I "$@"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# usage: devices.sh bulk one container per device sends its lookup budget and exits
|
||||
# devices.sh live one container per device trickles traffic forever (restart unless-stopped)
|
||||
set -eu
|
||||
DEMO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
MODE=$1
|
||||
for spec in tv:192.168.50.41:20 iphone:192.168.50.52:25 pixel:192.168.50.53:30 kids:192.168.50.54:35 desktop:192.168.50.20:45 mac:192.168.50.21:40; do
|
||||
name=${spec%%:*}; rest=${spec#*:}; ip=${rest%%:*}; mean=${rest#*:}
|
||||
docker rm -f "nxdemo-$name" >/dev/null 2>&1 || true
|
||||
if [ "$MODE" = bulk ]; then
|
||||
docker run -d --name "nxdemo-$name" --network nxdemo-lan --ip "$ip" -v "$DEMO/gen:/gen:ro" \
|
||||
python:3-alpine python -I /gen/traffic.py "$name" bulk
|
||||
else
|
||||
docker run -d --name "nxdemo-$name" --restart unless-stopped --network nxdemo-lan --ip "$ip" -v "$DEMO/gen:/gen:ro" \
|
||||
python:3-alpine python -I /gen/traffic.py "$name" live "$mean"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,52 @@
|
||||
.{
|
||||
.dns = .{ .rate_limit = 100000, .rate_window_seconds = 60 },
|
||||
.web = .{ .port = 8080, .password = "nxdns-demo", .api_rate_limit_per_min = 5000 },
|
||||
.logging = .{ .query_log_flush_interval_s = 5 },
|
||||
|
||||
.groups = .{
|
||||
.{ .name = "default" },
|
||||
.{ .name = "kids", .safe_search = true },
|
||||
},
|
||||
|
||||
.upstreams = .{
|
||||
.{ .url = "https://cloudflare-dns.com/dns-query", .priority = 10 },
|
||||
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
|
||||
},
|
||||
|
||||
.clients = .{
|
||||
.{ .ip = "192.168.50.41", .name = "Living room TV" },
|
||||
.{ .ip = "192.168.50.52", .name = "iPhone" },
|
||||
.{ .ip = "192.168.50.53", .name = "Pixel" },
|
||||
.{ .ip = "192.168.50.54", .name = "Kids' phone", .group = "kids" },
|
||||
.{ .ip = "192.168.50.20", .name = "Desktop PC" },
|
||||
.{ .ip = "192.168.50.21", .name = "MacBook Air" },
|
||||
},
|
||||
|
||||
.blocklist_sources = .{
|
||||
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack unified hosts" },
|
||||
.{ .url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt", .name = "HaGeZi Multi PRO" },
|
||||
.{ .url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/gambling.txt", .name = "HaGeZi Gambling" },
|
||||
.{ .url = "https://nsfw-small.oisd.nl/domainswild2", .name = "OISD NSFW small" },
|
||||
},
|
||||
.group_sources = .{
|
||||
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
|
||||
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt" },
|
||||
.{ .group = "kids", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
|
||||
.{ .group = "kids", .source_url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt" },
|
||||
.{ .group = "kids", .source_url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/gambling.txt" },
|
||||
.{ .group = "kids", .source_url = "https://nsfw-small.oisd.nl/domainswild2" },
|
||||
},
|
||||
|
||||
.rules = .{
|
||||
.{ .group = "default", .pattern = "s.youtube.com", .kind = .exact, .action = .allow },
|
||||
.{ .group = "default", .pattern = "*.samsungacr.com", .kind = .wildcard, .action = .block },
|
||||
.{ .group = "kids", .pattern = "s.youtube.com", .kind = .exact, .action = .allow },
|
||||
.{ .group = "kids", .pattern = "*.tiktokv.com", .kind = .wildcard, .action = .block },
|
||||
},
|
||||
|
||||
.local_records = .{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.50.5" },
|
||||
.{ .name = "printer.lan", .rtype = .a, .value = "192.168.50.8" },
|
||||
.{ .name = "files.lan", .rtype = .cname, .value = "nas.lan" },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Print the id of the newest blocked app-measurement.com lookup from the iPhone: the query the
|
||||
detail screenshot shows. usage: blocked_query_id.py DATA_DIR"""
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
row = sqlite3.connect(f"file:{sys.argv[1]}/querylog.db?mode=ro", uri=True).execute(
|
||||
"SELECT q.id FROM query_log q JOIN domains d ON d.id = q.domain_id"
|
||||
" WHERE q.client_ip = '192.168.50.52' AND q.policy_reason = 'blocklist_domain'"
|
||||
" AND d.domain = 'app-measurement.com' ORDER BY q.id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
sys.exit("no blocked app-measurement.com lookup from 192.168.50.52 in the log yet")
|
||||
print(row[0])
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The repository's projection-coherence oracle (`recompute_checks` and
|
||||
`expectProjectionsMatchRecompute` in src/storage/repositories/queries_repo.zig),
|
||||
plus file-level invariants. Exits 1 on any violation. usage: check_coherence.py DATA_DIR"""
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
DATA = sys.argv[1]
|
||||
BUCKET = "(CASE WHEN timestamp >= 0 THEN timestamp / 1800 * 1800 ELSE (timestamp - 1799) / 1800 * 1800 END)"
|
||||
CHECKS = [
|
||||
("SELECT bucket, queries, blocked, cached, rt_sum, rt_count FROM bucket_totals",
|
||||
f"SELECT {BUCKET}, count(*), coalesce(sum(blocked <> 0), 0), coalesce(sum(cache_hit = 1), 0),"
|
||||
" coalesce(sum(response_time_us), 0), count(response_time_us) FROM query_log GROUP BY 1"),
|
||||
("SELECT bucket, client_ip, queries FROM bucket_clients",
|
||||
f"SELECT {BUCKET}, client_ip, count(*) FROM query_log GROUP BY 1, 2"),
|
||||
("SELECT bucket, qtype, count FROM bucket_types",
|
||||
f"SELECT {BUCKET}, coalesce(qtype, -1), count(*) FROM query_log GROUP BY 1, 2"),
|
||||
("SELECT bucket, route_kind, source_present, source_text, count FROM bucket_routes",
|
||||
f"SELECT {BUCKET}, route_kind, source IS NOT NULL, coalesce(source, ''), count(*)"
|
||||
" FROM (SELECT timestamp, route_kind, CASE route_kind WHEN 'upstream' THEN upstream"
|
||||
" WHEN 'forward_zone' THEN forward_zone END AS source FROM query_log) GROUP BY 1, 2, 3, 4"),
|
||||
]
|
||||
|
||||
ql = sqlite3.connect(f"file:{DATA}/querylog.db?mode=ro", uri=True)
|
||||
failures = 0
|
||||
|
||||
|
||||
def expect(label, ok, detail=""):
|
||||
global failures
|
||||
print(("ok " if ok else "FAIL ") + label + (f" ({detail})" if detail else ""))
|
||||
failures += not ok
|
||||
|
||||
|
||||
for projection, recompute in CHECKS:
|
||||
diff = ql.execute(
|
||||
f"SELECT (SELECT count(*) FROM ({projection} EXCEPT {recompute}))"
|
||||
f" + (SELECT count(*) FROM ({recompute} EXCEPT {projection}))"
|
||||
).fetchone()[0]
|
||||
expect(f"projection == recompute: {projection.split('FROM ')[1]}", diff == 0, f"{diff} differing rows")
|
||||
|
||||
integrity = ql.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
expect("querylog integrity_check", integrity == "ok", integrity)
|
||||
expect("querylog user_version == 1", ql.execute("PRAGMA user_version").fetchone()[0] == 1)
|
||||
orphans = ql.execute("SELECT count(*) FROM query_log WHERE domain_id NOT IN (SELECT id FROM domains)").fetchone()[0]
|
||||
expect("every query_log.domain_id names a domain", orphans == 0, f"{orphans} orphans")
|
||||
created, since, first = ql.execute(
|
||||
"SELECT created_at, available_since, (SELECT min(timestamp) FROM query_log) FROM querylog_meta").fetchone()
|
||||
expect("available_since = created_at + 1 <= first row", since == created + 1 and (first is None or since <= first),
|
||||
f"created_at={created} available_since={since} first_row={first}")
|
||||
disorder = ql.execute(
|
||||
"SELECT count(*) FROM query_log a JOIN query_log b ON b.id = a.id + 1 WHERE b.timestamp < a.timestamp").fetchone()[0]
|
||||
expect("query_log ids ascend with timestamps", disorder == 0, f"{disorder} inversions")
|
||||
|
||||
cfg = sqlite3.connect(f"file:{DATA}/config.db?mode=ro", uri=True)
|
||||
cfg.execute(f"ATTACH 'file:{DATA}/querylog.db?mode=ro' AS ql")
|
||||
bad = cfg.execute("""SELECT count(*) FROM clients c WHERE EXISTS (SELECT 1 FROM ql.query_log q WHERE q.client_ip = c.ip)
|
||||
AND (c.first_seen > (SELECT min(timestamp) FROM ql.query_log WHERE client_ip = c.ip)
|
||||
OR c.last_seen < (SELECT max(timestamp) FROM ql.query_log WHERE client_ip = c.ip))""").fetchone()[0]
|
||||
expect("clients first_seen/last_seen bracket their logged rows", bad == 0, f"{bad} clients out of range")
|
||||
unknown = cfg.execute("SELECT count(DISTINCT client_ip) FROM ql.query_log WHERE client_ip NOT IN (SELECT ip FROM clients)").fetchone()[0]
|
||||
expect("every logged client is a named client row", unknown == 0, f"{unknown} unknown")
|
||||
expect("config integrity_check", cfg.execute("PRAGMA integrity_check").fetchone()[0] == "ok")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Print each profile's expected blocked share (by query weight) via /api/lookup,
|
||||
and every domain that does not resolve on the host."""
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from profiles import PROFILES # noqa: E402
|
||||
|
||||
COOKIE = open(sys.argv[2]).read().split("nxdns_session")[1].split()[0]
|
||||
BASE = "http://127.0.0.1:18080"
|
||||
GROUP = {"kids": 2}
|
||||
|
||||
|
||||
def lookup(domain, group_id):
|
||||
req = urllib.request.Request(f"{BASE}/api/lookup?domain={domain}&group_id={group_id}")
|
||||
req.add_header("Cookie", f"nxdns_session={COOKIE}")
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
|
||||
cache = {}
|
||||
for name, prof in PROFILES.items():
|
||||
gid = GROUP.get(name, 1)
|
||||
total = blocked = 0
|
||||
for domain, weight in prof["domains"]:
|
||||
res = lookup(domain, gid)
|
||||
total += weight
|
||||
if sys.argv[3:] == ["-v"]:
|
||||
print(name, domain, json.dumps(res)[:200])
|
||||
is_blocked = res.get("blocked") or (res.get("decision") or {}).get("action") == "block"
|
||||
if is_blocked:
|
||||
blocked += weight
|
||||
elif domain not in cache and not domain.endswith(".lan"):
|
||||
try:
|
||||
socket.getaddrinfo(domain, None)
|
||||
cache[domain] = True
|
||||
except OSError:
|
||||
cache[domain] = False
|
||||
print("UNRESOLVED", name, domain)
|
||||
print(f"{name}: blocked weight share {blocked / total:.1%}")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""usage: contact_sheet.py SHOTS_DIR OUT_HTML — light and dark side by side, images inlined."""
|
||||
import base64
|
||||
import html
|
||||
import sys
|
||||
|
||||
SHOTS, OUT = sys.argv[1], sys.argv[2]
|
||||
PAGES = [
|
||||
("overview", "Overview — 24 hours of a six-device household: total and blocked queries, and which device made them."),
|
||||
("activity", "Activity — recent queries, each with its client, result and route."),
|
||||
("query-detail", "Query detail — a blocked app-analytics lookup and the provenance that blocked it."),
|
||||
("clients", "Clients — each device by name and group; the kids' phone runs with safe search and extra lists."),
|
||||
("diagnostics", "Diagnostics — a resolved upstream episode: the primary DoH resolver was unreachable for about two minutes."),
|
||||
]
|
||||
|
||||
|
||||
def img(name, scheme):
|
||||
with open(f"{SHOTS}/{name}-{scheme}.png", "rb") as f:
|
||||
data = base64.b64encode(f.read()).decode()
|
||||
return f'<figure><img alt="{name} {scheme}" src="data:image/png;base64,{data}"><figcaption>{scheme}</figcaption></figure>'
|
||||
|
||||
|
||||
rows = "\n".join(
|
||||
f'<section><h2>{html.escape(caption)}</h2><div class="pair">{img(name, "light")}{img(name, "dark")}</div></section>'
|
||||
for name, caption in PAGES
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>nxdns screenshots</title>
|
||||
<style>
|
||||
:root {{ --bg: #f6f6f7; --fg: #1d1d22; --muted: #66666e; --line: #d9d9de; }}
|
||||
@media (prefers-color-scheme: dark) {{ :root {{ --bg: #141417; --fg: #ececf0; --muted: #9a9aa3; --line: #2c2c33; }} }}
|
||||
body {{ margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }}
|
||||
h1 {{ font-size: 20px; margin: 0 0 24px; }}
|
||||
h2 {{ font-size: 15px; font-weight: 500; margin: 32px 0 12px; }}
|
||||
.pair {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||
@media (max-width: 800px) {{ .pair {{ grid-template-columns: 1fr; }} }}
|
||||
figure {{ margin: 0; }}
|
||||
img {{ width: 100%; height: auto; border: 1px solid var(--line); border-radius: 6px; display: block; }}
|
||||
figcaption {{ color: var(--muted); font-size: 13px; margin-top: 4px; }}
|
||||
</style></head>
|
||||
<body><h1>nxdns admin — marketing screenshots (1440×900 @2x)</h1>
|
||||
{rows}
|
||||
</body></html>
|
||||
"""
|
||||
with open(OUT, "w") as f:
|
||||
f.write(page)
|
||||
print(OUT)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Per-device domain profiles: (domain, weight). Weights are relative lookup
|
||||
frequencies within a device; each lookup emits the device's query-type set."""
|
||||
|
||||
# Query-type sets per lookup, with their probabilities.
|
||||
A, AAAA, HTTPS = 1, 28, 65
|
||||
APPLE_TYPES = [((A, AAAA, HTTPS), 0.75), ((A, AAAA), 0.15), ((A,), 0.10)]
|
||||
ANDROID_TYPES = [((A, AAAA), 0.55), ((A, AAAA, HTTPS), 0.30), ((A,), 0.15)]
|
||||
WINDOWS_TYPES = [((A, AAAA), 0.60), ((A, AAAA, HTTPS), 0.20), ((A,), 0.20)]
|
||||
TV_TYPES = [((A,), 0.70), ((A, AAAA), 0.30)]
|
||||
|
||||
COMMON_WEB = [
|
||||
("www.google.com", 30), ("www.gstatic.com", 18), ("fonts.googleapis.com", 8),
|
||||
("fonts.gstatic.com", 8), ("www.wikipedia.org", 3), ("en.wikipedia.org", 4),
|
||||
("upload.wikimedia.org", 3), ("www.bbc.co.uk", 3), ("www.theguardian.com", 3),
|
||||
("www.reddit.com", 6), ("www.redditstatic.com", 3), ("i.redd.it", 3),
|
||||
("www.amazon.com", 4), ("m.media-amazon.com", 4), ("www.ebay.com", 2),
|
||||
("github.com", 2), ("cdn.jsdelivr.net", 3), ("cdnjs.cloudflare.com", 3),
|
||||
("www.youtube.com", 10), ("i.ytimg.com", 8), ("yt3.ggpht.com", 4),
|
||||
("www.nytimes.com", 2), ("static01.nyt.com", 2), ("www.weather.com", 2),
|
||||
("duckduckgo.com", 2), ("www.bing.com", 2), ("stackoverflow.com", 2),
|
||||
("www.imdb.com", 1), ("www.booking.com", 1), ("www.paypal.com", 1),
|
||||
("ajax.googleapis.com", 3), ("maps.googleapis.com", 3), ("www.google-analytics.com", 6),
|
||||
("www.googletagmanager.com", 6), ("googleads.g.doubleclick.net", 4),
|
||||
("securepubads.g.doubleclick.net", 3), ("pagead2.googlesyndication.com", 3),
|
||||
("connect.facebook.net", 2), ("bat.bing.com", 2), ("static.ads-twitter.com", 1),
|
||||
("c.amazon-adsystem.com", 2), ("cdn.taboola.com", 1), ("widgets.outbrain.com", 1),
|
||||
("sb.scorecardresearch.com", 2), ("js-agent.newrelic.com", 1), ("browser.sentry-cdn.com", 1),
|
||||
]
|
||||
|
||||
PROFILES = {
|
||||
"tv": {
|
||||
"types": TV_TYPES,
|
||||
"domains": [
|
||||
("api-global.netflix.com", 40), ("nrdp.prod.ftl.netflix.com", 18), ("assets.nflxext.com", 14),
|
||||
("ichnaea.netflix.com", 10), ("www.youtube.com", 22), ("youtubei.googleapis.com", 28),
|
||||
("i.ytimg.com", 22), ("redirector.googlevideo.com", 14), ("www.googleapis.com", 8),
|
||||
("disney.api.edge.bamgrid.com", 12), ("bam-sdk-configs.bamgrid.com", 4),
|
||||
("appconfigs.disney-plus.net", 5), ("apresolve.spotify.com", 4), ("spclient.wg.spotify.com", 5),
|
||||
("time.samsungcloudsolution.com", 6), ("otn.samsungcloudsolution.com", 4),
|
||||
("www.samsungotn.net", 2), ("osb-apps.samsungqbe.com", 4), ("gpm.samsungqbe.com", 3),
|
||||
("cdn.samsungcloudsolution.com", 3),
|
||||
("connectivitycheck.gstatic.com", 4),
|
||||
# Trackers and ads a smart TV really emits.
|
||||
("log-config.samsungacr.com", 9), ("log-ingestion-eu.samsungacr.com", 8),
|
||||
("config.samsungads.com", 7), ("samsung-com.112.2o7.net", 5), ("ad.samsungadhub.com", 4),
|
||||
("tracking.samsungads.com", 4), ("googleads.g.doubleclick.net", 6),
|
||||
("pubads.g.doubleclick.net", 5), ("s0.2mdn.net", 3), ("imasdk.googleapis.com", 4),
|
||||
("logs.netflix.com", 4), ("events.samsungads.com", 3),
|
||||
],
|
||||
"lookups": 3900,
|
||||
},
|
||||
"iphone": {
|
||||
"types": APPLE_TYPES,
|
||||
"domains": COMMON_WEB + [
|
||||
("gateway.icloud.com", 14), ("p53-caldav.icloud.com", 4), ("api.apple-cloudkit.com", 8),
|
||||
("gspe1-ssl.ls.apple.com", 6), ("mesu.apple.com", 2), ("init.itunes.apple.com", 4),
|
||||
("bag.itunes.apple.com", 3), ("play.itunes.apple.com", 2), ("captive.apple.com", 6),
|
||||
("weather-data.apple.com", 4), ("configuration.ls.apple.com", 3),
|
||||
("graph.instagram.com", 8), ("i.instagram.com", 12), ("scontent.cdninstagram.com", 10),
|
||||
("static.cdninstagram.com", 4), ("graph.facebook.com", 5), ("g.whatsapp.net", 12),
|
||||
("mmg.whatsapp.net", 6), ("api.spotify.com", 5),
|
||||
("spclient.wg.spotify.com", 6), ("i.scdn.co", 4), ("www.google.com", 6),
|
||||
("maps.apple.com", 2), ("mask.icloud.com", 3),
|
||||
# App analytics and ad SDKs.
|
||||
("app-measurement.com", 10), ("firebaselogging-pa.googleapis.com", 6),
|
||||
("app-analytics-services.com", 4), ("api.mixpanel.com", 3), ("api2.amplitude.com", 3),
|
||||
("graph.facebook.com", 2), ("ads.tiktok.com", 2), ("cdn.branch.io", 2), ("api2.branch.io", 2),
|
||||
("app.adjust.com", 3), ("in.appcenter.ms", 2), ("events.appsflyer.com", 3),
|
||||
("sessions.bugsnag.com", 2), ("iadsdk.apple.com", 3), ("metrics.icloud.com", 3),
|
||||
("notify.bugsnag.com", 1), ("ads.mopub.com", 1),
|
||||
],
|
||||
"lookups": 1300,
|
||||
},
|
||||
"pixel": {
|
||||
"types": ANDROID_TYPES,
|
||||
"domains": COMMON_WEB + [
|
||||
("android.googleapis.com", 10), ("play.googleapis.com", 8), ("www.googleapis.com", 10),
|
||||
("android.clients.google.com", 6), ("mtalk.google.com", 10), ("connectivitycheck.gstatic.com", 8),
|
||||
("clients4.google.com", 4), ("play-lh.googleusercontent.com", 6), ("lh3.googleusercontent.com", 5),
|
||||
("photos.googleapis.com", 3), ("gmail.googleapis.com", 4), ("inbox.google.com", 2),
|
||||
("youtubei.googleapis.com", 10), ("i.ytimg.com", 6), ("rr1---sn-4g5e6nzz.googlevideo.com", 3),
|
||||
("g.whatsapp.net", 10), ("mmg.whatsapp.net", 5), ("web.whatsapp.com", 1),
|
||||
("i.instagram.com", 8), ("scontent.cdninstagram.com", 7), ("api.spotify.com", 4),
|
||||
("spclient.wg.spotify.com", 5), ("discord.com", 3), ("gateway.discord.gg", 3),
|
||||
("cdn.discordapp.com", 3), ("time.android.com", 3),
|
||||
# App analytics and ad SDKs.
|
||||
("app-measurement.com", 10), ("firebaselogging-pa.googleapis.com", 8),
|
||||
("crashlyticsreports-pa.googleapis.com", 4), ("settings.crashlytics.com", 3),
|
||||
("googleads.g.doubleclick.net", 5), ("adservice.google.com", 3),
|
||||
("graph.facebook.com", 3), ("an.facebook.com", 2), ("api.mixpanel.com", 2),
|
||||
("app.adjust.com", 2), ("t.appsflyer.com", 2), ("ads.mopub.com", 1),
|
||||
],
|
||||
"lookups": 1450,
|
||||
},
|
||||
"kids": {
|
||||
"types": ANDROID_TYPES,
|
||||
"domains": [
|
||||
("www.google.com", 12), ("www.youtube.com", 20), ("youtubei.googleapis.com", 18),
|
||||
("i.ytimg.com", 14), ("yt3.ggpht.com", 6), ("android.googleapis.com", 6),
|
||||
("mtalk.google.com", 6), ("connectivitycheck.gstatic.com", 5), ("www.gstatic.com", 5),
|
||||
("play.googleapis.com", 5), ("www.roblox.com", 10), ("apis.roblox.com", 10),
|
||||
("tr.rbxcdn.com", 6), ("www.minecraft.net", 3),
|
||||
("api.minecraftservices.com", 4), ("pocket.realms.minecraft.net", 2),
|
||||
("api.spotify.com", 3), ("www.duolingo.com", 3), ("android-api.duolingo.com", 4),
|
||||
("en.wikipedia.org", 2), ("www.bing.com", 2), ("g.whatsapp.net", 4),
|
||||
("www.tiktok.com", 4), ("api16-normal-c-useast1a.tiktokv.com", 4),
|
||||
("v16-webapp.tiktok.com", 3), ("discord.com", 2),
|
||||
# Game ad networks and analytics.
|
||||
("unityads.unity3d.com", 7), ("config.unityads.unity3d.com", 4),
|
||||
("auction.unityads.unity3d.com", 4), ("ms.applovin.com", 5), ("a.applovin.com", 4),
|
||||
("rt.applovin.com", 3), ("googleads.g.doubleclick.net", 6), ("app-measurement.com", 7),
|
||||
("firebaselogging-pa.googleapis.com", 4), ("supersonicads.com", 2),
|
||||
("outcome-ssp.supersonicads.com", 2), ("init.supersonicads.com", 2),
|
||||
("events.appsflyer.com", 2), ("ads.vungle.com", 2), ("api.vungle.com", 2),
|
||||
# Casino ads in free games: only the kids group blocks these.
|
||||
("www.pokerstars.com", 1), ("www.bet365.com", 1), ("www.888casino.com", 1)],
|
||||
"lookups": 1150,
|
||||
},
|
||||
"desktop": {
|
||||
"types": WINDOWS_TYPES,
|
||||
"domains": COMMON_WEB + [
|
||||
("www.msftconnecttest.com", 6), ("login.live.com", 6), ("login.microsoftonline.com", 4),
|
||||
("settings-win.data.microsoft.com", 6), ("v10.events.data.microsoft.com", 10),
|
||||
("watson.telemetry.microsoft.com", 4), ("self.events.data.microsoft.com", 5),
|
||||
("browser.events.data.msn.com", 4), ("arc.msn.com", 3), ("ris.api.iris.microsoft.com", 3),
|
||||
("www.bing.com", 6), ("assets.msn.com", 4), ("ntp.msn.com", 4), ("img-s-msn-com.akamaized.net", 3),
|
||||
("fe3cr.delivery.mp.microsoft.com", 3), ("tsfe.trafficshaping.dsp.mp.microsoft.com", 2),
|
||||
("dl.delivery.mp.microsoft.com", 2), ("ctldl.windowsupdate.com", 3), ("time.windows.com", 2),
|
||||
("outlook.office365.com", 6), ("teams.microsoft.com", 4), ("graph.microsoft.com", 4),
|
||||
("onedrive.live.com", 2), ("store.steampowered.com", 3), ("api.steampowered.com", 4),
|
||||
("steamcdn-a.akamaihd.net", 2), ("discord.com", 3), ("gateway.discord.gg", 3),
|
||||
("clients2.google.com", 3), ("update.googleapis.com", 2), ("nas.lan", 3), ("printer.lan", 1),
|
||||
("vortex.data.microsoft.com", 4), ("mobile.events.data.microsoft.com", 3),
|
||||
("telemetry.nvidia.com", 2), ("events.gfe.nvidia.com", 2),
|
||||
],
|
||||
"lookups": 900,
|
||||
},
|
||||
"mac": {
|
||||
"types": APPLE_TYPES,
|
||||
"domains": COMMON_WEB + [
|
||||
("gateway.icloud.com", 10), ("api.apple-cloudkit.com", 6), ("gs-loc.apple.com", 3),
|
||||
("swscan.apple.com", 2), ("swdist.apple.com", 1), ("xp.apple.com", 4),
|
||||
("ocsp2.apple.com", 6), ("valid.apple.com", 2), ("captive.apple.com", 4),
|
||||
("configuration.ls.apple.com", 2), ("time.apple.com", 2), ("api.github.com", 4),
|
||||
("avatars.githubusercontent.com", 3), ("objects.githubusercontent.com", 2),
|
||||
("registry.npmjs.org", 3), ("slack.com", 6), ("wss-primary.slack.com", 4),
|
||||
("edgeapi.slack.com", 3), ("a.slack-edge.com", 3), ("zoom.us", 3), ("us05web.zoom.us", 2),
|
||||
("www.notion.so", 4), ("api.spotify.com", 3), ("spclient.wg.spotify.com", 4),
|
||||
("docs.google.com", 5), ("drive.google.com", 3), ("mail.google.com", 6),
|
||||
("nas.lan", 4), ("files.lan", 2), ("printer.lan", 2),
|
||||
("metrics.icloud.com", 3), ("api-adservices.apple.com", 2), ("notes-analytics-events.apple.com", 2),
|
||||
("stats.g.doubleclick.net", 3), ("o0.ingest.sentry.io", 2), ("api.segment.io", 2),
|
||||
("cdn.segment.com", 2), ("api.mixpanel.com", 1), ("in.hotjar.com", 1), ("static.hotjar.com", 1),
|
||||
],
|
||||
"lookups": 800,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Spread the bulk-generated query log over the previous day with a household
|
||||
rhythm, then rebuild every projection from the rewritten rows.
|
||||
|
||||
Run only while nxdns is stopped. usage: rewrite_history.py DATA_DIR
|
||||
"""
|
||||
import datetime
|
||||
import random
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
DATA = sys.argv[1]
|
||||
NOW = int(time.time())
|
||||
START = NOW - 24 * 3600 + 120
|
||||
END = NOW - 300
|
||||
CREATED_AT = START - 1200
|
||||
|
||||
# Relative activity per local hour of day, per device.
|
||||
CURVES = {
|
||||
"192.168.50.41": [2, 1, 1, 1, 1, 1, 3, 20, 25, 8, 6, 6, 10, 8, 6, 8, 15, 45, 80, 100, 100, 90, 60, 20], # TV
|
||||
"192.168.50.52": [6, 3, 3, 3, 3, 5, 25, 70, 60, 35, 35, 40, 60, 45, 35, 40, 45, 60, 70, 80, 85, 80, 60, 25], # iPhone
|
||||
"192.168.50.53": [5, 3, 3, 3, 3, 4, 15, 60, 70, 40, 40, 45, 65, 50, 40, 45, 50, 65, 75, 80, 75, 70, 50, 20], # Pixel
|
||||
"192.168.50.54": [1, 1, 1, 1, 1, 1, 4, 35, 20, 2, 2, 2, 4, 2, 2, 40, 90, 100, 85, 80, 60, 25, 4, 2], # Kids' phone
|
||||
"192.168.50.20": [2, 1, 1, 1, 1, 1, 1, 4, 10, 15, 15, 15, 20, 15, 15, 20, 30, 50, 80, 100, 100, 85, 50, 15], # Desktop PC
|
||||
"192.168.50.21": [2, 1, 1, 1, 1, 1, 2, 15, 60, 100, 100, 95, 60, 90, 100, 95, 80, 40, 25, 25, 20, 12, 5, 3], # MacBook Air
|
||||
}
|
||||
|
||||
|
||||
def weight(ip, ts):
|
||||
return CURVES[ip][datetime.datetime.fromtimestamp(ts).hour]
|
||||
|
||||
|
||||
def sample_times(ip, n):
|
||||
minutes = list(range(START, END, 60))
|
||||
weights = [weight(ip, m) for m in minutes]
|
||||
return sorted(m + random.randrange(60) for m in random.choices(minutes, weights, k=n))
|
||||
|
||||
|
||||
def lookups(rows):
|
||||
"""Group a client's rows into lookups: the A/AAAA/HTTPS burst of one name."""
|
||||
groups, last = [], None
|
||||
for row_id, ts, domain_id in rows:
|
||||
if last and last[0] == domain_id and ts - last[1] <= 1:
|
||||
groups[-1].append(row_id)
|
||||
else:
|
||||
groups.append([row_id])
|
||||
last = (domain_id, ts)
|
||||
return groups
|
||||
|
||||
|
||||
# The repository's own bucket recompute (src/storage/repositories/queries_repo.zig,
|
||||
# `recompute_bucket_sql`), verbatim; ?1 is the bucket start, ?2 its exclusive end.
|
||||
RECOMPUTE_BUCKET_SQL = [
|
||||
"""INSERT INTO bucket_totals (bucket, queries, blocked, cached, rt_sum, rt_count)
|
||||
SELECT ?1, count(*), coalesce(sum(blocked <> 0), 0), coalesce(sum(cache_hit = 1), 0),
|
||||
coalesce(sum(response_time_us), 0), count(response_time_us)
|
||||
FROM query_log
|
||||
WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
HAVING count(*) > 0""",
|
||||
"""INSERT INTO bucket_clients (bucket, client_ip, queries)
|
||||
SELECT ?1, client_ip, count(*)
|
||||
FROM query_log
|
||||
WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
GROUP BY client_ip""",
|
||||
"""INSERT INTO bucket_types (bucket, qtype, count)
|
||||
SELECT ?1, coalesce(qtype, -1), count(*)
|
||||
FROM query_log
|
||||
WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
GROUP BY coalesce(qtype, -1)""",
|
||||
"""INSERT INTO bucket_routes (bucket, route_kind, source_present, source_text, count)
|
||||
SELECT ?1, route_kind, source IS NOT NULL, coalesce(source, ''), count(*)
|
||||
FROM (SELECT route_kind,
|
||||
CASE route_kind
|
||||
WHEN 'upstream' THEN upstream
|
||||
WHEN 'forward_zone' THEN forward_zone
|
||||
END AS source
|
||||
FROM query_log
|
||||
WHERE timestamp >= ?1 AND timestamp < ?2)
|
||||
GROUP BY route_kind, source""",
|
||||
]
|
||||
GRAIN = 1800
|
||||
|
||||
COLUMNS = (
|
||||
"timestamp, domain_id, client_ip, qtype, blocked, response_time_us, cache_hit, upstream, qclass, rcode,"
|
||||
" group_id, group_name, policy_action, policy_reason, matched, source_id, source_name, cname_target,"
|
||||
" safe_search_target, route_kind, forward_zone"
|
||||
)
|
||||
|
||||
ql = sqlite3.connect(f"{DATA}/querylog.db", isolation_level=None)
|
||||
ql.execute("BEGIN IMMEDIATE")
|
||||
new_ts = {}
|
||||
for (ip,) in ql.execute("SELECT DISTINCT client_ip FROM query_log").fetchall():
|
||||
rows = ql.execute(
|
||||
"SELECT id, timestamp, domain_id FROM query_log WHERE client_ip = ? ORDER BY timestamp, id", (ip,)
|
||||
).fetchall()
|
||||
groups = lookups(rows)
|
||||
for group, ts in zip(groups, sample_times(ip, len(groups))):
|
||||
for row_id in group:
|
||||
new_ts[row_id] = ts
|
||||
ql.execute("CREATE TEMP TABLE remap (old_id INTEGER PRIMARY KEY, ts INTEGER NOT NULL)")
|
||||
ql.executemany("INSERT INTO remap VALUES (?, ?)", new_ts.items())
|
||||
rest = COLUMNS.split(", ", 1)[1]
|
||||
ql.execute(f"CREATE TEMP TABLE rewritten AS SELECT r.ts AS timestamp, {rest}, q.id AS old_id"
|
||||
" FROM query_log q JOIN remap r ON r.old_id = q.id")
|
||||
before = ql.execute("SELECT count(*) FROM query_log").fetchone()[0]
|
||||
ql.execute("DELETE FROM query_log")
|
||||
ql.execute(f"INSERT INTO query_log ({COLUMNS}) SELECT {COLUMNS} FROM rewritten ORDER BY timestamp, old_id")
|
||||
after = ql.execute("SELECT count(*) FROM query_log").fetchone()[0]
|
||||
assert before == after == len(new_ts), (before, after, len(new_ts))
|
||||
|
||||
for table in ("bucket_totals", "bucket_clients", "bucket_types", "bucket_routes"):
|
||||
ql.execute(f"DELETE FROM {table}")
|
||||
buckets = [b for (b,) in ql.execute(f"SELECT DISTINCT timestamp / {GRAIN} * {GRAIN} FROM query_log")]
|
||||
for bucket in buckets:
|
||||
for sql in RECOMPUTE_BUCKET_SQL:
|
||||
ql.execute(sql, (bucket, bucket + GRAIN))
|
||||
ql.execute("UPDATE querylog_meta SET created_at = ?, available_since = ? WHERE id = 1", (CREATED_AT, CREATED_AT + 1))
|
||||
ql.execute("COMMIT")
|
||||
ql.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
span = ql.execute("SELECT min(timestamp), max(timestamp), count(*) FROM query_log").fetchone()
|
||||
print(f"querylog: {span[2]} rows from {span[0]} to {span[1]}, {len(buckets)} buckets rebuilt")
|
||||
|
||||
cfg = sqlite3.connect(f"{DATA}/config.db", isolation_level=None)
|
||||
cfg.execute("ATTACH ? AS ql", (f"{DATA}/querylog.db",))
|
||||
cfg.execute("BEGIN IMMEDIATE")
|
||||
cfg.execute("""UPDATE clients SET
|
||||
first_seen = coalesce((SELECT min(timestamp) FROM ql.query_log WHERE client_ip = clients.ip), first_seen),
|
||||
last_seen = coalesce((SELECT max(timestamp) FROM ql.query_log WHERE client_ip = clients.ip), last_seen)""")
|
||||
cfg.execute("UPDATE rules SET created_at = ?", (CREATED_AT,))
|
||||
cfg.execute("COMMIT")
|
||||
cfg.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
for row in cfg.execute("SELECT ip, name, first_seen, last_seen FROM clients ORDER BY ip"):
|
||||
print("client", row)
|
||||
@@ -0,0 +1,50 @@
|
||||
// usage: node shoot.mjs OUT_DIR QUERY_ID
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const [outDir, queryId] = process.argv.slice(2);
|
||||
const BASE = "http://127.0.0.1:18080";
|
||||
const PAGES = [
|
||||
["overview", "/overview?period=24h"],
|
||||
["activity", "/activity"],
|
||||
["query-detail", `/activity/queries/${queryId}`],
|
||||
["clients", "/clients"],
|
||||
["diagnostics", "/diagnostics"],
|
||||
];
|
||||
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[aria-busy="true"], [role="progressbar"]'),
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
).catch(() => console.log(" still busy after 15s"));
|
||||
await page.waitForTimeout(2500);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: scheme,
|
||||
timezoneId: "Europe/Paris",
|
||||
locale: "en-GB",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => m.type() === "error" && console.log(` console error: ${m.text()}`));
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.locator("#password").fill("nxdns-demo");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL((u) => !u.pathname.startsWith("/login"));
|
||||
for (const [name, path] of PAGES) {
|
||||
await page.goto(BASE + path);
|
||||
await page.mouse.move(1430, 890);
|
||||
await settle(page);
|
||||
if (name === "activity") await page.waitForTimeout(8000);
|
||||
const file = `${outDir}/${name}-${scheme}.png`;
|
||||
await page.screenshot({ path: file });
|
||||
console.log(file);
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,67 @@
|
||||
"""One household device's DNS traffic, stdlib only.
|
||||
|
||||
usage: traffic.py PROFILE bulk send the profile's lookup budget fast, then exit
|
||||
traffic.py PROFILE live MEAN_S one lookup every ~MEAN_S seconds, forever
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from profiles import PROFILES # noqa: E402
|
||||
|
||||
SERVER = (os.environ.get("NXDNS", "192.168.50.2"), 53)
|
||||
|
||||
|
||||
def build_query(qid, name, qtype):
|
||||
header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 1)
|
||||
qname = b"".join(bytes([len(label)]) + label.encode() for label in name.split(".")) + b"\0"
|
||||
opt = b"\0" + struct.pack("!HHIH", 41, 1232, 0, 0)
|
||||
return header + qname + struct.pack("!HH", qtype, 1) + opt
|
||||
|
||||
|
||||
def pick_types(type_sets):
|
||||
sets, weights = zip(*type_sets)
|
||||
return random.choices(sets, weights)[0]
|
||||
|
||||
|
||||
def main():
|
||||
name, mode = sys.argv[1], sys.argv[2]
|
||||
profile = PROFILES[name]
|
||||
domains, weights = zip(*profile["domains"])
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(6)
|
||||
sent = answered = failed = 0
|
||||
lookups = profile["lookups"] if mode == "bulk" else None
|
||||
mean = float(sys.argv[3]) if mode == "live" else 0.0
|
||||
done = 0
|
||||
while lookups is None or done < lookups:
|
||||
domain = random.choices(domains, weights)[0]
|
||||
for qtype in pick_types(profile["types"]):
|
||||
qid = random.randrange(65536)
|
||||
sock.sendto(build_query(qid, domain, qtype), SERVER)
|
||||
sent += 1
|
||||
try:
|
||||
while True:
|
||||
data, _ = sock.recvfrom(4096)
|
||||
if struct.unpack("!H", data[:2])[0] == qid:
|
||||
answered += 1
|
||||
break
|
||||
except socket.timeout:
|
||||
failed += 1
|
||||
print(f"{name}: no answer for {domain} type {qtype}", flush=True)
|
||||
done += 1
|
||||
if mode == "bulk":
|
||||
time.sleep(random.uniform(0.05, 0.25))
|
||||
else:
|
||||
time.sleep(random.expovariate(1.0 / mean))
|
||||
if done % 500 == 0:
|
||||
print(f"{name}: {done} lookups, {sent} sent, {answered} answered, {failed} unanswered", flush=True)
|
||||
print(f"{name}: finished {done} lookups, {sent} sent, {answered} answered, {failed} unanswered", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "nxdns-screenshot-demo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nxdns-screenshot-demo",
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "nxdns-screenshot-demo",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Starts nxdns on the demo LAN, importing etc/config.zon into a fresh nxdemo-data volume on the
|
||||
# first run. nxdns keeps the image's own uid 65532: under a host uid it was signalled from outside
|
||||
# docker twice. The data lives in a named volume so no file in this tree is owned by that uid.
|
||||
set -eu
|
||||
DEMO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
IMAGE=${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:0.0.21}
|
||||
docker network inspect nxdemo-lan >/dev/null 2>&1 ||
|
||||
docker network create --driver bridge --subnet 192.168.50.0/24 --gateway 192.168.50.1 nxdemo-lan
|
||||
if ! docker volume inspect nxdemo-data >/dev/null 2>&1; then
|
||||
docker volume create nxdemo-data >/dev/null
|
||||
docker run --rm -v "$DEMO/etc:/etc/nxdns:ro" -v nxdemo-data:/var/lib/nxdns "$IMAGE" import /etc/nxdns/config.zon
|
||||
fi
|
||||
docker run -d --name nxdemo-nxdns --restart unless-stopped \
|
||||
--network nxdemo-lan --ip 192.168.50.2 --sysctl net.ipv4.ip_unprivileged_port_start=0 \
|
||||
-p 127.0.0.1:18080:8080 -v nxdemo-data:/var/lib/nxdns "$IMAGE" run
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Removes every container, the volume and the network the demo created. Each step runs even if an
|
||||
# earlier one finds nothing to remove.
|
||||
set -u
|
||||
docker rm -f nxdemo-tv nxdemo-iphone nxdemo-pixel nxdemo-kids nxdemo-desktop nxdemo-mac nxdemo-nxdns
|
||||
docker volume rm nxdemo-data
|
||||
docker network rm nxdemo-lan
|
||||
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 219 KiB |