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 && ( +

+

+ )} {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() {
- i.enabled).length} enabled`} /> + i.meta.enabled).length} enabled`} + icon={Layers} + /> Checking {name}…

) : checkError ? (

{checkError}

- ) : result?.error ? ( + ) : lookupError !== null ? (

- ) : result ? ( + ) : match !== null ? (
{action ?? 'unknown'} {name} @@ -296,11 +308,11 @@ export default function Blocklists() { rule: {matchedPattern || '—'} - hits: {formatNumber(result.hits ?? 0)} + hits: {formatNumber(match.hits)} - {Array.isArray(result.categories) && result.categories.length > 0 ? ( + {match.categories !== null && match.categories.length > 0 ? ( - {result.categories.map((c) => ( + {match.categories.map((c) => ( {c} diff --git a/web/src/pages/Cache.tsx b/web/src/pages/Cache.tsx index 19a55ca..976730b 100644 --- a/web/src/pages/Cache.tsx +++ b/web/src/pages/Cache.tsx @@ -18,10 +18,18 @@ import { import { Database, FileClock, Gauge, HardDrive, Layers, RefreshCw, Timer } from 'lucide-react' import { flushCache, getCache, getCacheTop } from '../lib/api' +import { useWhoami } from '../lib/session.ts' import type { CachePersisted, CacheTopEntry } from '../lib/types' import { useApi, useAsyncAction } from '../hooks/useApi' import { useAutoRefresh } from '../hooks/useAutoRefresh' -import { formatBytes, formatMs, formatNumber, formatRate, formatRelative } from '../lib/format' +import { + formatBytes, + formatFraction, + formatMs, + formatNumber, + formatQtype, + formatRelative, +} from '../lib/format' import { Badge, Button, @@ -44,11 +52,16 @@ const TOP_LIMIT = 50 const HITS = '#34d399' const MISSES = '#f87171' -/** `persisted` gained fields over time; read defensively. */ -type PersistedView = Partial & { - errors?: number - error?: string - last_error?: string +/** What the page renders before the first response lands. */ +const EMPTY_PERSISTED: CachePersisted = { + enabled: false, + path: '', + bytes: 0, + entries: 0, + errors: 0, + last_snapshot: '', + loaded: false, + writes: 0, } type PieSlice = { name: string; value: number; fill: string } @@ -81,6 +94,7 @@ function ChartTip({ active, payload }: TipProps) { export default function Cache() { const { toast } = useToast() + const { canWrite } = useWhoami() const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) @@ -113,9 +127,8 @@ export default function Cache() { [hits, misses], ) - const persisted: PersistedView = (data?.persisted ?? {}) as PersistedView - const persistErrors = persisted.errors ?? 0 - const persistMessage = persisted.last_error ?? persisted.error ?? '' + const persisted: CachePersisted = data?.persisted ?? EMPTY_PERSISTED + const persistErrors = persisted.errors const entries: CacheTopEntry[] = useMemo(() => top?.items ?? [], [top]) @@ -137,12 +150,13 @@ export default function Cache() { if (error) return + // The cache counts hits and bytes per name and tracks no timestamp at all, + // so those are the only two columns the ranking can honestly show. const topColumns = [ - { key: 'name', header: 'Name', width: '48%' }, + { key: 'name', header: 'Name', width: '54%' }, { key: 'qtype', header: 'Type', width: '10%' }, - { key: 'count', header: 'Lookups', width: '14%', align: 'right' as const }, - { key: 'hits', header: 'Served from cache', width: '14%', align: 'right' as const }, - { key: 'last_seen', header: 'Last seen', width: '14%', align: 'right' as const }, + { key: 'hits', header: 'Lookups', width: '18%', align: 'right' as const }, + { key: 'bytes', header: 'Size', width: '18%', align: 'right' as const }, ] const topRows: Row[] = entries.map((entry) => ({ @@ -152,10 +166,9 @@ export default function Cache() { {entry.name} ), - qtype: {entry.qtype || '—'}, - count: {formatNumber(entry.count)}, + qtype: {formatQtype(entry.qtype, entry.qtype_name)}, hits: {formatNumber(entry.hits)}, - last_seen: {formatRelative(entry.last_seen)}, + bytes: {formatBytes(entry.bytes)}, })) return ( @@ -169,6 +182,8 @@ export default function Cache() { icon={RefreshCw} onClick={() => setConfirmFlush(true)} loading={flush.pending} + disabled={!canWrite} + title={canWrite ? undefined : 'This API key is read-only'} > Flush cache @@ -185,9 +200,9 @@ export default function Cache() { />
@@ -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={ - } /> - +
setForm((f) => ({ ...f, name: e.target.value }))} - disabled={form.advanced} - autoComplete="off" - spellCheck={false} - /> + setForm((f) => ({ ...f, name: e.target.value }))} + disabled={form.advanced} + autoComplete="off" + spellCheck={false} + />