diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index bdb5631..3b8eb2b 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -95,6 +95,12 @@ func (s *Server) handleBlocklistRefresh(w http.ResponseWriter, r *http.Request)
return
}
}
+ if in.Names == nil {
+ // A nil slice marshals to null, and the console counts the names it
+ // gets back to build its toast — so "refresh everything" reported
+ // "0 sources". The key set must not depend on the request.
+ in.Names = []string{}
+ }
if err := s.hooks.RefreshBlocklists(r.Context(), in.Names); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go
index 0332379..ff13996 100644
--- a/internal/app/contract_test.go
+++ b/internal/app/contract_test.go
@@ -1,7 +1,9 @@
package app
import (
+ "bufio"
"encoding/json"
+ "io"
"net/http"
"sort"
"strconv"
@@ -429,3 +431,156 @@ func TestConsoleWritePayloadsAreAccepted(t *testing.T) {
}
})
}
+
+// TestRemainingEndpointKeySets covers the endpoints the first pass left out:
+// the domain check (which has three distinct shapes, one of them an error
+// delivered as 200), the log clear, the settings writes and the first SSE
+// frame. A key set the console reads but no test pins is a field that works
+// today and breaks the console the first time somebody tidies a struct.
+func TestRemainingEndpointKeySets(t *testing.T) {
+ h := newHarness(t, nil)
+
+ t.Run("domain check miss", func(t *testing.T) {
+ code, body := h.get("/api/domains/check?name=nothing.example.test")
+ if code != http.StatusOK {
+ t.Fatalf("GET /api/domains/check = %d: %s", code, body)
+ }
+ requireKeys(t, "GET /api/domains/check (no match)", keysOf(t, body),
+ wantKeys("action", "matched", "name", "query", "source"))
+ })
+
+ t.Run("domain check match", func(t *testing.T) {
+ code, body := h.get("/api/domains/check?name=ads.example.com")
+ if code != http.StatusOK {
+ t.Fatalf("GET /api/domains/check = %d: %s", code, body)
+ }
+ requireKeys(t, "GET /api/domains/check (match)", keysOf(t, body), wantKeys(
+ "action", "categories", "hits", "match_kind", "matched",
+ "matched_pattern", "name", "query", "rule", "source",
+ ))
+ })
+
+ t.Run("logs clear", func(t *testing.T) {
+ code, body := h.do(http.MethodPost, "/api/logs/clear", "")
+ if code != http.StatusOK {
+ t.Fatalf("POST /api/logs/clear = %d: %s", code, body)
+ }
+ requireKeys(t, "POST /api/logs/clear", keysOf(t, body), wantKeys("ok"))
+ })
+
+ t.Run("settings validate", func(t *testing.T) {
+ // A document that is valid: the issue lists are present and empty.
+ code, body := h.do(http.MethodPost, "/api/settings/validate",
+ `{"config_yaml":"listen: 127.0.0.1:5353\n"}`)
+ if code != http.StatusOK {
+ t.Fatalf("POST /api/settings/validate = %d: %s", code, body)
+ }
+ requireKeys(t, "POST /api/settings/validate", keysOf(t, body),
+ wantKeys("errors", "ok", "warnings"))
+ // A malformed document is still a 200 with a populated `errors`: the
+ // console renders the field list, so the envelope is the same.
+ code, body = h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml":"\t- :\n"}`)
+ if code != http.StatusOK {
+ t.Fatalf("POST /api/settings/validate (bad yaml) = %d: %s", code, body)
+ }
+ requireKeys(t, "POST /api/settings/validate (bad yaml)", keysOf(t, body),
+ wantKeys("errors", "ok", "warnings"))
+ var bad struct {
+ Errors []struct{ Field, Message string } `json:"errors"`
+ }
+ if err := json.Unmarshal(body, &bad); err != nil {
+ t.Fatal(err)
+ }
+ if len(bad.Errors) == 0 {
+ t.Errorf("a malformed document validated clean: %s", body)
+ }
+ })
+
+ t.Run("settings save and reload", func(t *testing.T) {
+ doc := h.OriginalConfig()
+ code, body := h.do(http.MethodPut, "/api/settings", `{"config_yaml":`+quote(doc)+`}`)
+ if code != http.StatusOK {
+ t.Fatalf("PUT /api/settings = %d: %s", code, body)
+ }
+ requireKeys(t, "PUT /api/settings", keysOf(t, body), wantKeys("ok", "version", "warnings"))
+ code, body = h.do(http.MethodPost, "/api/settings/reload", "")
+ if code != http.StatusOK {
+ t.Fatalf("POST /api/settings/reload = %d: %s", code, body)
+ }
+ requireKeys(t, "POST /api/settings/reload", keysOf(t, body), wantKeys("ok", "version"))
+ })
+
+ t.Run("log stream ready frame", func(t *testing.T) {
+ // The stream never ends, so read the first frame and hang up: the
+ // console only needs to know the stream opened and what it carries.
+ req, _ := http.NewRequest("GET", "http://"+h.api+"/api/logs/stream", nil)
+ req.Header.Set("X-API-Key", h.key)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET /api/logs/stream: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("GET /api/logs/stream = %d", res.StatusCode)
+ }
+ br := newLineReader(res.Body)
+ var event, data string
+ for range 2 {
+ line, err := br.readLine()
+ if err != nil {
+ t.Fatalf("read stream: %v", err)
+ }
+ switch {
+ case line == "":
+ case len(line) > 7 && line[:7] == "event: ":
+ event = line[7:]
+ case len(line) > 6 && line[:6] == "data: ":
+ data = line[6:]
+ }
+ if event != "" && data != "" {
+ break
+ }
+ }
+ if event != "ready" {
+ t.Fatalf("first event = %q, want ready", event)
+ }
+ requireKeys(t, "GET /api/logs/stream ready frame", keysOf(t, []byte(data)),
+ wantKeys("at", "version"))
+ })
+}
+
+// quote renders a Go string as a JSON string literal.
+func quote(s string) string {
+ b, err := json.Marshal(s)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+// lineReader reads \n-terminated lines off the SSE stream, which is what
+// bufio.Scanner would be for — except Scanner's default 64 KiB cap turns a long
+// data frame into "token too long" and the test would fail on payload size
+// rather than on the contract.
+type lineReader struct {
+ r *bufio.Reader
+ buf []byte
+}
+
+func newLineReader(r io.Reader) *lineReader {
+ return &lineReader{r: bufio.NewReader(r), buf: make([]byte, 0, 4096)}
+}
+
+func (l *lineReader) readLine() (string, error) {
+ l.buf = l.buf[:0]
+ for {
+ chunk, isPrefix, err := l.r.ReadLine()
+ if err != nil {
+ return "", err
+ }
+ l.buf = append(l.buf, chunk...)
+ if !isPrefix {
+ return string(l.buf), nil
+ }
+ }
+}
diff --git a/internal/blocklist/manager.go b/internal/blocklist/manager.go
index bc039ba..ce48a04 100644
--- a/internal/blocklist/manager.go
+++ b/internal/blocklist/manager.go
@@ -149,7 +149,7 @@ func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
}
b.NoteSource(SourceMeta{
Name: "inline:block", Format: string(FormatPlain), Rules: len(rules),
- Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
+ Bytes: st.Bytes, UpdatedAt: start, Duration: config.Duration(time.Since(start)), Enabled: true,
})
} else {
errs["inline:block"] = err.Error()
@@ -163,7 +163,7 @@ func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
}
b.NoteSource(SourceMeta{
Name: "inline:allow", Format: string(FormatPlain), Rules: len(rules),
- Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
+ Bytes: st.Bytes, UpdatedAt: start, Duration: config.Duration(time.Since(start)), Enabled: true,
})
} else {
errs["inline:allow"] = err.Error()
@@ -191,7 +191,7 @@ func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
}
b.NoteSource(SourceMeta{
Name: name, Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes,
- UpdatedAt: start, Duration: time.Since(start), Enabled: true, Path: pattern,
+ UpdatedAt: start, Duration: config.Duration(time.Since(start)), Enabled: true, Path: pattern,
})
}
@@ -371,7 +371,7 @@ func (m *Manager) loadOne(ctx context.Context, cfg *config.Config, l config.List
ETag: etag,
LastMod: lastMod,
UpdatedAt: start,
- Duration: time.Since(start),
+ Duration: config.Duration(time.Since(start)),
Enabled: true,
URL: l.URL,
Path: path,
diff --git a/internal/blocklist/set.go b/internal/blocklist/set.go
index 9c9215c..eea9ce1 100644
--- a/internal/blocklist/set.go
+++ b/internal/blocklist/set.go
@@ -16,6 +16,8 @@ import (
"strings"
"sync/atomic"
"time"
+
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Action is what to do with a matched name.
@@ -196,20 +198,24 @@ type RuleCounts struct {
// SourceMeta describes one contributing source.
type SourceMeta struct {
- Name string `json:"name"`
- Format string `json:"format"`
- Rules int `json:"rules"`
- Bytes int64 `json:"bytes"`
- ETag string `json:"etag"`
- LastMod string `json:"last_mod"`
- UpdatedAt time.Time `json:"updated_at"`
- Duration time.Duration `json:"duration"`
- Err string `json:"err"`
- Enabled bool `json:"enabled"`
- URL string `json:"url"`
- Path string `json:"path"`
- Stale bool `json:"stale"`
- HTTPStatus int `json:"http_status"`
+ Name string `json:"name"`
+ Format string `json:"format"`
+ Rules int `json:"rules"`
+ Bytes int64 `json:"bytes"`
+ ETag string `json:"etag"`
+ LastMod string `json:"last_mod"`
+ UpdatedAt time.Time `json:"updated_at"`
+ // Duration is a duration STRING on the wire, like every other duration the
+ // API serves: a raw time.Duration marshals as a nanosecond count, and the
+ // console was dividing by 1e6 to compensate for a number nobody else on
+ // the wire sends.
+ Duration config.Duration `json:"duration"`
+ Err string `json:"err"`
+ Enabled bool `json:"enabled"`
+ URL string `json:"url"`
+ Path string `json:"path"`
+ Stale bool `json:"stale"`
+ HTTPStatus int `json:"http_status"`
}
// Builder accumulates rules and produces a Set.
diff --git a/internal/web/dist/.gitkeep b/internal/web/dist/.gitkeep
index 1d40ad1..e69de29 100644
--- a/internal/web/dist/.gitkeep
+++ b/internal/web/dist/.gitkeep
@@ -1 +0,0 @@
-Build artifacts land here: run `make web` (npm ci && npm run build) then rebuild the binary.
diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html
index c4be9a9..59a9f5b 100644
--- a/internal/web/dist/index.html
+++ b/internal/web/dist/index.html
@@ -5,7 +5,7 @@
web
-
+
diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
index 5d504e6..1437e76 100644
--- a/web/src/components/layout/AppShell.tsx
+++ b/web/src/components/layout/AppShell.tsx
@@ -7,6 +7,7 @@ import {
Globe,
LayoutDashboard,
ListChecks,
+ Lock,
LogOut,
Menu,
Network,
@@ -18,9 +19,11 @@ import {
} from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { logout } from '../../lib/api.ts'
+import { useWhoami } from '../../lib/session.ts'
import type { Health } from '../../lib/types.ts'
import { HealthPill } from './HealthPill.tsx'
import { ThemeToggle } from './ThemeToggle.tsx'
+import { WhoamiProvider } from './WhoamiProvider.tsx'
import { Tooltip } from '../ui/Tooltip.tsx'
interface NavItem {
@@ -58,6 +61,8 @@ export function AppShell({
const [signingOut, setSigningOut] = useState(false)
const location = useLocation()
const navigate = useNavigate()
+ // The provider is the app's own, so the shell's header can read it too.
+ const { readOnly, identity } = useWhoami()
useEffect(() => {
const query = window.matchMedia(MOBILE_QUERY)
@@ -104,7 +109,8 @@ export function AppShell({
const rail = !isMobile && collapsed
return (
-
+
+
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
the hamburger that opens the sheet instead. */}
@@ -253,10 +259,30 @@ export function AppShell({
)}
>
+ {readOnly && (
+
+
+
+ Read-only session
+ {identity !== '' && (
+ <>
+ {' '}
+ as {identity}
+ >
+ )}
+ : this credential may read the node but not change it, so every write control
+ below is disabled. The daemon answers 403 on the write routes for this key.
+
+
+ )}
{children}
-
+
+
)
}
diff --git a/web/src/components/layout/WhoamiProvider.tsx b/web/src/components/layout/WhoamiProvider.tsx
new file mode 100644
index 0000000..d539380
--- /dev/null
+++ b/web/src/components/layout/WhoamiProvider.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react'
+import { useMemo } from 'react'
+
+import { whoami } from '../../lib/api.ts'
+import { WhoamiContext } from '../../lib/session.ts'
+import type { WhoamiState } from '../../lib/session.ts'
+import { useApi } from '../../hooks/useApi.ts'
+
+/**
+ * Fetches `/api/whoami` once for the whole console and shares it.
+ *
+ * The response is the only thing that tells the UI whether this credential may
+ * change anything: `read_only` on a key makes the API answer 403 on every
+ * write route, so a console that ignores it offers a Save button that can only
+ * ever fail. One provider, one request, every page reading the same answer.
+ */
+export function WhoamiProvider({ children }: { children: ReactNode }) {
+ const { data, error, loading, refetch } = useApi(() => whoami(), [])
+ const value = useMemo(
+ () => ({ who: data, error, loading, refetch }),
+ [data, error, loading, refetch],
+ )
+ return {children}
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index e227aa3..81c8021 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -7,30 +7,36 @@
*/
import type {
+ AddDomainInput,
+ AddDomainResponse,
BlocklistsResponse,
CacheResponse,
CacheTopResponse,
+ ClientsPayload,
ClientsResponse,
- ClientGroup,
Dashboard,
- DefaultPolicy,
- DomainAction,
- DomainCheck,
+ DomainCheckResponse,
DomainFilters,
DomainsResponse,
+ FlushCacheResponse,
+ LoginInput,
+ LoginResponse,
LogFilters,
LogItem,
- LoginResponse,
LogsResponse,
LiveStats,
OkResponse,
+ RefreshBlocklistsResponse,
+ ReloadSettingsResponse,
+ SaveResponse,
SaveSettingsResponse,
SettingsResponse,
- UpstreamBootstrap,
- UpstreamPolicy,
- UpstreamServer,
+ StreamReady,
+ ToggleBlocklistResponse,
+ UpstreamsPayload,
UpstreamsResponse,
ValidateResponse,
+ Whoami,
} from './types.ts'
const TOKEN_KEY = 'fancydns.token'
@@ -73,78 +79,10 @@ export function clearToken(): void {
}
}
-/** Types the API returns for endpoints the shared types.ts does not name. */
-export interface WhoamiResponse {
- name: string
- mode: string
- read_only: boolean
- can_write: boolean
- auth_mode: string
- base_path: string
-}
-
-export interface LoginInput {
- api_key?: string
- username?: string
- password?: string
-}
-
-export interface RefreshBlocklistsResponse {
- started: boolean
- names: string[]
-}
-
-export interface ToggleBlocklistResponse {
- ok: boolean
- name: string
- enabled: boolean
-}
-
-export interface AddDomainInput {
- name: string
- action: DomainAction | string
- list?: string
- source?: string
- content?: string
-}
-
-/** A lookup failure comes back 200 with an `error` string, not a 4xx. */
-export type DomainCheckResponse = DomainCheck & { error?: string }
-
-export interface ClientsPayload {
- groups: ClientGroup[]
- default_policy: DefaultPolicy | string
-}
-
-export interface UpstreamsPayload {
- policy: UpstreamPolicy | string
- servers: UpstreamServer[]
- bootstraps: UpstreamBootstrap[]
- healthcheck_interval_s?: number
- max_attempts?: number
- breaker_threshold?: number
-}
-
-export interface SaveResponse {
- ok: boolean
- version: string
- warnings?: string[]
-}
-
-export interface FlushCacheResponse {
- ok: boolean
- entries: number
-}
-
-export interface ReloadSettingsResponse {
- ok: boolean
- version: string
-}
-
export type LogStreamHandlers = {
onEntry: (entry: LogItem) => void
onStats: (stats: LiveStats) => void
- onReady: (info: { at?: string; version?: string }) => void
+ onReady: (info: StreamReady) => void
onError: (message: string) => void
onOpen: () => void
}
@@ -280,8 +218,8 @@ export async function logout(): Promise {
}
}
-export function whoami(): Promise {
- return apiFetch('/api/whoami')
+export function whoami(): Promise {
+ return apiFetch('/api/whoami')
}
export function getDashboard(range: string): Promise {
@@ -292,14 +230,16 @@ export function getBlocklists(): Promise {
return apiFetch('/api/blocklists')
}
-export async function refreshBlocklists(names?: string[]): Promise {
- if (names && names.length > 0) {
- return apiFetch('/api/blocklists/refresh', {
- method: 'POST',
- body: JSON.stringify({ names }),
- })
- }
- return apiFetch('/api/blocklists/refresh', { method: 'POST' })
+/**
+ * A refresh with no names recompiles every source. The body always carries the
+ * key: the daemon echoes back the list it queued, and an omitted one arrives as
+ * `null`, which is what made the toast count "0 sources queued".
+ */
+export async function refreshBlocklists(names: string[] = []): Promise {
+ return apiFetch('/api/blocklists/refresh', {
+ method: 'POST',
+ body: JSON.stringify({ names }),
+ })
}
export function toggleBlocklist(
@@ -316,8 +256,8 @@ export function getDomains(filters: DomainFilters = {}): Promise(apiUrl('/api/domains', { ...filters }))
}
-export function addDomain(input: AddDomainInput): Promise {
- return apiFetch('/api/domains', {
+export function addDomain(input: AddDomainInput): Promise {
+ return apiFetch('/api/domains', {
method: 'POST',
body: JSON.stringify(input),
})
@@ -526,7 +466,7 @@ async function readSse(
} else if (event === 'stats') {
handlers.onStats(payload as LiveStats)
} else if (event === 'ready') {
- handlers.onReady(payload as { at?: string; version?: string })
+ handlers.onReady(payload as StreamReady)
}
}
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
index 93c3a04..580e3be 100644
--- a/web/src/lib/format.ts
+++ b/web/src/lib/format.ts
@@ -23,16 +23,39 @@ export function formatNumber(value: number | null | undefined): string {
return n.toLocaleString('en-US')
}
-/** 0.9432 -> "94.3%". Accepts 0..1 (fractions) and 0..100 (percents). */
-export function formatPercent(fraction: number | null | undefined, digits = 1): string {
- const n = typeof fraction === 'number' && Number.isFinite(fraction) ? fraction : 0
- const scaled = n > 1 ? n : n * 100
- return `${scaled.toFixed(digits)}%`
+/**
+ * 0.9432 -> "94.3%": a value the server already scaled to 0..100, like
+ * `blocked_pct` in the dashboard payload. Do not hand it a 0..1 fraction.
+ */
+export function formatPercent(points: number | null | undefined, digits = 1): string {
+ const n = typeof points === 'number' && Number.isFinite(points) ? points : 0
+ return `${n.toFixed(digits)}%`
}
-/** 0.043 -> "4.3%" — for rates the API reports as 0..1. */
-export function formatRate(fraction: number | null | undefined, digits = 1): string {
- return formatPercent(fraction, digits)
+/**
+ * 0.043 -> "4.3%": a 0..1 fraction, which is how the API reports rates
+ * (`cache_hit_rate`, `fail_rate`) and `blocked_pct` in the SSE stats frame.
+ * The same key is 0..100 in the dashboard payload, so the unit has to be
+ * chosen per call site rather than guessed from the value.
+ */
+export function formatFraction(fraction: number | null | undefined, digits = 1): string {
+ const n = typeof fraction === 'number' && Number.isFinite(fraction) ? fraction : 0
+ return formatPercent(n * 100, digits)
+}
+
+/**
+ * A duration STRING from the API ("1.5s", "24h0m0s", "0s") -> a compact
+ * human form. The Go side marshals every duration that way, so this is the
+ * only duration form that should appear in a response.
+ */
+export function formatDuration(value: string | null | undefined): string {
+ if (!value) return '—'
+ const m = /^(-?\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)(.*)$/.exec(value.trim())
+ if (!m) return value
+ const n = Number.parseFloat(m[1])
+ if (!Number.isFinite(n)) return value
+ if (n === 0) return '0 ms'
+ return formatMs(m[2] === 'ns' || m[2] === 'us' || m[2] === 'µs' ? n / 1e6 : n * 1000)
}
/** Milliseconds: 0.42 -> "420 µs", 12.5 -> "12.5 ms", 2400 -> "2.40 s". */
@@ -58,7 +81,14 @@ export function formatUptime(seconds: number | null | undefined): string {
return `${secs}s`
}
-/** Parses RFC3339 strings and epoch numbers (seconds or milliseconds). */
+/**
+ * Parses RFC3339 strings and epoch numbers (seconds or milliseconds).
+ *
+ * A Go zero time ("0001-01-01T00:00:00Z") is what an unwritten timestamp
+ * looks like on the wire — `cache.persisted.last_snapshot` before the first
+ * snapshot, for one — and is not a date, so it reads as "never" rather than
+ * as a date two thousand years ago.
+ */
export function toDate(value: string | number | null | undefined): Date | null {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') {
@@ -68,7 +98,9 @@ export function toDate(value: string | number | null | undefined): Date | null {
return Number.isNaN(d.getTime()) ? null : d
}
const d = new Date(value)
- return Number.isNaN(d.getTime()) ? null : d
+ if (Number.isNaN(d.getTime())) return null
+ if (d.getUTCFullYear() <= 1) return null
+ return d
}
/** "3 minutes ago" / "in 2 hours". */
@@ -131,6 +163,64 @@ export function formatRcode(rcode: number | string | null | undefined): string {
return `${code} ${RCODE_NAMES[code] ?? 'UNKNOWN'}`
}
+/** DNS qtype number -> short name, e.g. 1 -> "A". */
+export const QTYPE_NAMES: Record = {
+ 1: 'A',
+ 2: 'NS',
+ 3: 'MD',
+ 4: 'MF',
+ 5: 'CNAME',
+ 6: 'SOA',
+ 7: 'MB',
+ 8: 'MG',
+ 9: 'MR',
+ 10: 'NULL',
+ 12: 'PTR',
+ 13: 'HINFO',
+ 15: 'MX',
+ 16: 'TXT',
+ 17: 'RP',
+ 18: 'AFSDB',
+ 24: 'SIG',
+ 25: 'KEY',
+ 28: 'AAAA',
+ 29: 'LOC',
+ 33: 'SRV',
+ 35: 'NAPTR',
+ 39: 'DNAME',
+ 41: 'OPT',
+ 43: 'DS',
+ 44: 'SSHFP',
+ 46: 'RRSIG',
+ 47: 'NSEC',
+ 48: 'DNSKEY',
+ 50: 'NSEC3',
+ 51: 'NSEC3PARAM',
+ 52: 'TLSA',
+ 59: 'CDS',
+ 64: 'SVCB',
+ 65: 'HTTPS',
+ 99: 'SPF',
+ 250: 'TSIG',
+ 251: 'IXFR',
+ 252: 'AXFR',
+ 255: 'ANY',
+ 257: 'CAA',
+}
+
+/**
+ * The qtype of a log row or a cached name: a NUMBER on the wire, with the
+ * server's own spelling in `name`. Rendering the number alone shows "1" where
+ * the operator expects "A", so prefer the name and fall back to the number.
+ */
+export function formatQtype(qtype: number | null | undefined, name?: string | null): string {
+ if (typeof name === 'string' && name !== '') return name
+ if (typeof qtype === 'number' && Number.isFinite(qtype)) {
+ return QTYPE_NAMES[qtype] ?? `TYPE${qtype}`
+ }
+ return '—'
+}
+
export const RCODE_NAMES: Record = {
0: 'NOERROR',
1: 'FORMERR',
diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts
new file mode 100644
index 0000000..5a2dc9d
--- /dev/null
+++ b/web/src/lib/session.ts
@@ -0,0 +1,61 @@
+import { createContext, useContext } from 'react'
+
+import type { Whoami } from './types.ts'
+
+export interface WhoamiState {
+ who: Whoami | null
+ error: string | null
+ loading: boolean
+ refetch: () => void
+}
+
+const noop = (): void => undefined
+
+/**
+ * Filled in by once per console. A consumer rendered outside
+ * it (the login page) sees the defaults, which read as "not known yet".
+ */
+export const WhoamiContext = createContext({
+ who: null,
+ error: null,
+ loading: false,
+ refetch: noop,
+})
+
+export interface Session {
+ who: Whoami | null
+ /** The credential is a read-only key: the API answers 403 on every write. */
+ readOnly: boolean
+ /** False means "do not offer a write control", not "hide the page". */
+ canWrite: boolean
+ /** The key's or user's name, for the read-only banner. */
+ identity: string
+ error: string | null
+ loading: boolean
+ refetch: () => void
+}
+
+/**
+ * What `/api/whoami` said about the caller.
+ *
+ * The two flags are belt and braces — `read_only` is the property of the
+ * credential and `can_write` is the server's own answer to "may this session
+ * mutate anything" — so either one being true means read-only.
+ *
+ * A failed or absent probe reports `canWrite: true`: the session is then
+ * unknown rather than read-only, and a write that the server refuses comes
+ * back as an error toast, which is a better failure than a console that
+ * hides every control because a probe timed out.
+ */
+export function useWhoami(): Session {
+ const { who, error, loading, refetch } = useContext(WhoamiContext)
+ return {
+ who,
+ readOnly: who?.read_only === true || who?.can_write === false,
+ canWrite: who === null || (who.can_write && !who.read_only),
+ identity: who?.name ?? '',
+ error,
+ loading,
+ refetch,
+ }
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 7b6574f..76b3619 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -1,15 +1,61 @@
-/** API shapes, mirroring the Go JSON tags exactly. */
+/**
+ * API shapes, mirroring the Go JSON tags exactly.
+ *
+ * The server is the authority here. `internal/app/contract_test.go` asserts the
+ * exact top-level key set of every admin endpoint against a fully wired app, so
+ * a rename on the Go side fails there with a readable diff; `DUMP_SHAPES=1 go
+ * test ./internal/app -run TestDumpShapes` prints the real payloads to diff
+ * against this file. Changing a field name or type below therefore means
+ * changing that test too — never adding a cast at a call site to quiet the
+ * typechecker. A type that does not match the wire is worse than a missing one:
+ * the missing one fails the build, the wrong one ships an empty table.
+ *
+ * Two rules the wire forces on this file:
+ *
+ * - a field is optional only where the Go side omits it (an `omitempty` tag);
+ * - a field whose Go type is a slice is `T[] | null`, because a nil slice
+ * marshals to `null` and not to `[]`.
+ */
+/** How a whole node is doing; the dashboard's health pill. */
export type Health = 'ok' | 'degraded' | 'down'
+/** One upstream server's circuit-breaker view. */
export type UpstreamHealth = 'up' | 'down' | 'degraded'
-export type LogAction = 'allow' | 'block' | 'cached' | 'error' | 'drop'
-export type DomainAction = 'allow' | 'block'
-export type ListFormat = 'auto' | 'hosts' | 'adblock' | 'plain' | 'rpz'
+/** The decision the pipeline reached for a query (qlog.Action). */
+export type LogAction = 'allow' | 'block' | 'cached' | 'error' | 'drop' | 'refused'
+/** What a rule does to the name it matches (blocklist.ParseAction). */
+export type DomainAction =
+ | 'allow'
+ | 'block'
+ | 'drop'
+ | 'nxdomain'
+ | 'redirect'
+ | 'passthrough'
+ | 'zero'
+ | 'cname'
+/** Free-form: config sets a format per source and the manager detects it. */
+export type ListFormat = string
export type UpstreamProto = 'udp' | 'tcp' | 'tls'
export type UpstreamPolicy = 'parallel' | 'random' | 'failover'
export type GroupPolicy = 'allow' | 'block_only' | 'deny'
export type DefaultPolicy = 'allow' | 'deny' | 'block_only'
+/** The transport a listener was bound with. */
+export type ListenerTransport = 'udp' | 'tcp' | 'dot'
+/** The identity the server resolved for the caller (GET /api/whoami). */
+export interface Whoami {
+ name: string
+ mode: string
+ /** The credential is a read-only key: the API answers 403 on every write. */
+ read_only: boolean
+ can_write: boolean
+ auth_mode: string
+ base_path: string
+}
+
+// --- GET /api/dashboard ----------------------------------------------------
+
+/** One 5s bucket of the rolling counters. `t` is a unix timestamp in seconds. */
export interface SeriesPoint {
t: number
queries: number
@@ -18,61 +64,175 @@ export interface SeriesPoint {
cache_hits: number
}
+/** Dashboard top_blocked[] entry. `qtype` is a number; there is no name field. */
export interface TopBlockedDomain {
domain: string
count: number
list: string
+ qtype: number
}
+/** Dashboard top_queried[] entry. */
export interface TopQueriedDomain {
domain: string
count: number
- qtype: string
+ qtype: number
blocked: boolean
}
-export interface UpstreamStat {
+/** A bound listener, as reported by both /api/dashboard and /api/status. */
+export interface ListenerInfo {
+ transport: ListenerTransport | string
addr: string
- proto: string
- health: UpstreamHealth
- latency_ms: number
- fail_rate: number
+ enabled: boolean
+ running: boolean
+ err: string
+}
+
+/** Per-transport counters, keyed by transport (udp, tcp, dot, doh). */
+export interface TransportStat {
queries: number
- avg_ms: number
- p95_ms: number
+ answers: number
+ dropped: number
+ errors: number
+ cache_hits: number
+ blocked: number
+ allow: number
+ sent: number
+ received: number
}
export interface Dashboard {
+ /** "ok", "degraded" or "down"; anything else is reported as degraded. */
health: Health
+ /** A semicolon-joined list of the problems behind a degraded health. */
+ degraded_reason: string
version: string
node_id: string
uptime_seconds: number
queries_total: number
queries_per_sec: number
+ errors_total: number
+ /** Queries in the pipeline right now. */
+ inflight: number
+ /** 0..1: hits / (hits + misses). */
cache_hit_rate: number
blocked_total: number
+ /** 0..100 here, but 0..1 in the SSE stats frame — see formatFraction. */
blocked_pct: number
+ /** Compiled blocklist rules and live cache entries, for the header line. */
+ blocklist_rules: number
+ cache_entries: number
+ config_version: number
+ /** Config swaps so far; the cluster uses it to detect divergence. */
+ generation: number
series: SeriesPoint[]
top_blocked: TopBlockedDomain[]
top_queried: TopQueriedDomain[]
+ /** Runtime view of the pool, the same objects GET /api/upstreams serves. */
upstreams: UpstreamStat[]
+ listeners: ListenerInfo[]
+ transports: Record
+ /** Only present when the daemon has no query log at all. */
+ recent?: LogItem[]
}
-export interface BlocklistItem {
+// --- GET /api/logs and the SSE entry frame ---------------------------------
+
+/**
+ * One logged query. Every key is always present — the Go struct has no
+ * `omitempty` — so an absent ACL group is an empty string, not a missing key.
+ * `qtype`/`rcode` are numbers; `qtype_name`/`rcode_name` are their spellings.
+ */
+export interface LogItem {
+ id: number
+ /** RFC 3339. */
+ ts: string
+ client: string
+ client_group: string
name: string
- format: ListFormat | string
+ qtype: number
+ qtype_name: string
+ rcode: number
+ rcode_name: string
+ action: LogAction
+ response: string
+ duration_ms: number
+ upstream: string
+ list: string
+ protocol: string
+ blocked: boolean
+}
+
+export interface LogsResponse {
+ items: LogItem[]
+ total: number
+ offset: number
+ limit: number
+ has_more: boolean
+}
+
+/**
+ * The `stats` frame of GET /api/logs/stream. `blocked_pct` and
+ * `cache_hit_rate` are 0..1 fractions here, where the dashboard reports
+ * blocked_pct as 0..100 — so formatFraction, never formatPercentPoints.
+ */
+export interface LiveStats {
+ qps: number
+ blocked_pct: number
+ cache_hit_rate: number
+ queries_total: number
+ blocked_total: number
+ errors_total: number
+ inflight: number
+ uptime_s: number
+}
+
+/** The `ready` frame of the stream. */
+export interface StreamReady {
+ at?: string
+ version?: string
+}
+
+// --- GET /api/blocklists ---------------------------------------------------
+
+/** Per-source fetch/parse state, nested under items[].meta. */
+export interface BlocklistSourceMeta {
+ name: string
+ format: string
enabled: boolean
url: string
path: string
rules: number
bytes: number
updated_at: string
- duration_ms: number
+ /** Fetch+compile time as a duration STRING, like every other duration. */
+ duration: string
err: string
etag: string
- categories: string[]
+ http_status: number
+ last_mod: string
stale: boolean
- last_modified: string
+}
+
+/** What one source's parse produced (blocklist.ParseStats). */
+export interface BlocklistParseStats {
+ format: string
+ rules: number
+ comments: number
+ skipped: number
+ malformed: number
+ duplicates: number
+ bytes: number
+ duration_ms: number
+}
+
+export interface BlocklistItem {
+ name: string
+ format: ListFormat
+ digest: string
+ meta: BlocklistSourceMeta
+ stats: BlocklistParseStats
}
export interface BlocklistStats {
@@ -82,24 +242,34 @@ export interface BlocklistStats {
root_rules: number
allow: number
block: number
+ passthrough: number
+ drop: number
duplicates: number
sources: number
built_at: string
+ /** Per-source meta keyed by source name. */
+ sources_detail: Record
}
export interface BlocklistsResponse {
items: BlocklistItem[]
stats: BlocklistStats
+ /** Digest of the compiled set, for spotting a mid-roll divergence. */
+ digest: string
+ /** Fetch or parse errors keyed by source name. */
+ errors: Record
}
+// --- GET /api/domains ------------------------------------------------------
+
+/** One compiled rule. `rule` is the wire form (`*.`/`**.` prefixed). */
export interface DomainItem {
name: string
action: DomainAction | string
- list: string
source: string
- categories: string[]
+ categories: string[] | null
hits: number
- updated_at: string
+ rule: string
}
export interface DomainsResponse {
@@ -107,113 +277,191 @@ export interface DomainsResponse {
total: number
page: number
per_page: number
+ /** The server hit its scan cap, so total is a lower bound. */
+ truncated: boolean
}
-export interface DomainCheck {
+/**
+ * GET /api/domains/check on a name that matched a rule. The keys below are the
+ * ones the matching path sets; the miss shape has no rule or hits at all.
+ */
+export interface DomainCheckMatch {
name: string
- action: DomainAction | string
+ query: string
+ matched: true
+ action: DomainAction
source: string
- categories: string[]
+ categories: string[] | null
hits: number
rule: string
matched_pattern: string
+ match_kind: string
}
+/** ...and on a name that did not match anything. */
+export interface DomainCheckMiss {
+ name: string
+ query: string
+ matched: false
+ action: 'none'
+ source: string
+}
+
+/** The lookup answers 200 with an error string rather than a 4xx. */
+export interface DomainCheckError {
+ name: string
+ error: string
+}
+
+export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | DomainCheckError
+
+// --- GET /api/clients ------------------------------------------------------
+
export interface ClientMatch {
- cidrs: string[]
- ptr_suffixes: string[]
- tags: string[]
+ cidrs: string[] | null
+ ptr_suffixes: string[] | null
+ tags: string[] | null
any: boolean
}
+/** The server's own view of a group, including counters the UI cannot set. */
export interface ClientGroup {
name: string
- comment: string
- policy: GroupPolicy | string
+ /** Omitted when the group has none, so it has to stay optional. */
+ comment?: string
+ policy: GroupPolicy
match: ClientMatch
- allow_domains: string[]
- deny_domains: string[]
+ allow_domains: string[] | null
+ deny_domains: string[] | null
skip_blocklist: boolean
skip_cache: boolean
rate_limit: number
max_concurrent: number
log_queries: boolean
- upstreams: string[]
+ upstreams: string[] | null
+ client_count: number
+ query_count: number
+ blocked_count: number
+ matched: boolean
}
export interface ClientsResponse {
groups: ClientGroup[]
- default_policy: DefaultPolicy | string
+ default_policy: DefaultPolicy
+ default_rate_limit: number
}
-export interface UpstreamServer {
+/**
+ * What PUT /api/clients accepts: config.Group and nothing else. The four
+ * server-owned counters of ClientGroup are refused on a write, so the editor
+ * must not send them back.
+ */
+export interface ClientGroupInput {
+ name: string
+ comment?: string
+ policy: GroupPolicy
+ match: ClientMatch
+ allow_domains: string[] | null
+ deny_domains: string[] | null
+ skip_blocklist: boolean
+ skip_cache: boolean
+ rate_limit: number
+ max_concurrent: number
+ log_queries: boolean
+ upstreams: string[] | null
+}
+
+export interface ClientsPayload {
+ groups: ClientGroupInput[]
+ default_policy: DefaultPolicy
+}
+
+// --- GET /api/upstreams ----------------------------------------------------
+
+/**
+ * One server in the pool, with its runtime health. `comment` and `last_error`
+ * are omitted when empty.
+ */
+export interface UpstreamStat {
addr: string
- proto: UpstreamProto | string
+ proto: UpstreamProto
server_name: string
weight: number
- comment: string
+ comment?: string
health: UpstreamHealth
latency_ms: number
- fail_rate: number
- queries: number
avg_ms: number
p95_ms: number
+ fail_rate: number
+ queries: number
+ failures: number
breaker_open: boolean
+ last_error?: string
}
export interface UpstreamBootstrap {
name: string
- addresses: string[]
+ addresses: string[] | null
+ /** A duration STRING ("24h0m0s"), like every other duration on the wire. */
refresh: string
}
export interface UpstreamsResponse {
- policy: UpstreamPolicy | string
- servers: UpstreamServer[]
+ policy: UpstreamPolicy
+ servers: UpstreamStat[]
bootstraps: UpstreamBootstrap[]
+ /** Duration strings ("30s", "24h0m0s"), not seconds. */
healthcheck_interval: string
+ breaker_cooldown: string
max_attempts: number
breaker_threshold: number
}
-export interface LogItem {
- id: number | string
- ts: string
- client: string
- client_group: string
- name: string
- qtype: string
- rcode: number
- action: LogAction | string
- response: string
- duration_ms: number
- upstream: string
- list: string
+/** What PUT /api/upstreams accepts for one server (config.UpstreamServer). */
+export interface UpstreamServerInput {
+ addr: string
+ proto: UpstreamProto
+ server_name: string
+ insecure_skip_verify: boolean
+ health_name?: string
+ max_fails?: number
+ weight: number
+ comment?: string
}
-export interface LogsResponse {
- items: LogItem[]
- total: number
- offset: number
+export interface UpstreamsPayload {
+ policy: UpstreamPolicy
+ servers: UpstreamServerInput[]
+ bootstraps: UpstreamBootstrap[]
+ /**
+ * The console edits the interval in seconds; the server also accepts the
+ * duration string under `healthcheck_interval`, and keeps the current value
+ * when neither is supplied.
+ */
+ healthcheck_interval_s?: number
+ max_attempts?: number
+ breaker_threshold?: number
}
-export interface LiveStats {
- qps: number
- blocked_pct: number
- cache_hit_rate: number
-}
+// --- GET /api/cache --------------------------------------------------------
export interface CachePersisted {
enabled: boolean
path: string
bytes: number
entries: number
+ /** Count of snapshot write errors; there is no error string on the wire. */
+ errors: number
+ /** A Go zero time ("0001-01-01T00:00:00Z") until the first snapshot. */
last_snapshot: string
+ /** True when the warm start read a snapshot at boot. */
+ loaded: boolean
writes: number
}
export interface CacheResponse {
enabled: boolean
+ /** Entry budget, not a byte count. */
size: number
entries: number
shards: number
@@ -221,28 +469,40 @@ export interface CacheResponse {
max_ttl_ms: number
serve_stale_ms: number
negative_ttl_ms: number
+ /** 0..1 fraction. */
hit_rate: number
hits: number
misses: number
stale_hits: number
evictions: number
+ /** Bytes actually held by cached answers. */
+ bytes: number
+ bytes_human: string
persisted: CachePersisted
}
+/**
+ * One cached name. The cache counts hits and bytes per name; it has never
+ * tracked a lookup count or a last-seen timestamp, so the console must not
+ * invent columns for them.
+ */
export interface CacheTopEntry {
name: string
- qtype: string
- count: number
+ qtype: number
+ qtype_name: string
hits: number
- last_seen: string
+ bytes: number
}
export interface CacheTopResponse {
items: CacheTopEntry[]
}
+// --- GET /api/settings -----------------------------------------------------
+
export interface SettingsResponse {
- version: string
+ /** A config revision counter (an int, not a hash). */
+ version: number
path: string
config_yaml: string
effective_yaml: string
@@ -254,7 +514,7 @@ export interface SettingsResponse {
export interface SaveSettingsResponse {
ok: boolean
- version: string
+ version: number
warnings: string[]
}
@@ -269,13 +529,58 @@ export interface ValidateResponse {
warnings: ConfigIssue[]
}
+// --- Write responses -------------------------------------------------------
+
export interface OkResponse {
ok: boolean
+ name?: string
+}
+
+export interface AddDomainResponse {
+ ok: boolean
+ name: string
+ /** Rules written, which is more than one for a bulk paste. */
+ count: number
+}
+
+export interface SaveResponse {
+ ok: boolean
+ version: number
+}
+
+/** `names` is always an array, never null: the source count is real. */
+export interface RefreshBlocklistsResponse {
+ started: boolean
+ names: string[]
+}
+
+export interface ToggleBlocklistResponse {
+ ok: boolean
+ name: string
+ enabled: boolean
+}
+
+export interface FlushCacheResponse {
+ ok: boolean
+ entries: number
+}
+
+export interface ReloadSettingsResponse {
+ ok: boolean
+ version: number
}
export interface LoginResponse {
ok: boolean
- token?: string
+ token: string
+}
+
+// --- Request bodies the console builds ------------------------------------
+
+export interface LoginInput {
+ api_key?: string
+ username?: string
+ password?: string
}
export interface DomainFilters {
@@ -292,7 +597,15 @@ export interface LogFilters {
q?: string
action?: string
client?: string
- rcode?: string
+ rcode?: number
blocked?: boolean
since?: string
}
+
+export interface AddDomainInput {
+ name: string
+ action: DomainAction
+ list?: string
+ source?: string
+ content?: string
+}
diff --git a/web/src/pages/Blocklists.tsx b/web/src/pages/Blocklists.tsx
index 17c36e4..1c12b35 100644
--- a/web/src/pages/Blocklists.tsx
+++ b/web/src/pages/Blocklists.tsx
@@ -11,11 +11,12 @@ import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
-import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types'
+import { useWhoami } from '../lib/session.ts'
+import type { BlocklistItem, BlocklistStats } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { useDebounced } from '../hooks/useDebounced'
-import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format'
+import { formatBytes, formatDuration, formatNumber, formatRelative, toDate } from '../lib/format'
import {
Badge,
Button,
@@ -53,24 +54,12 @@ const EMPTY_STATS: BlocklistStats = {
root_rules: 0,
allow: 0,
block: 0,
+ passthrough: 0,
+ drop: 0,
duplicates: 0,
sources: 0,
built_at: '',
-}
-
-type CheckView = Partial & {
- list?: string
- pattern?: string
- error?: string
- /** Older daemons called the matched rule "rule". */
- rule?: string
- matched_pattern?: string
-}
-
-/** The lookup endpoint answers 200 with an `error` string, not a 4xx. */
-function checkView(value: unknown): CheckView | null {
- if (!value || typeof value !== 'object') return null
- return value as CheckView
+ sources_detail: {},
}
function truncate(value: string, max = 42): string {
@@ -80,6 +69,7 @@ function truncate(value: string, max = 42): string {
export default function Blocklists() {
const { toast } = useToast()
+ const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
@@ -93,15 +83,16 @@ export default function Blocklists() {
() => (name.length > 0 ? checkDomain(name) : Promise.resolve(null)),
[name],
)
- const result = checkView(checked)
+ const result = checked
- const stats = (data?.stats as BlocklistStats | undefined) ?? EMPTY_STATS
+ const stats = data?.stats ?? EMPTY_STATS
const items: BlocklistItem[] = useMemo(() => data?.items ?? [], [data])
const compiledAgo = useMemo(() => {
const built = toDate(stats.built_at)
if (built) return formatRelative(built.toISOString())
+ // Fall back to the newest per-source fetch when the set was never compiled.
const newest = items
- .map((i) => toDate(i.updated_at))
+ .map((i) => toDate(i.meta.updated_at))
.filter((d): d is Date => d !== null)
.sort((a, b) => b.getTime() - a.getTime())[0]
return newest ? formatRelative(newest.toISOString()) : 'never'
@@ -136,11 +127,14 @@ export default function Blocklists() {
notify(toast, 'fail', refresh.error ?? 'blocklist refresh failed')
return
}
+ // The response echoes the names the daemon queued, and an empty list means
+ // "every source" — counting it would be a toast that says "0 sources".
+ const queued = res.names ?? []
notify(
toast,
'ok',
- names && names.length > 0
- ? `refresh started for ${names.join(', ')}`
+ queued.length > 0
+ ? `refresh started for ${queued.join(', ')}`
: 'refresh started for every source',
)
refetch()
@@ -161,41 +155,48 @@ export default function Blocklists() {
]
const rows: Row[] = items.map((item) => {
- const enabled = overrides[item.name] ?? item.enabled
+ // Everything about a source except its name and parse stats lives under
+ // meta; before that nested shape these columns read fields that never
+ // arrived, so they rendered blank.
+ const { meta } = item
+ const enabled = overrides[item.name] ?? meta.enabled
const busy = pendingRows[item.name] === true
return {
__key: item.name,
name: (
{item.name}
- {item.err ? (
- {item.err}
+ {meta.err ? (
+ {meta.err}
) : null}
),
- format: {item.format},
+ format: {item.format || meta.format},
enabled: (
void onToggle(item, next)}
label={enabled ? 'on' : 'off'}
+ disabled={!canWrite}
/>
),
- rules: {formatNumber(item.rules)},
- size: {formatBytes(item.bytes)},
+ rules: {formatNumber(meta.rules)},
+ size: {formatBytes(meta.bytes)},
updated: (
- {formatRelative(item.updated_at)}
- {item.stale ? stale : null}
+ {formatRelative(meta.updated_at)}
+ {meta.stale ? stale : null}
),
- duration: {formatMs(item.duration_ms)},
+ // meta.duration is a duration string on the wire, like every other
+ // duration the API serves.
+ duration: {formatDuration(meta.duration)},
source: (
- {truncate(item.url || item.path)}
+ {truncate(meta.url || meta.path)}
),
refresh: (
@@ -203,16 +204,20 @@ export default function Blocklists() {
icon={RefreshCw}
label={`refresh ${item.name}`}
variant="ghost"
- disabled={busy}
+ disabled={busy || !canWrite}
onClick={() => void onRefresh([item.name])}
/>
),
}
})
- const action = result?.action ?? null
- const matchedList = result?.source || result?.list || ''
- const matchedPattern = result?.matched_pattern || result?.rule || result?.pattern || ''
+ // The lookup answers 200 with an error string instead of a 4xx, so its three
+ // shapes are told apart by their keys rather than by the status code.
+ const lookupError = result !== null && 'error' in result ? result.error : null
+ const match = result !== null && 'matched' in result && result.matched ? result : null
+ const action = match?.action ?? null
+ const matchedList = match?.source ?? ''
+ const matchedPattern = match?.matched_pattern ?? ''
return (
@@ -224,6 +229,8 @@ export default function Blocklists() {
icon={RefreshCw}
onClick={() => void onRefresh()}
loading={refresh.pending}
+ disabled={!canWrite}
+ title={canWrite ? undefined : 'This API key is read-only'}
>
Refresh all
@@ -232,7 +239,12 @@ export default function Blocklists() {
@@ -244,7 +259,7 @@ export default function Cache() {
-
{formatRate(hitRate, 0)}
+
{formatFraction(hitRate, 0)}
hit rate
@@ -340,18 +355,18 @@ export default function Cache() {
{formatRelative(persisted.last_snapshot)}
- {persistMessage !== '' && (
-
- {persistMessage}
-
- )}
+
+ {persisted.loaded
+ ? 'The daemon loaded a snapshot at boot, so the cache was warm on the first query.'
+ : 'No snapshot was loaded at boot: the first query after a restart went upstream.'}
+
auto-refresh every 15s}
>
diff --git a/web/src/pages/Clients.tsx b/web/src/pages/Clients.tsx
index e3aa349..a35e536 100644
--- a/web/src/pages/Clients.tsx
+++ b/web/src/pages/Clients.tsx
@@ -11,9 +11,10 @@ import { useMemo, useState } from 'react'
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { ApiError, getClients, putClients } from '../lib/api'
-import type { ClientGroup, ClientMatch } from '../lib/types'
+import { useWhoami } from '../lib/session.ts'
+import type { ClientGroup, ClientGroupInput, ClientsPayload, DefaultPolicy, GroupPolicy } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
-import { fromLines, toLines } from '../lib/format'
+import { fromLines, toLines, formatNumber } from '../lib/format'
import {
Badge,
Button,
@@ -97,7 +98,15 @@ function toNumber(value: string): number {
return Number.isFinite(n) && n > 0 ? n : 0
}
-function matchOf(g: ClientGroup): ClientMatch {
+/** A match with every list present; the API sends null for an empty one. */
+interface MatchLists {
+ cidrs: string[]
+ ptr_suffixes: string[]
+ tags: string[]
+ any: boolean
+}
+
+function matchOf(g: ClientGroup): MatchLists {
const m = g.match
return {
cidrs: m?.cidrs ?? [],
@@ -143,11 +152,11 @@ function fromGroup(g: ClientGroup): GroupDraft {
}
}
-function toGroup(d: GroupDraft): ClientGroup {
+function toGroup(d: GroupDraft): ClientGroupInput {
return {
name: d.name.trim(),
comment: d.comment,
- policy: d.policy,
+ policy: d.policy as GroupPolicy,
match: {
cidrs: toLines(d.cidrs),
ptr_suffixes: toLines(d.ptr_suffixes),
@@ -165,6 +174,33 @@ function toGroup(d: GroupDraft): ClientGroup {
}
}
+/**
+ * The write shape of a group read from the server.
+ *
+ * A summary carries four server-owned counters (client_count, query_count,
+ * blocked_count, matched) that config.Group does not have; sending them back
+ * is exactly what the API answers 400 to, so they are dropped here rather
+ * than quietly riding along.
+ */
+function toInput(g: ClientGroup): ClientGroupInput {
+ return {
+ name: g.name,
+ comment: g.comment,
+ policy: g.policy,
+ // The match lists are normalised, so a null from the API cannot be echoed
+ // straight back into the write payload.
+ match: matchOf(g),
+ allow_domains: g.allow_domains,
+ deny_domains: g.deny_domains,
+ skip_blocklist: g.skip_blocklist,
+ skip_cache: g.skip_cache,
+ rate_limit: g.rate_limit,
+ max_concurrent: g.max_concurrent,
+ log_queries: g.log_queries,
+ upstreams: g.upstreams,
+ }
+}
+
/** `groups[2].match.cidrs` -> `cidrs`. */
function fieldLeaf(path: string): string {
const cleaned = path.replace(/^groups\[\d+\]\.?/, '').replace(/^groups\.\d+\.?/, '')
@@ -174,6 +210,7 @@ function fieldLeaf(path: string): string {
export default function Clients() {
const { toast } = useToast()
+ const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo(() => data?.groups ?? [], [data])
@@ -187,7 +224,7 @@ export default function Clients() {
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
- const save = useAsyncAction(async (payload: { groups: ClientGroup[]; default_policy: string }) => {
+ const save = useAsyncAction(async (payload: ClientsPayload) => {
try {
return await putClients(payload)
} catch (err) {
@@ -214,11 +251,14 @@ export default function Clients() {
}
const commit = async (
- nextGroups: ClientGroup[],
+ nextGroups: ClientGroupInput[],
nextDefault: string,
message: string,
): Promise => {
- const res = await save.run({ groups: nextGroups, default_policy: nextDefault })
+ const res = await save.run({
+ groups: nextGroups,
+ default_policy: nextDefault as DefaultPolicy,
+ })
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the client groups')
return false
@@ -261,7 +301,7 @@ export default function Clients() {
return
}
const group = toGroup(draft)
- const next = [...groups]
+ const next = groups.map(toInput)
if (editingIndex !== null && next[editingIndex]) {
next[editingIndex] = group
} else {
@@ -278,7 +318,7 @@ export default function Clients() {
if (index === null) return
const group = groups[index]
if (!group) return
- const next = groups.filter((_, i) => i !== index)
+ const next = groups.filter((_, i) => i !== index).map(toInput)
if (await commit(next, effectiveDefault, `deleted ${group.name}`)) {
setPendingDelete(null)
}
@@ -286,7 +326,7 @@ export default function Clients() {
const saveDefault = async (next: string): Promise => {
setDefaultDraft(next)
- const ok = await commit(groups, next, `default policy set to ${next}`)
+ const ok = await commit(groups.map(toInput), next, `default policy set to ${next}`)
if (!ok) {
setDefaultDraft(null)
return
@@ -302,18 +342,28 @@ export default function Clients() {
title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
actions={
-