milestone 24: persist and surface the unsupported-line count
This commit is contained in:
@@ -98,7 +98,7 @@ Two SQLite files with opposite write profiles, isolated from each other:
|
||||
|
||||
Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state:
|
||||
|
||||
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines are counted + skipped (counts in metadata → UI). The checksum covers the three bodies in that order, so a source with no exceptions keeps the digest it had when only two existed and no upgrade forces a refetch.
|
||||
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines and browser-syntax lines nxdns cannot translate into a DNS decision are counted + skipped, both counts in metadata → UI. The checksum covers the three bodies in that order, so a source with no exceptions keeps the digest it had when only two existed and no upgrade forces a refetch.
|
||||
- `config.db` keeps source **metadata only** (`blocklist_sources`).
|
||||
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5).
|
||||
- Corruption recovery is per-file: checksum mismatch → re-download one list.
|
||||
@@ -419,6 +419,7 @@ CREATE TABLE blocklist_sources (
|
||||
wildcard_count INTEGER NOT NULL DEFAULT 0,
|
||||
exception_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_regex_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
|
||||
checksum TEXT
|
||||
);
|
||||
|
||||
|
||||
@@ -287,6 +287,21 @@ first, so a downloaded list can reopen only a hole another downloaded list dug.
|
||||
Each source reports how many it carried as `exceptions`; there is no way to write
|
||||
one by hand, and no reason to want one — write an allow rule instead.
|
||||
|
||||
Two counters report what a compile skipped, and they are different facts.
|
||||
`skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
|
||||
patterns only from the operator, so a regex line in a downloaded list is counted,
|
||||
skipped and surfaced — adopt the ones you trust as `regex` rules.
|
||||
`skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
|
||||
decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$`
|
||||
modifier (except `$important` on an exception line, tolerated above), scheme
|
||||
anchors, non-anchored `@@` forms — and, in a `domains`-format
|
||||
list, a line holding more than one field before its inline comment, which usually
|
||||
means the list is really a hosts file that was declared as `domains`. Neither is
|
||||
an error, and the two are never one number. A large `skipped_unsupported` beside
|
||||
a small `domain_count` usually means the list is written for browser extensions,
|
||||
and its DNS or hosts variant will block more here. Both appear per source in the
|
||||
blocklists UI and on `/api/blocklists`.
|
||||
|
||||
### group_sources
|
||||
|
||||
Which groups consult which blocklist sources.
|
||||
|
||||
@@ -220,15 +220,18 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
|
||||
```
|
||||
|
||||
```json
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0}]}
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0,"skipped_unsupported":0}]}
|
||||
```
|
||||
|
||||
The three zeros are the parts of this list that a hosts file cannot have.
|
||||
The four zeros describe this particular download, not the hosts format.
|
||||
`wildcards` counts entries covering a name and its subdomains, `exceptions`
|
||||
counts the `@@` lines an Adblock Plus list uses to lift a name another list
|
||||
blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a
|
||||
downloaded list. Only the Adblock Plus syntax writes any of them; a hosts file is
|
||||
one name per line.
|
||||
downloaded list. `skipped_unsupported` counts lines nxdns cannot translate into a
|
||||
DNS decision, and a large value next to a small `domains` means the list targets
|
||||
browsers rather than DNS. Only `exceptions` is Adblock-Plus-only: a hosts list
|
||||
can carry regex lines, `*.`-prefixed wildcards, and bare sink addresses that
|
||||
count as unsupported. This one carries none of them.
|
||||
|
||||
The download is about 3 MB and takes a few seconds. Watch the first terminal
|
||||
until this appears:
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
# Milestone 24: `skipped_unsupported` is persisted, surfaced and explained
|
||||
|
||||
Goal: close the compile-pipeline silent drop that misleads the operator. `Counts.
|
||||
skipped_unsupported` (`src/filter/compiler.zig:34`) is counted on every compile
|
||||
(compiler.zig:92) and then discarded on the happy path — it reaches the
|
||||
compiled-file header and the `NoValidEntries` error text, but no database
|
||||
column, no API field and no UI cell. Its sibling `skipped_regex` reaches all
|
||||
three. A blocklist made almost entirely of cosmetic browser filters therefore
|
||||
compiles to almost nothing and looks, in the UI, exactly like a clean list.
|
||||
AGENTS.md forbids exactly this ("no silent drops"). The milestone persists the
|
||||
count, surfaces it everywhere `skipped_regex` is surfaced, and explains to the
|
||||
operator why the two skip counters are different facts.
|
||||
|
||||
Design written 2026-08-13 against HEAD `21571e4`, revised after a Codex review
|
||||
of the first draft.
|
||||
|
||||
## Implementation contract (read first)
|
||||
|
||||
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||
- **The v1 baseline is editable and there is no migration step.** As of commit
|
||||
`21571e4`, `src/storage/migrations.zig` holds exactly one step and
|
||||
`target_version` is 1 (migrations.zig:29-36). nxdns has zero installs; the
|
||||
baseline freezes at v0.1 (`config_schema.zig:6-12`, PLAN §3.7, §11.2). The
|
||||
new column is one line edited into `config_schema.ddl_v1` plus the identical
|
||||
line in PLAN §11.2, which is kept byte-identical to it. A diff that adds a
|
||||
`ddl_v2` or a second `Step` is wrong and must be reverted, not merged.
|
||||
- After the API shape change, regenerate the contract samples
|
||||
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md — the
|
||||
`zig build test -Dintegration -Dcontract-samples-out=...` command, never a
|
||||
hand edit) and update `web/src/lib/types.ts` to match.
|
||||
- No new `src/**.zig` files, so `src/tests.zig` and `build.zig` are untouched.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. The column is `skipped_unsupported_count`, one edited line, no step
|
||||
|
||||
`skipped_regex_count` (config_schema.zig:63) sets the convention; the new
|
||||
column follows it. In `config_schema.ddl_v1`, directly after the
|
||||
`skipped_regex_count` line inside `CREATE TABLE blocklist_sources`:
|
||||
|
||||
```sql
|
||||
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
|
||||
```
|
||||
|
||||
The identical line goes into PLAN §11.2 after PLAN.md:421 — that section is
|
||||
kept byte-identical to `ddl_v1` and currently is (verified: the fenced SQL
|
||||
matches the Zig string literal line for line).
|
||||
|
||||
Consequence to accept, not to fix: a development database stamped version 1
|
||||
before this edit will fail the first `SELECT` naming the column with "no such
|
||||
column", because the stamped version equals `target_version` and the step never
|
||||
reruns. That is the documented pre-v0.1 contract (`config_schema.zig:6-12`) —
|
||||
delete the scratch database. Do not add fallback SQL, `PRAGMA table_info`
|
||||
probing, or a migration step to paper over it.
|
||||
|
||||
The baseline test "a fresh database reaches the baseline with every v1 column
|
||||
and rule kind" (migrations.zig:266-283) gains
|
||||
`try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));`
|
||||
beside the existing `exception_count` probe.
|
||||
|
||||
### 2. The repo persists it beside `skipped_regex_count`
|
||||
|
||||
`src/storage/repositories/sources_repo.zig`, mirroring `skipped_regex_count`
|
||||
exactly (no default on either struct field, so every construction site fails
|
||||
to compile until it names the count — that is the visibility this milestone is
|
||||
about):
|
||||
|
||||
- `SourceRow` (sources_repo.zig:80-97) gains `skipped_unsupported_count: i64`
|
||||
after `skipped_regex_count`.
|
||||
- `SourceStats` (sources_repo.zig:99-110) gains the same field.
|
||||
- `row_columns_sql` (sources_repo.zig:112-117) appends
|
||||
`skipped_unsupported_count` to the SELECT list (index 11);
|
||||
`readSourceRow` (sources_repo.zig:128-148) reads it with
|
||||
`stmt.columnInt(11)`.
|
||||
- `update_stats_sql` (sources_repo.zig:159-164) adds
|
||||
`skipped_unsupported_count = ?8`; `updateSourceStats`
|
||||
(sources_repo.zig:168-179) binds it.
|
||||
- The module doc comment (sources_repo.zig:3-5) adds the column to its list of
|
||||
server-produced facts, and so does the `src/config/model.zig:14` doc comment
|
||||
that repeats that list.
|
||||
|
||||
**The sum at sources_repo.zig:318 includes the new column.** That sum lives in
|
||||
the test "insertBlocklistSource leaves the runtime columns at their defaults";
|
||||
it exists to prove an insert leaves every runtime counter at 0, and the new
|
||||
column is a runtime counter. This is a deliberate decision, not an oversight:
|
||||
no production query sums these columns into a "total entries" figure, and none
|
||||
may start to — `skipped_regex_count` and `skipped_unsupported_count` count
|
||||
lines *not* written, unlike `domain_count`, `wildcard_count` and
|
||||
`exception_count`, so any future entries total must exclude both. The test sum
|
||||
asserts defaults, which is the one context where adding them is correct.
|
||||
|
||||
Existing `SourceStats` / assertion sites in this file (the literals at
|
||||
sources_repo.zig:386-393, 420-427, 483-493 and the zero-default loop at
|
||||
sources_repo.zig:365-374) carry the new field with distinct non-zero values
|
||||
where their siblings have them, and the round-trip assertions extend to it.
|
||||
|
||||
### 3. The manager writes it, and rehydration restores it
|
||||
|
||||
`src/filter/manager.zig`. The header already prints
|
||||
`# skipped_unsupported {d}` (manager.zig:243) and `SourceStatus.counts` is a
|
||||
full `compiler.Counts` (manager.zig:178), so within one process the count
|
||||
already reaches the status table. What is missing is the database, which is
|
||||
the only thing a restart reads — rehydration never reparses headers.
|
||||
|
||||
- The fresh-publish path (manager.zig:854-861) passes
|
||||
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported` to
|
||||
`updateSourceStats`.
|
||||
- **The checksum-unchanged path (manager.zig:821-836) carries a bug this
|
||||
milestone fixes.** It writes the *stored* `row.skipped_regex_count` back to
|
||||
the database (manager.zig:830) while handing the *fresh*
|
||||
`compiled.result.counts` to the in-memory status (manager.zig:833). The
|
||||
checksum covers the `.list`, `.wild` and `.allow` bodies only, and a skipped
|
||||
line lands in none of them — so a list that changes only its regex or
|
||||
browser-syntax lines keeps its checksum, the running server shows the new
|
||||
number, the database keeps the old one, and the next restart silently reverts
|
||||
what the operator saw. That is milestone 21's column, wrong today.
|
||||
|
||||
Both skip counters take `compiled.result.counts` on this path:
|
||||
`.skipped_regex_count = compiled.result.counts.skipped_regex` and
|
||||
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported`.
|
||||
`domain_count`, `wildcard_count` and `exception_count` keep reading from
|
||||
`row` — they count written entries, so an unchanged checksum does mean an
|
||||
unchanged value for them.
|
||||
|
||||
This needs a test the author has watched fail with the fix reverted: two
|
||||
compiles of bodies whose written entries are identical but whose skipped
|
||||
lines differ, asserting the same checksum, then asserting the database holds
|
||||
the second compile's counts, then rehydrating through `applyLoadOutcomes` to
|
||||
prove the restored status carries them. Report the observed failure output.
|
||||
- `applyLoadOutcomes` rehydration (manager.zig:1545-1550) adds
|
||||
`.skipped_unsupported = countOf(row.skipped_unsupported_count)` to the
|
||||
`Counts` it rebuilds, so a restarted server reports what the last compile
|
||||
skipped instead of 0.
|
||||
- Test literals name the field: one `SourceStats` (manager.zig:1928) and two
|
||||
`SourceRow` (manager.zig:2051, 2225). The rehydration test that feeds a row
|
||||
through
|
||||
`applyLoadOutcomes` asserts a non-zero `skipped_unsupported` lands in
|
||||
`status.counts`. The header pin test (manager.zig:2030-2031) already covers
|
||||
the `# skipped_unsupported` line and is extended only if its fixture counts
|
||||
change.
|
||||
- `rejectedWithoutEntries` (manager.zig:1640-1643) and `failNoValidEntries`
|
||||
(manager.zig:1127-1128) already read the count and are unchanged.
|
||||
|
||||
`src/filter/filter_integration_test.zig` names `.skipped_regex_count` in six
|
||||
`SourceStats` literals (:826, :858, :1075, :1145, :1257, :2158) — each gains
|
||||
the sibling field. The stats round-trip assertion at :915 gains a sibling
|
||||
assertion for the new column.
|
||||
|
||||
**Do not add an unsupported line to the existing fixture.** The fixture at
|
||||
filter_integration_test.zig:112 is hosts-shaped, and `detectFormat`
|
||||
(`src/filter/parsers.zig:118`) assigns one format to a whole source. A single
|
||||
`##.ad-banner` or `$`-modifier line flips it to ABP, which re-parses every
|
||||
existing line: the `*.wild` entry becomes unsupported and address fields
|
||||
tokenize as domains (`src/filter/parser_abp.zig:68`). Every expected count in
|
||||
that test would move, for a reason unrelated to this milestone.
|
||||
|
||||
Add a **separate ABP-format fixture and test** carrying `##.ad-banner` and
|
||||
`||ads.example^$third-party` beside two blockable names, and assert
|
||||
`skipped_unsupported_count = 2` through the compile-persist-read round trip
|
||||
there.
|
||||
|
||||
`src/config/reconcile.zig`: the runtime-column preservation test seeds stats
|
||||
at reconcile.zig:1096-1110 and asserts survival at reconcile.zig:1173. The
|
||||
seed gains `.skipped_unsupported_count` with a distinct value and the
|
||||
assertion block gains its expectation. No reconcile logic changes: the engine
|
||||
updates declarative columns by name and never touches runtime columns, so the
|
||||
new column survives with zero code change — the test is the proof.
|
||||
|
||||
### 4. The API speaks it in both shapes
|
||||
|
||||
Two shapes carry blocklist counters, and the new count joins both under the
|
||||
names its siblings set:
|
||||
|
||||
- **`Blocklist`** (rows of `GET /api/blocklists`, serialized straight from
|
||||
`SourceRow`): the field arrives automatically once `SourceRow` has it, as
|
||||
`skipped_unsupported_count`. `src/web/openapi.yaml:1877-1895` adds it to
|
||||
`required` and `properties`.
|
||||
- **`SourceStatus`** (rows of `POST /api/blocklists/update`): `StatusView`
|
||||
(`src/web/handlers/blocklists.zig:52-80`) gains
|
||||
`skipped_unsupported: u32` after `skipped_regex`, mapped from
|
||||
`status.counts.skipped_unsupported` in `from`.
|
||||
`src/web/openapi.yaml:1920-1938` adds it to `required` and `properties`.
|
||||
The handler test literals at blocklists.zig:323 (`SourceStats`) and :386
|
||||
(`counts`) carry the field with non-zero values and the response assertions
|
||||
extend to it.
|
||||
|
||||
Contract fallout, all in the same session (ruling 6 explains why):
|
||||
|
||||
- Regenerate `web/src/lib/contractSamples.gen.ts` with the AGENTS.md command.
|
||||
- `web/src/lib/types.ts`: `Blocklist` gains
|
||||
`skipped_unsupported_count: number` (types.ts:154-166); `SourceStatus`
|
||||
gains `skipped_unsupported: number` (types.ts:183-195).
|
||||
- The web test mocks are **not** typed against these interfaces, so `tsc` will
|
||||
not force them. `BLOCKLISTS` in
|
||||
`web/src/features/blocklists/BlocklistsPage.test.tsx` is an inferred object
|
||||
literal handed to a `Record<string, unknown>` (BlocklistsPage.test.tsx:40),
|
||||
and the same untyped-fetch pattern holds in
|
||||
`web/src/features/groups/GroupsPage.test.tsx:16` and
|
||||
`web/src/features/settings/authority.test.tsx:64` — both of which already
|
||||
omit `exception_count` without failing. Updating a mock here is a semantic
|
||||
fixture change, not a typecheck fix, and the implementer must not expect a
|
||||
compiler error to point at them.
|
||||
|
||||
So: the BlocklistsPage mocks (:21, :34 blocklist rows; :104, :155, :168
|
||||
status rows) gain the field because S2's rendering assertions read it. The
|
||||
groups and settings mocks are left alone — they render no counter column,
|
||||
and widening them buys nothing. S1 picks values no other cell in the same
|
||||
table already shows (`3` and `7` are taken), e.g.
|
||||
`skipped_unsupported_count: 21` and `skipped_unsupported: 17`.
|
||||
|
||||
### 5. The UI shows it always, in both tables, and says what it means
|
||||
|
||||
Both tables gain a `Skipped unsupported` column directly after
|
||||
`Skipped regex`, rendered unconditionally:
|
||||
|
||||
- `web/src/features/blocklists/BlocklistsPage.tsx`: header after :157, cell
|
||||
`{b.skipped_unsupported_count}` after :190, same
|
||||
`shared.td, shared.tabularNums` props as its neighbours.
|
||||
- `web/src/features/blocklists/SourceStatusSection.tsx`: header after :91,
|
||||
cell `{source.skipped_unsupported}` after :114.
|
||||
|
||||
Always-shown is a decision, not a default: every other counter column here is
|
||||
unconditional, including `Skipped regex` and `Exceptions`, which are 0 for
|
||||
every plain hosts list, and the tutorial already explains those zeros. A
|
||||
column that appears only when non-zero would make two lists' tables disagree
|
||||
in shape, would hide the header that gives the number its meaning, and would
|
||||
special-case exactly the counter this milestone exists to make visible. The
|
||||
zero cell is not noise; it states that nothing in this list was classified as
|
||||
unsupported — which is narrower than "clean", since `invalid` and `long_lines`
|
||||
stay unsurfaced (ruling 7).
|
||||
|
||||
The two counters mean different things and the page must say so once. A muted
|
||||
paragraph (the existing `styles.empty`-style muted text, matching
|
||||
`SourceStatusSection`'s `note` treatment) rendered under the sources table in
|
||||
`BlocklistsPage.tsx`, inside the same `else` branch as the table — the note
|
||||
describes the two skip columns, so it appears exactly when they do. "Always
|
||||
visible" above means unconditional on values, never hidden at 0; it does not
|
||||
mean the empty state (`blocklists.length === 0`) carries a paragraph about
|
||||
columns that are not on screen. The empty state stays one instruction. Exact
|
||||
copy:
|
||||
|
||||
> Both “Skipped” columns count 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.
|
||||
|
||||
`BlocklistsPage.test.tsx` asserts: both new headers render, the mock values
|
||||
(`21`, and `17` after an update snapshot) render, and the note text is
|
||||
present. No threshold logic, no badge, no coloring by magnitude (see
|
||||
anti-requirements).
|
||||
|
||||
### 6. The docs explain both counters without contradicting what stands
|
||||
|
||||
- `docs/reference/configuration.md`, section `### blocklist_sources`, after
|
||||
the exceptions paragraph (configuration.md:282-288), a new paragraph:
|
||||
|
||||
> Two counters report what a compile skipped, and they are different facts.
|
||||
> `skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
|
||||
> patterns only from the operator, so a regex line in a downloaded list is
|
||||
> counted, skipped and surfaced — adopt the ones you trust as `regex` rules.
|
||||
> `skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
|
||||
> decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a
|
||||
> `$` modifier (except `$important` on an exception line, tolerated above),
|
||||
> scheme anchors, non-anchored `@@` forms — and, in a
|
||||
> `domains`-format list, a line holding more than one field before its
|
||||
> inline comment, which usually means the list is really a hosts file that
|
||||
> was declared as `domains`. Neither is an error, and the two are never one
|
||||
> number. A large `skipped_unsupported` beside a small `domain_count`
|
||||
> usually means the list is written for browser extensions, and its DNS or
|
||||
> hosts variant will block more here. Both appear per source in the
|
||||
> blocklists UI and on `/api/blocklists`.
|
||||
|
||||
- `docs/tutorial/first-run.md`: the recorded JSON at first-run.md:223 gains
|
||||
`"skipped_unsupported":0` after `"skipped_regex":0` (the endpoint now
|
||||
returns it; the recorded values themselves stand). The paragraph at
|
||||
first-run.md:226-231 becomes four zeros, keeps its `skipped_regex` sentence
|
||||
as written, and **drops the "parts of this list that a hosts file cannot
|
||||
have" framing** — a deliberate small widening ruled during implementation
|
||||
review. The framing was false before this milestone touched it:
|
||||
`parser_hosts.zig` recognizes regex lines (:16), reports a bare sink
|
||||
address as unsupported (:21), and a `*.`-prefixed name compiles to a
|
||||
wildcard in any format. Only `exceptions` is Adblock-Plus-only. The
|
||||
paragraph now presents the zeros as facts about this particular download
|
||||
and says so.
|
||||
- The `$` modifier is named as skipped **with its one exception**: `$important`
|
||||
on an anchored `@@` exception line is accepted and lands in
|
||||
`exception_count` (`parser_abp.zig`, PLAN §2.2). The configuration.md
|
||||
paragraph above, and the `SourceRow.skipped_unsupported_count` doc comment
|
||||
in `sources_repo.zig` that mirrors it, both carry the qualifier — an
|
||||
unqualified "rules carrying a `$` modifier" contradicts the parser.
|
||||
- PLAN.md:101 (§3.8) currently reads "regex lines are counted + skipped
|
||||
(counts in metadata → UI)". It becomes: regex lines *and* browser-syntax
|
||||
lines are counted and skipped, both counts in metadata → UI. One sentence;
|
||||
§2.2 (PLAN.md:36-37) already says unsupported forms "stay unsupported and
|
||||
counted" and needs no edit.
|
||||
- `docs/reference/files-and-directories.md` is untouched: the compiled-file
|
||||
header already carried `# skipped_unsupported` before this milestone and
|
||||
the file table there does not enumerate header lines.
|
||||
|
||||
### 7. The other three counters stay out, and the reasons differ per counter
|
||||
|
||||
The first draft claimed `invalid`, `long_lines` and `duplicates` were already
|
||||
"header-and-error-path only". That is false:
|
||||
|
||||
- `invalid` reaches the compiled-file header (manager.zig:244) and the
|
||||
`NoValidEntries` text (manager.zig:1127).
|
||||
- `long_lines` reaches the `NoValidEntries` text only — not the header.
|
||||
- `duplicates` reaches **nothing**: counted at compile, discarded on every
|
||||
path.
|
||||
|
||||
They stay out of scope, and not for one shared reason — which is why the goal
|
||||
above says "the silent drop that misleads the operator" rather than "the one
|
||||
silent drop left":
|
||||
|
||||
- `duplicates` hides no failure. A deduplicated name still blocks; the count
|
||||
measures input redundancy, not lost coverage. Discarding it discards a
|
||||
curiosity, so "no silent drops" does not reach it — there is no drop.
|
||||
- `invalid` and `long_lines` do measure dropped lines, and they remain only
|
||||
partially surfaced. The catastrophic form — a download that is all rejects —
|
||||
already fails loudly (`rejectedWithoutEntries`, state `no_valid_entries`);
|
||||
the residual is a list that loses some lines and still loads, visible in
|
||||
the header file and nowhere the UI reaches. That residual is real, it is
|
||||
recorded here deliberately, and it is not this milestone: the two skip
|
||||
counters name an action the operator can take (adopt the patterns; fetch
|
||||
the DNS variant), while these two say only "the list is malformed", which
|
||||
no column makes more actionable.
|
||||
|
||||
The decision is reversible; a later milestone can widen the table. What this
|
||||
spec may not do is claim the three are visible when `duplicates` is not.
|
||||
|
||||
### 8. Export, import and reconcile change nothing
|
||||
|
||||
`nxdns export` / `import` carry configuration; the compile counters are facts
|
||||
a running server produces. `model.BlocklistSource` holds only the four
|
||||
configuration columns, the insert leaves runtime columns at their defaults
|
||||
(sources_repo.zig:1-9, :48-61), and the dump helpers used by the import and
|
||||
reconcile suites are `SELECT *` compared dump-to-dump
|
||||
(`src/config/import.zig:167-186`, `src/config/reconcile.zig:988`), so no
|
||||
golden text names columns and the new column appears in both sides of every
|
||||
comparison. Ruling 3's reconcile-test extension is the only touch in
|
||||
`src/config/`, plus the model.zig:14 comment from ruling 2.
|
||||
|
||||
## Sessions
|
||||
|
||||
Two sessions, strictly sequential: S1 then S2. No parallelism — deliberately.
|
||||
The regenerated `contractSamples.gen.ts` typechecks only against a `types.ts`
|
||||
that already carries the new fields, and the samples are produced by a Zig
|
||||
integration run, so the generator and the interface it must satisfy sit on
|
||||
opposite sides of the language boundary and cannot land independently. S2's
|
||||
rendering assertions then read fields that only exist once S1 has landed both.
|
||||
|
||||
(The mocks are *not* part of this argument: they are untyped, so widening
|
||||
`types.ts` does not break them. The first draft claimed otherwise.)
|
||||
|
||||
### Session S1: column, persistence, API, contract
|
||||
|
||||
Owns: `src/storage/config_schema.zig`, `src/storage/migrations.zig` (test
|
||||
only), `src/storage/repositories/sources_repo.zig`, `src/filter/manager.zig`,
|
||||
`src/filter/filter_integration_test.zig`, `src/config/reconcile.zig` (test
|
||||
only), `src/config/model.zig` (comment only),
|
||||
`src/web/handlers/blocklists.zig`, `src/web/openapi.yaml`,
|
||||
`web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` (regenerated,
|
||||
never hand-edited), `web/src/features/blocklists/BlocklistsPage.test.tsx`
|
||||
(mock fields only — no rendering assertions),
|
||||
`PLAN.md` (the §11.2 line and
|
||||
the §3.8 sentence).
|
||||
|
||||
- S1.1 rulings 1 and 2: the DDL line in both copies, the repo columns, the
|
||||
migrations and repo tests.
|
||||
- S1.2 ruling 3: both `updateSourceStats` call sites, rehydration, test
|
||||
literals, the reconcile preservation assertion.
|
||||
- S1.3 ruling 4: `StatusView`, openapi.yaml, sample regeneration, `types.ts`,
|
||||
mock fields.
|
||||
|
||||
Acceptance (S1):
|
||||
- [ ] `zig build test` passes; the migrations baseline test proves
|
||||
`blocklist_sources.skipped_unsupported_count` exists.
|
||||
- [ ] A compile of the new **ABP-format** fixture carrying `##.ad-banner` and
|
||||
`||ads.example^$third-party` persists `skipped_unsupported_count = 2`
|
||||
through `updateSourceStats` and reads it back through `listSourceRows`.
|
||||
The existing hosts-shaped fixture is unchanged, and every count it
|
||||
already asserts still holds.
|
||||
- [ ] A second compile whose written entries are byte-identical but whose
|
||||
skipped lines differ produces the same checksum and still updates both
|
||||
skip counters in the database; the test was watched failing with the
|
||||
manager.zig:830 fix reverted, and the failure output is recorded.
|
||||
- [ ] `applyLoadOutcomes` fed a row with `skipped_unsupported_count = 5` and
|
||||
no live refresh yields `status.counts.skipped_unsupported == 5`.
|
||||
- [ ] `zig build test -Dintegration` passes, and the regenerated
|
||||
`contractSamples.gen.ts` carries `skipped_unsupported_count` in the
|
||||
Blocklist sample and `skipped_unsupported` in the SourceStatus sample.
|
||||
- [ ] `cd web && npm run typecheck && npm test` pass with the widened types.
|
||||
|
||||
### Session S2: UI and docs (needs S1)
|
||||
|
||||
Owns: `web/src/features/blocklists/BlocklistsPage.tsx`,
|
||||
`web/src/features/blocklists/SourceStatusSection.tsx`,
|
||||
`web/src/features/blocklists/BlocklistsPage.test.tsx` (rendering assertions),
|
||||
`docs/reference/configuration.md`, `docs/tutorial/first-run.md`.
|
||||
|
||||
- S2.1 ruling 5: both columns, the note paragraph, the rendering assertions.
|
||||
- S2.2 ruling 6: the two doc edits.
|
||||
|
||||
Acceptance (S2):
|
||||
- [ ] `cd web && npm run typecheck && npm test && npm run lint` pass; the new
|
||||
tests assert both `Skipped unsupported` headers, the mock values and the
|
||||
note text.
|
||||
- [ ] `npm run build` passes (`assert-css-layers.mjs` runs inside it).
|
||||
- [ ] `docs/tutorial/first-run.md` no longer says "three zeros", its JSON
|
||||
sample carries `skipped_unsupported`, and its `skipped_regex` sentence
|
||||
is unchanged.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Verify S1 acceptance before starting S2. After S2, run the full gate set
|
||||
(`zig build test`, `zig build test -Dintegration`, `test-aarch64` if qemu is
|
||||
present, `cd web && npm test`, `npm run assert-bundled`), then a live smoke
|
||||
against a scratch server with two real sources:
|
||||
|
||||
- `https://easylist.to/easylist/easylist.txt` — a browser-targeted list;
|
||||
expect a `skipped_unsupported` several times its `domains` (do not assert an
|
||||
exact number; assert the ratio and non-zero).
|
||||
- `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` — expect
|
||||
`skipped_unsupported` 0.
|
||||
|
||||
Verify the numbers appear in the `POST /api/blocklists/update` response, in
|
||||
`GET /api/blocklists`, and in both UI tables. Then restart the server and
|
||||
verify the counts survive into the status table without a refresh — that is
|
||||
ruling 3's rehydration working against the real database. Record deviations
|
||||
in `## Recorded (implementation)`.
|
||||
|
||||
## Module layout
|
||||
|
||||
New files: none. Deleted surface: none.
|
||||
|
||||
## File ownership
|
||||
|
||||
| File | Session |
|
||||
| --- | --- |
|
||||
| `src/storage/config_schema.zig` | S1 |
|
||||
| `src/storage/migrations.zig` | S1 |
|
||||
| `src/storage/repositories/sources_repo.zig` | S1 |
|
||||
| `src/filter/manager.zig` | S1 |
|
||||
| `src/filter/filter_integration_test.zig` | S1 |
|
||||
| `src/config/reconcile.zig` (test), `src/config/model.zig` (comment) | S1 |
|
||||
| `src/web/handlers/blocklists.zig`, `src/web/openapi.yaml` | S1 |
|
||||
| `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` | S1 |
|
||||
| `PLAN.md` (§11.2 line, §3.8 sentence) | S1 |
|
||||
| `web/src/features/blocklists/BlocklistsPage.test.tsx` | S1 (mock fields), then S2 (assertions) — sequential, never concurrent |
|
||||
| `web/src/features/blocklists/BlocklistsPage.tsx`, `web/src/features/blocklists/SourceStatusSection.tsx` | S2 |
|
||||
| `docs/reference/configuration.md`, `docs/tutorial/first-run.md` | S2 |
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All session acceptance boxes above.
|
||||
- [ ] `config_schema.ddl_v1` and PLAN §11.2 are byte-identical, both carrying
|
||||
the new line; `migrations.steps` still holds exactly one step and
|
||||
`target_version` is still 1.
|
||||
- [ ] The live smoke: EasyList shows a large `skipped_unsupported` and
|
||||
StevenBlack shows 0, in the API and in both UI tables, and both values
|
||||
survive a server restart.
|
||||
- [ ] `nxdns export` output is byte-identical before and after a refresh that
|
||||
wrote the new column (runtime columns stay out of exports).
|
||||
- [ ] The regenerated contract samples typecheck against `web/src/lib/types.ts`
|
||||
— that is what the sample mechanism proves, and it covers server-to-
|
||||
TypeScript agreement only.
|
||||
- [ ] `src/web/openapi.yaml` is reviewed **by hand** against the two changed
|
||||
response shapes, and the reviewer says so in `## Recorded`. No automated
|
||||
guard covers this: `web_integration_test.zig:2089` checks that routes and
|
||||
methods exist and `:2575` counts operations, but nothing compares a
|
||||
component schema to a real response. An openapi.yaml that omits the new
|
||||
field will pass every gate in this repo.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No migration step, no `ddl_v2`, no runtime schema probing. The baseline is
|
||||
edited in place per §3.7; a pre-edit scratch database is deleted, not
|
||||
reconciled.
|
||||
- No merging of the two skip counters into one number, anywhere — not in the
|
||||
API, not in a UI total, not in prose. They are different facts.
|
||||
- No "this list targets browsers" heuristic: no threshold, badge, warning
|
||||
color or ratio computation in the UI. The number plus the note paragraph is
|
||||
the surface; a cutoff would be an invented policy.
|
||||
- No conditional rendering of the new column. It shows at 0 like every other
|
||||
counter column.
|
||||
- No change to `nxdns export` / `import` ZON: compile statistics are not
|
||||
configuration.
|
||||
- No per-line diagnostics, no sample of skipped lines in logs, API or UI —
|
||||
counters and health surfaces, not log spam (AGENTS.md).
|
||||
- No change to `rejectedWithoutEntries` or `failNoValidEntries` semantics.
|
||||
- No hand edits to `contractSamples.gen.ts`.
|
||||
- No new columns beyond the one. See ruling 7 for what that leaves open and
|
||||
why — the reason is a scope decision, not a claim that the other counters
|
||||
are already visible.
|
||||
@@ -11,8 +11,8 @@
|
||||
//! Runtime columns are deliberately absent. `clients.first_seen`,
|
||||
//! `clients.last_seen`, `rules.created_at` and
|
||||
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
|
||||
//! exception_count, skipped_regex_count, checksum}` are facts a running server
|
||||
//! produces, not configuration. Including them would make two exports taken
|
||||
//! exception_count, skipped_regex_count, skipped_unsupported_count, checksum}`
|
||||
//! are facts a running server produces, not configuration. Including them would make two exports taken
|
||||
//! minutes apart differ, which would make the byte-stable round trip untestable
|
||||
//! against a live server.
|
||||
//!
|
||||
|
||||
@@ -1102,6 +1102,7 @@ fn seedSourceStats(database: *db.Db, id: i64) !void {
|
||||
.wildcard_count = 21,
|
||||
.exception_count = 9,
|
||||
.skipped_regex_count = 7,
|
||||
.skipped_unsupported_count = 33,
|
||||
.checksum = "a" ** 64,
|
||||
});
|
||||
}
|
||||
@@ -1171,6 +1172,8 @@ test "a source keeps its id, its checksum and its counters across a reconcile" {
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
|
||||
try testing.expectEqual(@as(i64, 4321), row.domain_count);
|
||||
try testing.expectEqual(@as(i64, 9), row.exception_count);
|
||||
try testing.expectEqual(@as(i64, 7), row.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 33), row.skipped_unsupported_count);
|
||||
try testing.expectEqualStrings("a" ** 64, row.checksum.?);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,27 @@ const http_domains: i64 = 2;
|
||||
const http_wildcards: i64 = 1;
|
||||
const http_exceptions: i64 = 0;
|
||||
const http_regex: i64 = 1;
|
||||
/// Zero, and asserted rather than assumed: a hosts list carries no line a DNS
|
||||
/// sinkhole cannot translate, which is what makes the abp fixture below a
|
||||
/// separate source instead of two more lines in this one.
|
||||
const http_unsupported: i64 = 0;
|
||||
|
||||
/// An ABP-format list: one element-hiding rule and one `$`-modifier rule that
|
||||
/// nxdns counts and skips, beside two names it blocks. `detectFormat` assigns
|
||||
/// one format to a whole source, so these lines cannot join `http_body` — a
|
||||
/// single `##` there would re-parse every hosts line as ABP.
|
||||
const abp_body =
|
||||
"! small abp list\n" ++
|
||||
"##.ad-banner\n" ++
|
||||
"||ads.example^$third-party\n" ++
|
||||
"||blocked.example^\n" ++
|
||||
"tracker.example\n";
|
||||
|
||||
/// `||blocked.example^` covers its own apex, so it writes one `.list` entry
|
||||
/// beside its `.wild` one; `tracker.example` writes the second.
|
||||
const abp_domains: i64 = 2;
|
||||
const abp_wildcards: i64 = 1;
|
||||
const abp_unsupported: i64 = 2;
|
||||
|
||||
/// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under
|
||||
/// `dir`, exactly as the manager's compile stage does, and returns the
|
||||
@@ -393,7 +414,7 @@ const Env = struct {
|
||||
// fixtures: the loopback http server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall };
|
||||
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };
|
||||
|
||||
/// How long the `stall` route holds a reply open when nothing releases it.
|
||||
///
|
||||
@@ -440,6 +461,11 @@ const oversize_length = "104857600";
|
||||
const HttpFixture = struct {
|
||||
server: net.Server,
|
||||
body: []const u8,
|
||||
/// What the `changed` route serves: the same list after its author edited
|
||||
/// it. A test sets this before the serving task starts and reaches it by
|
||||
/// switching the route, so the two bodies are read through the atomic that
|
||||
/// selects them and never written beside a request in flight.
|
||||
changed_body: []const u8,
|
||||
route: std.atomic.Value(u8),
|
||||
/// Connections accepted, whatever came over them. A test that claims a pass
|
||||
/// downloaded nothing reads this rather than the route counters: a refetch
|
||||
@@ -466,6 +492,7 @@ const HttpFixture = struct {
|
||||
return .{
|
||||
.server = try local.listen(io, .{ .reuse_address = true }),
|
||||
.body = body,
|
||||
.changed_body = "",
|
||||
.route = .init(@intFromEnum(Route.body)),
|
||||
.accepted = .init(0),
|
||||
.flushed_parts = .init(0),
|
||||
@@ -514,6 +541,7 @@ const HttpFixture = struct {
|
||||
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
|
||||
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
|
||||
.body => try request.respond(self.body, .{ .keep_alive = false }),
|
||||
.changed => try request.respond(self.changed_body, .{ .keep_alive = false }),
|
||||
.redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
|
||||
try request.respond(self.body, .{ .keep_alive = false })
|
||||
else
|
||||
@@ -824,6 +852,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = &bodyChecksum(good_list, good_wild, ""),
|
||||
});
|
||||
|
||||
@@ -856,6 +885,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = &bodyChecksum(unsorted_list, good_wild, ""),
|
||||
});
|
||||
|
||||
@@ -913,6 +943,7 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
|
||||
try testing.expectEqual(http_domains, row.domain_count);
|
||||
try testing.expectEqual(http_wildcards, row.wildcard_count);
|
||||
try testing.expectEqual(http_regex, row.skipped_regex_count);
|
||||
try testing.expectEqual(http_unsupported, row.skipped_unsupported_count);
|
||||
try testing.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len);
|
||||
try testing.expect(row.last_updated != null);
|
||||
|
||||
@@ -1073,6 +1104,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
|
||||
.wildcard_count = http_wildcards,
|
||||
.exception_count = http_exceptions,
|
||||
.skipped_regex_count = http_regex,
|
||||
.skipped_unsupported_count = http_unsupported,
|
||||
.checksum = blk: {
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
@@ -1143,6 +1175,7 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
|
||||
.wildcard_count = 0,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body, ""),
|
||||
});
|
||||
}
|
||||
@@ -1255,6 +1288,7 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
.wildcard_count = 0,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, "", ""),
|
||||
});
|
||||
|
||||
@@ -2156,6 +2190,7 @@ test "20: a data directory written before exceptions existed loads with no check
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = &legacy_checksum,
|
||||
});
|
||||
|
||||
@@ -2170,3 +2205,139 @@ test "20: a data directory written before exceptions existed loads with no check
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 21–22: the unsupported count, from the compile to the database and back
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "21: an abp list's unsupported lines are counted, persisted and read back" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, abp_body);
|
||||
defer fixture.deinit(io);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
const id = try seedSource(&env.database, url);
|
||||
|
||||
try testing.expect(try refreshOnce(env, url));
|
||||
try env.mgr.reload(io);
|
||||
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
const row = try rows.byUrl(url);
|
||||
try testing.expectEqual(abp_domains, row.domain_count);
|
||||
try testing.expectEqual(abp_wildcards, row.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
|
||||
try testing.expectEqual(abp_unsupported, row.skipped_unsupported_count);
|
||||
|
||||
const status = try env.status(id);
|
||||
try testing.expectEqual(manager.State.ok, status.state);
|
||||
try testing.expectEqual(@as(u32, @intCast(abp_unsupported)), status.counts.skipped_unsupported);
|
||||
|
||||
// What the number costs the operator: the `$`-modifier rule named a domain
|
||||
// and blocked nothing, while the two lines nxdns could translate did block.
|
||||
try testing.expect(!(try env.evaluate("ads.example"))[0].blocked);
|
||||
try testing.expect((try env.evaluate("blocked.example"))[0].blocked);
|
||||
try testing.expect((try env.evaluate("tracker.example"))[0].blocked);
|
||||
}
|
||||
|
||||
/// The same list before and after its author edited only lines nxdns skips.
|
||||
/// The written entries are identical in both, so the two compiles produce one
|
||||
/// checksum and the refresh takes the unchanged-checksum path.
|
||||
const churn_before =
|
||||
"! churn fixture\n" ++
|
||||
"##.ad-one\n" ++
|
||||
"||blocked.example^\n" ++
|
||||
"tracker.example\n";
|
||||
|
||||
const churn_after =
|
||||
"! churn fixture\n" ++
|
||||
"##.ad-one\n" ++
|
||||
"##.ad-two\n" ++
|
||||
"/ads[0-9]+/\n" ++
|
||||
"||blocked.example^\n" ++
|
||||
"tracker.example\n";
|
||||
|
||||
test "22: a list that changed only its skipped lines still updates both skip counters" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, churn_before);
|
||||
fixture.changed_body = churn_after;
|
||||
defer fixture.deinit(io);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
const id = try seedSource(&env.database, url);
|
||||
|
||||
try testing.expect(try refreshOnce(env, url));
|
||||
|
||||
var first_checksum: [64]u8 = undefined;
|
||||
{
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
const row = try rows.byUrl(url);
|
||||
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 1), row.skipped_unsupported_count);
|
||||
@memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
|
||||
}
|
||||
|
||||
// The edited list. Two more skipped lines and not one written entry moved,
|
||||
// so the refresh finds its stored checksum and rewrites nothing on disk.
|
||||
fixture.setRoute(.changed);
|
||||
try testing.expect(!try refreshOnce(env, url));
|
||||
|
||||
{
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
const row = try rows.byUrl(url);
|
||||
try testing.expectEqualStrings(&first_checksum, row.checksum orelse return error.TestNoChecksum);
|
||||
// The three written counts are the ones an unchanged checksum vouches
|
||||
// for; the two skip counts are the ones it says nothing about.
|
||||
try testing.expectEqual(abp_domains, row.domain_count);
|
||||
try testing.expectEqual(abp_wildcards, row.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 2), row.skipped_unsupported_count);
|
||||
}
|
||||
|
||||
const live = try env.status(id);
|
||||
try testing.expectEqual(@as(u32, 1), live.counts.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 2), live.counts.skipped_unsupported);
|
||||
|
||||
// The restart. A new manager over the same database and the same files
|
||||
// carries nothing across in memory, so the status it publishes is what
|
||||
// rehydration read out of the row — which is the only reason writing the
|
||||
// fresh counts above matters.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
&env.database,
|
||||
.{ .dir = env.tmp.dir },
|
||||
&env.f,
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.reload(io);
|
||||
|
||||
const restored = try env.status(id);
|
||||
try testing.expectEqual(manager.State.ok, restored.state);
|
||||
try testing.expect(restored.loaded);
|
||||
try testing.expectEqual(@as(u32, 1), restored.counts.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 2), restored.counts.skipped_unsupported);
|
||||
}
|
||||
|
||||
+19
-2
@@ -822,12 +822,21 @@ pub const Manager = struct {
|
||||
if (std.mem.eql(u8, stored, &compiled.result.checksum) and
|
||||
self.diskBodiesMatch(io, dir, row.id, stored))
|
||||
{
|
||||
// The three written counts come from the row: the checksum
|
||||
// covers the three bodies, so an unchanged checksum means an
|
||||
// unchanged number of entries in each. The two skip counts do
|
||||
// not: a skipped line lands in no body, so a list that changed
|
||||
// only its regex or browser-syntax lines arrives here with a
|
||||
// stale row and a fresh compile. Taking them from the row would
|
||||
// show the operator one number and restore another after a
|
||||
// restart.
|
||||
try sources_repo.updateSourceStats(self.database, row.id, .{
|
||||
.last_updated = now,
|
||||
.domain_count = row.domain_count,
|
||||
.wildcard_count = row.wildcard_count,
|
||||
.exception_count = row.exception_count,
|
||||
.skipped_regex_count = row.skipped_regex_count,
|
||||
.skipped_regex_count = compiled.result.counts.skipped_regex,
|
||||
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
|
||||
.checksum = stored,
|
||||
});
|
||||
status.succeed(now, compiled.result.counts);
|
||||
@@ -857,6 +866,7 @@ pub const Manager = struct {
|
||||
.wildcard_count = compiled.result.counts.wildcards,
|
||||
.exception_count = compiled.result.counts.exceptions,
|
||||
.skipped_regex_count = compiled.result.counts.skipped_regex,
|
||||
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
|
||||
.checksum = &compiled.result.checksum,
|
||||
});
|
||||
status.succeed(now, compiled.result.counts);
|
||||
@@ -1537,7 +1547,7 @@ fn applyLoadOutcomes(
|
||||
entry.loaded = true;
|
||||
// Two states survive a successful load. `.ok`, because a
|
||||
// refresh in this process already filled the counters the
|
||||
// compile produced and the three database columns are a subset
|
||||
// compile produced and the five database columns are a subset
|
||||
// of them. And any refresh failure, because the files that just
|
||||
// loaded are exactly the ones the failed refresh could not
|
||||
// replace, so the operator must still see why.
|
||||
@@ -1547,6 +1557,7 @@ fn applyLoadOutcomes(
|
||||
.wildcards = countOf(row.wildcard_count),
|
||||
.exceptions = countOf(row.exception_count),
|
||||
.skipped_regex = countOf(row.skipped_regex_count),
|
||||
.skipped_unsupported = countOf(row.skipped_unsupported_count),
|
||||
});
|
||||
},
|
||||
.failed => |reason| {
|
||||
@@ -1926,6 +1937,7 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
|
||||
.domain_count = 1,
|
||||
.wildcard_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.exception_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body, allow_body),
|
||||
});
|
||||
@@ -2049,6 +2061,7 @@ test "the log label names a source without printing what its url carries" {
|
||||
.domain_count = 0,
|
||||
.wildcard_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.skipped_unsupported_count = 0,
|
||||
.checksum = null,
|
||||
};
|
||||
const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{
|
||||
@@ -2223,6 +2236,7 @@ fn testRow(id: i64, enabled: bool) sources_repo.SourceRow {
|
||||
.wildcard_count = 4,
|
||||
.exception_count = 2,
|
||||
.skipped_regex_count = 1,
|
||||
.skipped_unsupported_count = 5,
|
||||
.checksum = "0" ** 64,
|
||||
};
|
||||
}
|
||||
@@ -2333,6 +2347,9 @@ test "a load of a source this process never refreshed takes the row counters" {
|
||||
try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains);
|
||||
try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards);
|
||||
try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex);
|
||||
// Rehydration: a restart reads this from the row and nowhere else, because
|
||||
// no path reparses a compiled file's header.
|
||||
try testing.expectEqual(@as(u32, 5), statuses[0].counts.skipped_unsupported);
|
||||
}
|
||||
|
||||
test "a status borrows nothing, so a copy outlives the table it came from" {
|
||||
|
||||
@@ -61,6 +61,7 @@ pub const ddl_v1: [:0]const u8 =
|
||||
\\ wildcard_count INTEGER NOT NULL DEFAULT 0,
|
||||
\\ exception_count INTEGER NOT NULL DEFAULT 0,
|
||||
\\ skipped_regex_count INTEGER NOT NULL DEFAULT 0,
|
||||
\\ skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
|
||||
\\ checksum TEXT
|
||||
\\);
|
||||
\\
|
||||
|
||||
@@ -271,6 +271,7 @@ test "a fresh database reaches the baseline with every v1 column and rule kind"
|
||||
try testing.expectEqual(@as(u32, 1), target_version);
|
||||
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
|
||||
try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
|
||||
try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));
|
||||
|
||||
try database.exec(
|
||||
\\INSERT INTO rules (group_id, pattern, kind, action, created_at)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! `blocklist_sources`.
|
||||
//!
|
||||
//! Only the four configuration columns are read and written. `last_updated`,
|
||||
//! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`
|
||||
//! and `checksum` are facts a running server produces; an insert leaves them at
|
||||
//! their column defaults so two exports taken minutes apart stay identical.
|
||||
//! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
|
||||
//! `skipped_unsupported_count` and `checksum` are facts a running server
|
||||
//! produces; an insert leaves them at their column defaults so two exports taken
|
||||
//! minutes apart stay identical.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count; the runtime columns and
|
||||
//! the REST surface follow it, both keyed by row id.
|
||||
@@ -93,6 +94,12 @@ pub const SourceRow = struct {
|
||||
/// builds `SourceRow` values from the refresh columns alone.
|
||||
exception_count: i64 = 0,
|
||||
skipped_regex_count: i64,
|
||||
/// Lines the compiler read and could not translate into a DNS decision:
|
||||
/// cosmetic element hiding, `$`-modifier rules (save the tolerated
|
||||
/// `$important` exception suffix, which lands in `exception_count`), scheme
|
||||
/// anchors. Counted and not written, like `skipped_regex_count` and unlike
|
||||
/// the three counts above.
|
||||
skipped_unsupported_count: i64,
|
||||
checksum: ?[]const u8,
|
||||
};
|
||||
|
||||
@@ -102,6 +109,7 @@ pub const SourceStats = struct {
|
||||
wildcard_count: i64,
|
||||
exception_count: i64,
|
||||
skipped_regex_count: i64,
|
||||
skipped_unsupported_count: i64,
|
||||
/// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then
|
||||
/// the `.allow` body. The allow body is hashed last so an empty one leaves
|
||||
/// the digest of a two-body compile unchanged, which is what keeps a
|
||||
@@ -112,7 +120,7 @@ pub const SourceStats = struct {
|
||||
const row_columns_sql =
|
||||
\\SELECT id, url, name, enabled, last_updated,
|
||||
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
|
||||
\\ is_suggested, exception_count
|
||||
\\ is_suggested, exception_count, skipped_unsupported_count
|
||||
\\ FROM blocklist_sources
|
||||
;
|
||||
|
||||
@@ -143,6 +151,7 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
|
||||
.wildcard_count = stmt.columnInt(6),
|
||||
.exception_count = stmt.columnInt(10),
|
||||
.skipped_regex_count = stmt.columnInt(7),
|
||||
.skipped_unsupported_count = stmt.columnInt(11),
|
||||
.checksum = checksum,
|
||||
};
|
||||
}
|
||||
@@ -159,7 +168,8 @@ pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
|
||||
const update_stats_sql =
|
||||
\\UPDATE blocklist_sources
|
||||
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
|
||||
\\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7
|
||||
\\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7,
|
||||
\\ skipped_unsupported_count = ?8
|
||||
\\ WHERE id = ?1
|
||||
;
|
||||
|
||||
@@ -175,6 +185,7 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
|
||||
try stmt.bindInt(5, stats.skipped_regex_count);
|
||||
try stmt.bindText(6, stats.checksum);
|
||||
try stmt.bindInt(7, stats.exception_count);
|
||||
try stmt.bindInt(8, stats.skipped_unsupported_count);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
@@ -315,8 +326,11 @@ test "insertBlocklistSource leaves the runtime columns at their defaults" {
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try database.queryInt(
|
||||
"SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count)" ++
|
||||
" FROM blocklist_sources",
|
||||
// Every runtime counter, summed only because this asserts they are
|
||||
// all 0. No production query may add the two skip counters to the
|
||||
// three written ones: a skipped line was never written.
|
||||
"SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count" ++
|
||||
" + skipped_unsupported_count) FROM blocklist_sources",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -370,6 +384,7 @@ test "listSourceRows returns row ids and the runtime columns in url order" {
|
||||
try testing.expectEqual(@as(i64, 0), row.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.exception_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.skipped_unsupported_count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +404,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
|
||||
.wildcard_count = 21,
|
||||
.exception_count = 9,
|
||||
.skipped_regex_count = 7,
|
||||
.skipped_unsupported_count = 15,
|
||||
.checksum = "a" ** 64,
|
||||
});
|
||||
|
||||
@@ -406,6 +422,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
|
||||
try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 9), updated.exception_count);
|
||||
try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 15), updated.skipped_unsupported_count);
|
||||
try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
|
||||
|
||||
// The two untouched rows kept their defaults.
|
||||
@@ -423,6 +440,7 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
|
||||
.wildcard_count = 3,
|
||||
.exception_count = 5,
|
||||
.skipped_regex_count = 4,
|
||||
.skipped_unsupported_count = 6,
|
||||
.checksum = "b" ** 64,
|
||||
});
|
||||
|
||||
@@ -489,6 +507,7 @@ test "updateSource leaves the runtime columns where the refresh path left them"
|
||||
.wildcard_count = 3,
|
||||
.exception_count = 2,
|
||||
.skipped_regex_count = 1,
|
||||
.skipped_unsupported_count = 4,
|
||||
.checksum = "c" ** 64,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ pub const StatusView = struct {
|
||||
wildcards: u32,
|
||||
exceptions: u32,
|
||||
skipped_regex: u32,
|
||||
skipped_unsupported: u32,
|
||||
|
||||
pub fn from(status: *const manager_mod.SourceStatus) StatusView {
|
||||
return .{
|
||||
@@ -75,6 +76,7 @@ pub const StatusView = struct {
|
||||
.wildcards = status.counts.wildcards,
|
||||
.exceptions = status.counts.exceptions,
|
||||
.skipped_regex = status.counts.skipped_regex,
|
||||
.skipped_unsupported = status.counts.skipped_unsupported,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -321,6 +323,7 @@ test "editing a blocklist keeps the counters the refresh wrote" {
|
||||
.wildcard_count = 3,
|
||||
.exception_count = 2,
|
||||
.skipped_regex_count = 1,
|
||||
.skipped_unsupported_count = 8,
|
||||
.checksum = "abc",
|
||||
});
|
||||
|
||||
@@ -335,6 +338,8 @@ test "editing a blocklist keeps the counters the refresh wrote" {
|
||||
try testing.expectEqualStrings("renamed", row.name);
|
||||
try testing.expect(!row.enabled);
|
||||
try testing.expectEqual(@as(i64, 42), row.domain_count);
|
||||
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
|
||||
try testing.expectEqual(@as(i64, 8), row.skipped_unsupported_count);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
@@ -383,7 +388,13 @@ test "a status becomes the flat shape the API answers with" {
|
||||
const message = "connection refused";
|
||||
@memcpy(status.last_error[0..message.len], message);
|
||||
status.last_error_len = message.len;
|
||||
status.counts = .{ .domains = 10, .wildcards = 2, .exceptions = 4, .skipped_regex = 1 };
|
||||
status.counts = .{
|
||||
.domains = 10,
|
||||
.wildcards = 2,
|
||||
.exceptions = 4,
|
||||
.skipped_regex = 1,
|
||||
.skipped_unsupported = 6,
|
||||
};
|
||||
|
||||
const view: StatusView = .from(&status);
|
||||
try testing.expectEqual(@as(i64, 7), view.id);
|
||||
@@ -393,4 +404,6 @@ test "a status becomes the flat shape the API answers with" {
|
||||
try testing.expectEqualStrings(message, view.last_error);
|
||||
try testing.expectEqual(@as(u32, 10), view.domains);
|
||||
try testing.expectEqual(@as(u32, 4), view.exceptions);
|
||||
try testing.expectEqual(@as(u32, 1), view.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 6), view.skipped_unsupported);
|
||||
}
|
||||
|
||||
@@ -1876,7 +1876,7 @@ components:
|
||||
|
||||
Blocklist:
|
||||
type: object
|
||||
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, checksum]
|
||||
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
url: { type: string }
|
||||
@@ -1890,6 +1890,7 @@ components:
|
||||
wildcard_count: { type: integer }
|
||||
exception_count: { type: integer }
|
||||
skipped_regex_count: { type: integer }
|
||||
skipped_unsupported_count: { type: integer }
|
||||
checksum:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1919,7 +1920,7 @@ components:
|
||||
|
||||
SourceStatus:
|
||||
type: object
|
||||
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex]
|
||||
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
state:
|
||||
@@ -1936,6 +1937,7 @@ components:
|
||||
wildcards: { type: integer }
|
||||
exceptions: { type: integer }
|
||||
skipped_regex: { type: integer }
|
||||
skipped_unsupported: { type: integer }
|
||||
|
||||
Rule:
|
||||
type: object
|
||||
|
||||
@@ -19,6 +19,7 @@ const BLOCKLISTS = {
|
||||
wildcard_count: 10,
|
||||
exception_count: 7,
|
||||
skipped_regex_count: 3,
|
||||
skipped_unsupported_count: 21,
|
||||
checksum: "abc",
|
||||
},
|
||||
{
|
||||
@@ -32,6 +33,7 @@ const BLOCKLISTS = {
|
||||
wildcard_count: 0,
|
||||
exception_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
@@ -102,6 +104,7 @@ const SNAPSHOT = {
|
||||
wildcards: 12,
|
||||
exceptions: 9,
|
||||
skipped_regex: 4,
|
||||
skipped_unsupported: 17,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -117,7 +120,13 @@ test("renders the source table and the status empty state", async () => {
|
||||
expect(screen.getByText("10")).toBeTruthy();
|
||||
expect(screen.getByText("7")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(screen.getByText("21")).toBeTruthy();
|
||||
expect(screen.getByText("never")).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||
).toBeTruthy();
|
||||
|
||||
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
|
||||
expect(enabledToggle.checked).toBe(true);
|
||||
@@ -153,6 +162,7 @@ test("update now disables the button, then replaces the status section from the
|
||||
wildcards: 12,
|
||||
exceptions: 9,
|
||||
skipped_regex: 4,
|
||||
skipped_unsupported: 17,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -166,6 +176,7 @@ test("update now disables the button, then replaces the status section from the
|
||||
wildcards: 0,
|
||||
exceptions: 0,
|
||||
skipped_regex: 0,
|
||||
skipped_unsupported: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -180,6 +191,8 @@ test("update now disables the button, then replaces the status section from the
|
||||
expect(screen.getByText("12")).toBeTruthy();
|
||||
expect(screen.getByText("9")).toBeTruthy();
|
||||
expect(screen.getByText("4")).toBeTruthy();
|
||||
expect(screen.getByText("17")).toBeTruthy();
|
||||
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
|
||||
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||
// The store notifies one flush before the mutation's success state lands.
|
||||
await screen.findByText(/Update completed/);
|
||||
|
||||
@@ -42,6 +42,10 @@ const styles = stylex.create({
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
note: {
|
||||
marginTop: "0.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
@@ -155,6 +159,7 @@ export default function BlocklistsPage() {
|
||||
<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)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
@@ -188,6 +193,9 @@ export default function BlocklistsPage() {
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{b.skipped_unsupported_count}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
@@ -221,6 +229,13 @@ export default function BlocklistsPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Both “Skipped” columns count 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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={tableError} />
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
|
||||
<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 error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -112,6 +113,9 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{source.skipped_unsupported}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{source.last_error === "" ? (
|
||||
<span {...stylex.props(styles.absent)}>—</span>
|
||||
|
||||
@@ -93,6 +93,7 @@ export const sample_list_blocklists: { blocklists: Blocklist[] } = {
|
||||
last_updated: null,
|
||||
name: "ads",
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcard_count: 0,
|
||||
},
|
||||
@@ -118,6 +119,7 @@ export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
|
||||
last_success: 0,
|
||||
loaded: false,
|
||||
skipped_regex: 0,
|
||||
skipped_unsupported: 0,
|
||||
state: "never_fetched",
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcards: 0,
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface Blocklist {
|
||||
wildcard_count: number;
|
||||
exception_count: number;
|
||||
skipped_regex_count: number;
|
||||
skipped_unsupported_count: number;
|
||||
checksum: string | null;
|
||||
}
|
||||
|
||||
@@ -192,6 +193,7 @@ export interface SourceStatus {
|
||||
wildcards: number;
|
||||
exceptions: number;
|
||||
skipped_regex: number;
|
||||
skipped_unsupported: number;
|
||||
}
|
||||
|
||||
export type RuleKind = "exact" | "wildcard" | "regex";
|
||||
|
||||
Reference in New Issue
Block a user