milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s

This commit is contained in:
2026-08-22 16:45:15 +02:00
parent 17422fac21
commit 648d9b4496
89 changed files with 7222 additions and 4239 deletions
+289 -131
View File
@@ -65,8 +65,11 @@ paths:
get:
summary: Health rollup
description: |
Always 200; `status` is `degraded` when the disk is not ok, no
upstream is available, or the query-log writer failed. Always
Always 200. `status` is `degraded` when, and only when, one of the five
condition objects is in a degrading state: protection `unavailable`,
upstreams `unavailable`, query history `losing` or `failed`,
diagnostics `unavailable`, or disk `low` or `critical`. A paused
protection is an operator's own choice and does not degrade. Always
unauthenticated and never rate limited.
security: []
responses:
@@ -465,6 +468,99 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/types:
get:
summary: Query-type breakdown for a period
description: |
How many queries of each DNS type the period's window holds, over the
same UTC-aligned window `/api/stats` reports for. Rows carry the numeric
type only: the type-name table lives in the admin, and a second copy
here would drift out of agreement with it. `qtype` is nullable in the
query log, so the rows that carry no type group into a row of their own
rather than vanishing from a breakdown that claims to add up. Ordered by
count descending, then type ascending with the null row last. Types
absent from the window are absent from the list.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The type breakdown.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsTypes"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/routes:
get:
summary: How the period's queries were answered
description: |
A breakdown by answering route over the same window `/api/stats`
reports for. `source` is the answering resolver's identity — the
upstream url on `upstream` rows, the zone on `forward_zone` rows, null
on every other kind and on rows whose identity the log did not record.
It is not the blocklist a block came from. Ordered by count descending,
then route ascending, then source ascending with nulls last.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The route breakdown.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsRoutes"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/clients:
get:
summary: Per-client bucketed counts for a period
description: |
One zero-filled series per client, bucketed exactly like
`/api/stats/timeseries` so the two charts share an x-axis. The eight
clients with the most queries in the window are named, ranked by count
descending then address ascending; every other client sums into
`other`, which is always present and always holds one entry per bucket
in the window — including when `clients` is empty, when no client fell
outside the named eight, and when the window holds no queries at all.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The per-client series.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsClients"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/lookup:
get:
summary: Explain a domain
@@ -498,35 +594,6 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/upstream/health:
get:
summary: Upstream pool health for a period
description: |
Each upstream's live routing state beside its recorded outcomes over
the period's window, which is the same UTC-aligned window `/api/stats`
reports for that period. The outcome counts come from per-minute
history in the query log, not from process-lifetime counters, so they
scope to the period and survive a restart.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: Per-upstream state and the availability rollup.
content:
application/json:
schema:
$ref: "#/components/schemas/UpstreamHealth"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/groups:
get:
summary: List groups
@@ -1822,45 +1889,100 @@ components:
Health:
type: object
required: [status, disk, upstreams, diagnostics, queries_dropped, writer_failed, refreshes_gated, snapshot_generation]
required: [status, protection, upstreams, query_history, diagnostics, disk]
properties:
status:
type: string
enum: [ok, degraded]
diagnostics:
type: object
required: [state, active_warnings, active_errors]
properties:
state:
type: string
enum: [recording, unavailable]
description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
active_warnings: { type: integer }
active_errors: { type: integer }
disk:
type: object
required: [state, free_bytes, db_bytes, log_bytes, sample_failures]
properties:
state:
type: string
enum: [ok, warn, critical]
free_bytes: { type: integer }
db_bytes: { type: integer }
log_bytes: { type: integer }
sample_failures: { type: integer }
protection:
$ref: "#/components/schemas/HealthProtection"
upstreams:
type: object
required: [available, total]
properties:
available: { type: integer }
total: { type: integer }
queries_dropped: { type: integer }
writer_failed: { type: boolean }
refreshes_gated: { type: integer }
snapshot_generation:
$ref: "#/components/schemas/HealthUpstreams"
query_history:
$ref: "#/components/schemas/HealthQueryHistory"
diagnostics:
$ref: "#/components/schemas/HealthDiagnostics"
disk:
$ref: "#/components/schemas/HealthDisk"
HealthProtection:
type: object
required: [state, until]
properties:
state:
type: string
enum: [active, paused, unavailable]
description: >
unavailable when no filter snapshot exists for the query path to
evaluate against, which outranks any pause and is the only one of
the three that degrades health. An expired timed pause is active.
until:
type: integer
nullable: true
description: Null until the first filter snapshot is published.
description: >
The second filtering resumes at. Null for an indefinite pause and
for every state other than paused.
HealthUpstreams:
type: object
required: [state, available, total]
properties:
state:
type: string
enum: [ok, unavailable]
description: unavailable exactly when `available` is 0; that degrades health.
available: { type: integer }
total:
type: integer
description: Enabled upstreams, which is what the routing pool is built from.
HealthQueryHistory:
type: object
required: [state, dropped_total, last_drop_s]
properties:
state:
type: string
enum: [recording, losing, failed]
description: >
failed when the query-log writer never started; losing while the
disk gate is holding writes back and has already cost rows in the
episode open now. Both degrade health. Drops from an earlier
episode do not change the state - they are reported by the two
fields below.
dropped_total:
type: integer
description: Query rows lost since this process started, cumulative.
last_drop_s:
type: integer
nullable: true
description: >
The newest drop, unix seconds; null until one happens. Stamped by a
separate atomic from the count, so a non-zero `dropped_total` beside
a null here is a legal momentary answer.
HealthDiagnostics:
type: object
required: [state, active_warnings, active_errors]
properties:
state:
type: string
enum: [recording, unavailable]
description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
active_warnings: { type: integer }
active_errors: { type: integer }
HealthDisk:
type: object
required: [state, free_bytes]
properties:
state:
type: string
enum: [ok, low, critical]
description: >
The disk monitor's own states; its `warn` is renamed `low` here,
because `warn` reads as a log level rather than as a quantity of
disk. Both `low` and `critical` degrade health.
free_bytes: { type: integer }
Version:
type: object
@@ -2119,6 +2241,9 @@ components:
- query_log.write
- query_log.maintenance
- query_log.recreated
# Legacy: nothing emits this any more (milestone 30 deleted the
# upstream-minute history subsystem), but stored rows survive and
# the list endpoint passes their code through.
- upstream_history.write
- upstream.exchange
- client_names.storage
@@ -2187,7 +2312,7 @@ components:
StatsTotals:
type: object
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us, coverage]
required: [period, since, until, queries, blocked, clients, avg_response_time_us, coverage]
properties:
period:
type: string
@@ -2200,7 +2325,6 @@ components:
description: Window end, unix seconds, exclusive.
queries: { type: integer }
blocked: { type: integer }
cached: { type: integer }
clients:
type: integer
description: Distinct client addresses in the window.
@@ -2239,6 +2363,106 @@ components:
coverage:
$ref: "#/components/schemas/Coverage"
TypeCount:
type: object
required: [qtype, count]
properties:
qtype:
type: integer
nullable: true
description: |
The numeric DNS type. Null is the group of logged queries that
recorded no type, not an absent row.
count: { type: integer }
StatsTypes:
type: object
required: [period, since, until, types, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
types:
type: array
items:
$ref: "#/components/schemas/TypeCount"
coverage:
$ref: "#/components/schemas/Coverage"
RouteCount:
type: object
required: [route, source, count]
properties:
route:
$ref: "#/components/schemas/RouteKind"
source:
type: string
nullable: true
description: |
The answering upstream's url or the forward zone, and null on every
other route kind. Also null when an `upstream` or `forward_zone`
row recorded no identity.
count: { type: integer }
StatsRoutes:
type: object
required: [period, since, until, routes, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
routes:
type: array
items:
$ref: "#/components/schemas/RouteCount"
coverage:
$ref: "#/components/schemas/Coverage"
ClientSeries:
type: object
required: [client, buckets]
properties:
client:
type: string
description: The client address as the log recorded it, redaction included.
buckets:
type: array
description: |
One count per bucket in the window, zero-filled. Every series in a
response has this same length, `other` included.
items:
type: integer
StatsClients:
type: object
required: [period, since, until, bucket_seconds, clients, other, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
bucket_seconds: { type: integer }
clients:
type: array
items:
$ref: "#/components/schemas/ClientSeries"
other:
type: array
description: |
Every client outside the named eight, summed per bucket. Always
present, and always one entry per bucket in the window — including
when `clients` is empty, when no client fell outside the named
eight, and when the window holds no queries at all.
items:
type: integer
coverage:
$ref: "#/components/schemas/Coverage"
Lookup:
type: object
required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite]
@@ -2267,72 +2491,6 @@ components:
type: string
nullable: true
UpstreamPeriodStats:
type: object
required: [attempts, successes, failures, success_rate, last_failure_at, last_failure_error]
properties:
attempts:
type: integer
description: Exchanges recorded against this upstream inside the window.
successes: { type: integer }
failures: { type: integer }
success_rate:
type: number
nullable: true
description: >
`successes / attempts`, from 0 to 1. Null when `attempts` is 0: no
observations is not perfect reliability.
last_failure_at:
type: integer
nullable: true
description: >
The newest failure inside the window, unix seconds. Null when the
window holds no failure, even if the upstream failed before it.
last_failure_error:
type: string
nullable: true
description: The error name belonging to `last_failure_at`; null exactly when it is.
UpstreamHealth:
type: object
required: [period, since, until, available, total, complete, upstreams]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since:
type: integer
description: Window start, unix seconds, inclusive.
until:
type: integer
description: Window end, unix seconds, exclusive.
available:
type: integer
description: How many upstreams the pool would route to right now.
total: { type: integer }
complete:
type: boolean
description: >
No capacity drops known in this process within the selected window;
up to about a minute of the newest outcomes may not have flushed
yet, and outcomes lost in an unclean shutdown are not detectable.
upstreams:
type: array
description: The upstreams configured now; a deleted upstream's history is not returned.
items:
type: object
required: [url, enabled, available, period]
properties:
url: { type: string }
enabled:
type: boolean
description: Live configuration, not history.
available:
type: boolean
description: Live state, not history; false while the upstream is backing off.
period:
$ref: "#/components/schemas/UpstreamPeriodStats"
Group:
type: object
required: [id, name, safe_search]