1 parent
289a8cc2d8
commit
66f36cfac6
19 files changed
+1905
-817
No files matched your search
@@ -11,7 +11,9 @@ import type {
|
||||
AddDomainResponse,
|
||||
BlocklistsResponse,
|
||||
CacheResponse,
|
||||
ClientGroupInput,
|
||||
ClientsPayload,
|
||||
GroupValidation,
|
||||
ClientsResponse,
|
||||
DomainCheckResponse,
|
||||
DomainFilters,
|
||||
@@ -370,6 +372,16 @@ export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function validateClientGroup(
|
||||
group: ClientGroupInput,
|
||||
testIp?: string,
|
||||
): Promise<GroupValidation> {
|
||||
return apiFetch<GroupValidation>('/api/clients/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ group, ...(testIp ? { test_ip: testIp } : {}) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function getUpstreams(): Promise<UpstreamsResponse> {
|
||||
return apiFetch<UpstreamsResponse>('/api/upstreams')
|
||||
}
|
||||
|
||||
@@ -345,6 +345,41 @@ export interface ClientsPayload {
|
||||
default_policy: DefaultPolicy
|
||||
}
|
||||
|
||||
// --- POST /api/clients/validate ---------------------------------------------
|
||||
|
||||
/** One observation about a group, tied to the field it concerns. */
|
||||
export interface GroupFinding {
|
||||
/** A JSON path inside the group: "policy", "match.cidrs", "rate_limit". */
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server says about a group before it is saved. `errors` block the
|
||||
* save; `warnings` are legal settings that probably are not what was meant;
|
||||
* `notes` are facts worth knowing.
|
||||
*/
|
||||
export interface GroupValidation {
|
||||
ok: boolean
|
||||
errors: GroupFinding[]
|
||||
warnings: GroupFinding[]
|
||||
notes: GroupFinding[]
|
||||
/** The group in plain English, one sentence per entry. */
|
||||
summary: string[]
|
||||
/** No client can ever land in the group: the ones above it claim them all. */
|
||||
unreachable: boolean
|
||||
/** 1-based place in the evaluation order, and the number of groups. */
|
||||
position: number
|
||||
total: number
|
||||
test?: {
|
||||
ip: string
|
||||
valid: boolean
|
||||
group: string
|
||||
default: boolean
|
||||
note: string
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/upstreams ----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
+378
-135
@@ -7,12 +7,21 @@
|
||||
* user sees what is wrong next to the input rather than in a toast alone.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, Copy, Info, Pencil, Plus, Trash2, Users } from 'lucide-react'
|
||||
|
||||
import { ApiError, getClients, putClients } from '../lib/api'
|
||||
import { ApiError, getClients, putClients, validateClientGroup } from '../lib/api'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import type { ClientGroup, ClientGroupInput, ClientsPayload, DefaultPolicy, GroupPolicy } from '../lib/types'
|
||||
import type {
|
||||
ClientGroup,
|
||||
ClientGroupInput,
|
||||
ClientsPayload,
|
||||
DefaultPolicy,
|
||||
GroupFinding,
|
||||
GroupPolicy,
|
||||
GroupValidation,
|
||||
} from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { fromLines, toLines, formatNumber } from '../lib/format'
|
||||
import {
|
||||
@@ -45,14 +54,63 @@ function notify(
|
||||
}
|
||||
|
||||
const POLICIES = [
|
||||
{ value: 'allow', label: 'Allow — resolve everything' },
|
||||
{ value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
|
||||
{ value: 'deny', label: 'Deny — refuse everything' },
|
||||
{ value: 'allow', label: 'Allow — resolve normally, blocklists apply' },
|
||||
{ value: 'deny', label: 'Deny — refuse every query from these clients' },
|
||||
]
|
||||
|
||||
/**
|
||||
* block_only does exactly what allow does, so it is not offered. A group that
|
||||
* already has it keeps showing it, rather than silently changing on edit.
|
||||
*/
|
||||
const LEGACY_POLICY = { value: 'block_only', label: 'Block only (old setting, same as Allow)' }
|
||||
|
||||
function policyOptions(current: string) {
|
||||
return current === 'block_only' ? [...POLICIES, LEGACY_POLICY] : POLICIES
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting points for the common cases. Each fills in the behaviour and leaves
|
||||
* the "who" (name, addresses) to the user, which is the part only they know.
|
||||
*/
|
||||
const TEMPLATES: { id: string; title: string; text: string; patch: Partial<GroupDraft> }[] = [
|
||||
{
|
||||
id: 'trusted',
|
||||
title: 'Trusted devices',
|
||||
text: 'Never blocked: ads and trackers resolve normally.',
|
||||
patch: { policy: 'allow', skip_blocklist: true, comment: 'Trusted devices: blocklists are skipped' },
|
||||
},
|
||||
{
|
||||
id: 'restricted',
|
||||
title: 'Only some sites',
|
||||
text: 'Clients can look up only the sites you list. Everything else is blocked.',
|
||||
patch: {
|
||||
policy: 'allow',
|
||||
allow_domains: '*.example.com',
|
||||
comment: 'Allowlist: only the listed sites resolve',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'guests',
|
||||
title: 'Guests',
|
||||
text: 'Normal filtering, with a cap on queries per second.',
|
||||
patch: { policy: 'allow', rate_limit: '20', comment: 'Guests: rate limited' },
|
||||
},
|
||||
{
|
||||
id: 'cutoff',
|
||||
title: 'Cut a device off',
|
||||
text: 'Refuses every query from these clients.',
|
||||
patch: { policy: 'deny', comment: 'Cut off: all queries refused' },
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug a device',
|
||||
text: 'Logs every query and bypasses the cache so each lookup is fresh.',
|
||||
patch: { policy: 'allow', log_queries: true, skip_cache: true, comment: 'Debugging: logged, uncached' },
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_POLICIES = [
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'block_only', label: 'Block only' },
|
||||
{ value: 'deny', label: 'Deny' },
|
||||
]
|
||||
|
||||
@@ -238,6 +296,53 @@ export default function Clients() {
|
||||
|
||||
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
|
||||
|
||||
// Live check of the draft against the server: the same validation a save
|
||||
// applies, plus a plain-English reading of what the group will do.
|
||||
const [check, setCheck] = useState<GroupValidation | null>(null)
|
||||
const [checkFailed, setCheckFailed] = useState<string | null>(null)
|
||||
const [testIp, setTestIp] = useState('')
|
||||
const pristine = useRef('')
|
||||
const checkSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorOpen) return
|
||||
const touched = JSON.stringify(draft) !== pristine.current
|
||||
// Do not shout about an empty name at someone who has just opened the form.
|
||||
if (!touched && draft.name.trim() === '') return
|
||||
const seq = ++checkSeq.current
|
||||
const handle = window.setTimeout(() => {
|
||||
validateClientGroup(toGroup(draft), testIp.trim() || undefined)
|
||||
.then((res) => {
|
||||
if (seq !== checkSeq.current) return
|
||||
setCheck(res)
|
||||
setCheckFailed(null)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (seq !== checkSeq.current) return
|
||||
setCheckFailed(err instanceof Error ? err.message : 'validation unavailable')
|
||||
})
|
||||
}, 350)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [draft, editorOpen, testIp])
|
||||
|
||||
const openDraft = (next: GroupDraft, index: number | null): void => {
|
||||
pristine.current = JSON.stringify(next)
|
||||
checkSeq.current++
|
||||
setCheck(null)
|
||||
setCheckFailed(null)
|
||||
setTestIp('')
|
||||
setEditingIndex(index)
|
||||
setDraft(next)
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
/** Errors the server found on one field of the draft, ready for an input. */
|
||||
const checkError = (...fields: string[]): string | undefined => {
|
||||
const hit = check?.errors.filter((f) => fields.includes(f.field))
|
||||
return hit && hit.length > 0 ? hit.map((f) => f.message).join(' ') : undefined
|
||||
}
|
||||
|
||||
const save = useAsyncAction(async (payload: ClientsPayload) => {
|
||||
try {
|
||||
return await putClients(payload)
|
||||
@@ -282,30 +387,18 @@ export default function Clients() {
|
||||
return true
|
||||
}
|
||||
|
||||
const openNew = (): void => {
|
||||
setEditingIndex(null)
|
||||
setDraft(EMPTY_DRAFT)
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
}
|
||||
const openNew = (): void => openDraft(EMPTY_DRAFT, null)
|
||||
|
||||
const openEdit = (index: number): void => {
|
||||
const group = groups[index]
|
||||
if (!group) return
|
||||
setEditingIndex(index)
|
||||
setDraft(fromGroup(group))
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
if (group) openDraft(fromGroup(group), index)
|
||||
}
|
||||
|
||||
const duplicate = (index: number): void => {
|
||||
const group = groups[index]
|
||||
if (!group) return
|
||||
const copy = fromGroup(group)
|
||||
setEditingIndex(null)
|
||||
setDraft({ ...copy, name: `${copy.name}-copy` })
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
openDraft({ ...copy, name: `${copy.name}-copy` }, null)
|
||||
}
|
||||
|
||||
const saveGroup = async (): Promise<void> => {
|
||||
@@ -314,6 +407,10 @@ export default function Clients() {
|
||||
notify(toast, 'fail', 'a group needs a name')
|
||||
return
|
||||
}
|
||||
if (check && !check.ok) {
|
||||
notify(toast, 'fail', 'fix the highlighted problems first')
|
||||
return
|
||||
}
|
||||
const group = toGroup(draft)
|
||||
const next = groups.map(toInput)
|
||||
if (editingIndex !== null && next[editingIndex]) {
|
||||
@@ -354,7 +451,7 @@ export default function Clients() {
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
|
||||
description="Give some devices different rules: no ad blocking for a trusted laptop, only approved sites for a child's tablet, a hard stop for a misbehaving camera. The first group that matches a device wins."
|
||||
actions={
|
||||
<Button
|
||||
icon={Plus}
|
||||
@@ -379,7 +476,7 @@ export default function Clients() {
|
||||
value={effectiveDefault}
|
||||
disabled={loading || save.pending || !canWrite || !defaultEditable}
|
||||
onChange={(e) => void saveDefault(e.target.value)}
|
||||
options={DEFAULT_POLICIES}
|
||||
options={effectiveDefault === 'block_only' ? [...DEFAULT_POLICIES, LEGACY_POLICY] : DEFAULT_POLICIES}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -424,6 +521,7 @@ export default function Clients() {
|
||||
{matchSummary(group)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(group.allow_domains?.length ?? 0) > 0 ? <Badge tone="warn">allowlist only</Badge> : null}
|
||||
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
|
||||
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
|
||||
{group.log_queries ? <Badge tone="muted">log</Badge> : null}
|
||||
@@ -457,153 +555,215 @@ export default function Clients() {
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
|
||||
description="Groups are matched top to bottom; the first match wins."
|
||||
description="A group is a set of devices that share rules. Each query is checked against the groups from the top; the first group that matches the device decides."
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{check && !check.ok ? (
|
||||
<span className="mr-auto text-xs text-danger">Fix the problems marked in red to save.</span>
|
||||
) : null}
|
||||
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void saveGroup()} loading={save.pending} disabled={!canWrite}>
|
||||
<Button
|
||||
onClick={() => void saveGroup()}
|
||||
loading={save.pending}
|
||||
disabled={!canWrite || (check !== null && !check.ok)}
|
||||
>
|
||||
{editingIndex === null ? 'Create group' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
{editingIndex === null ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Start from</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => ({ ...EMPTY_DRAFT, name: d.name, cidrs: d.cidrs, ...t.patch }))}
|
||||
className="rounded-md border border-line bg-surface-2 px-3 py-2 text-left transition-colors hover:border-line-strong hover:bg-surface-3"
|
||||
>
|
||||
<span className="block text-sm font-medium">{t.title}</span>
|
||||
<span className="block text-xs text-fg-subtle">{t.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormSection title="Name">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Name"
|
||||
hint="Lowercase letters, digits, '-' and '.', e.g. kids-tablets."
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
error={checkError('name') ?? fieldError('name')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
label="Note to self"
|
||||
hint="Optional. Shown on the group's card."
|
||||
value={draft.comment}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Which devices?"
|
||||
help="A device is in the group if it matches any one of the boxes below. Addresses are the most reliable; names and tags need working reverse DNS (PTR records)."
|
||||
>
|
||||
<Textarea
|
||||
label="IP addresses or ranges"
|
||||
hint="One per line. One device: 192.168.1.20/32. A whole network: 192.168.1.0/24."
|
||||
monospace
|
||||
rows={3}
|
||||
value={draft.cidrs}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
|
||||
error={checkError('match.cidrs') ?? fieldError('cidrs')}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Textarea
|
||||
label="Reverse-DNS name ends with"
|
||||
hint="One per line, e.g. lan.example.net or .lan. Matches devices whose address resolves back to a name under it."
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.ptr_suffixes}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
|
||||
error={checkError('match.ptr_suffixes') ?? fieldError('ptr_suffixes')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Name tags"
|
||||
hint="One word per line. A tag is one part of a device's reverse name: laptop for laptop.lan.example.net."
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.tags}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
|
||||
error={checkError('match.tags') ?? fieldError('tags')}
|
||||
/>
|
||||
</div>
|
||||
{checkError('match', 'match.any') ? (
|
||||
<p className="text-sm text-danger">{checkError('match', 'match.any')}</p>
|
||||
) : null}
|
||||
<Input
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
error={fieldError('name')}
|
||||
label="Try an address"
|
||||
hint="Type a device's IP to see which group it would land in once this group is saved."
|
||||
value={testIp}
|
||||
onChange={(e) => setTestIp(e.target.value)}
|
||||
placeholder="192.168.1.20"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{check?.test ? (
|
||||
<p
|
||||
className={
|
||||
check.test.valid ? 'text-sm text-fg-muted' : 'text-sm text-danger'
|
||||
}
|
||||
>
|
||||
{check.test.valid
|
||||
? check.test.default
|
||||
? check.test.note
|
||||
: `${check.test.ip} lands in "${check.test.group}". ${check.test.note}`
|
||||
: check.test.note}
|
||||
</p>
|
||||
) : null}
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="What happens to their queries?"
|
||||
help="Names are looked up normally and the blocklists are applied, unless you change that below."
|
||||
>
|
||||
<Select
|
||||
label="Policy"
|
||||
value={draft.policy}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
|
||||
options={POLICIES}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Comment"
|
||||
value={draft.comment}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Textarea
|
||||
label="Client CIDRs"
|
||||
hint="One per line, e.g. 192.168.1.0/24"
|
||||
monospace
|
||||
rows={4}
|
||||
value={draft.cidrs}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
|
||||
error={fieldError('cidrs')}
|
||||
options={policyOptions(draft.policy)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reverse-DNS suffixes"
|
||||
hint="One per line, e.g. .lan"
|
||||
monospace
|
||||
rows={4}
|
||||
value={draft.ptr_suffixes}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
|
||||
error={fieldError('ptr_suffixes')}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Tags"
|
||||
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.tags}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
|
||||
error={fieldError('tags')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Textarea
|
||||
label="Allow domains"
|
||||
hint="Glob patterns that skip the blocklists for this group."
|
||||
label="Only allow these sites (allowlist)"
|
||||
hint="Leave empty for no restriction. If you list anything, these devices can look up ONLY these names and everything else is blocked. *.example.com covers example.com and everything under it."
|
||||
monospace
|
||||
rows={3}
|
||||
value={draft.allow_domains}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
|
||||
error={fieldError('allow_domains')}
|
||||
error={checkError('allow_domains') ?? fieldError('allow_domains')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Deny domains"
|
||||
hint="Glob patterns always refused for this group."
|
||||
label="Always block these sites"
|
||||
hint="Blocked for this group even if no blocklist has them. Wins over the allowlist."
|
||||
monospace
|
||||
rows={3}
|
||||
value={draft.deny_domains}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
|
||||
error={fieldError('deny_domains')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label="Upstreams"
|
||||
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.upstreams}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
|
||||
error={fieldError('upstreams')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Rate limit (qps)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.rate_limit}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
|
||||
hint="0 disables the limit."
|
||||
error={fieldError('rate_limit')}
|
||||
/>
|
||||
<Input
|
||||
label="Max concurrent queries"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.max_concurrent}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
|
||||
hint="0 means unlimited."
|
||||
error={fieldError('max_concurrent')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Switch
|
||||
checked={draft.any}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, any: next }))}
|
||||
label="Match any client"
|
||||
description="Last-resort group that matches everything not claimed above."
|
||||
error={checkError('deny_domains') ?? fieldError('deny_domains')}
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.skip_blocklist}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, skip_blocklist: next }))}
|
||||
label="Skip blocklists"
|
||||
description="Resolve everything, even listed names."
|
||||
label="Ignore the blocklists"
|
||||
description="Names on the blocklists resolve normally for these devices. For trusted devices that break when ads are blocked."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.skip_cache}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
|
||||
label="Skip cache"
|
||||
description="Always go upstream, never serve a cached answer."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.log_queries}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
|
||||
label="Log queries"
|
||||
description="Include this group's queries in the query log."
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<details className="rounded-md border border-line">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium">
|
||||
Advanced: limits, upstreams, caching, logging
|
||||
</summary>
|
||||
<div className="space-y-4 border-t border-line p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Queries per second, per device"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.rate_limit}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
|
||||
hint="Above this, queries are refused. 0 means no limit, which also exempts the group from the default limit."
|
||||
error={checkError('rate_limit') ?? fieldError('rate_limit')}
|
||||
/>
|
||||
<Input
|
||||
label="Queries in flight, per device"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.max_concurrent}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
|
||||
hint="Extra queries get SERVFAIL. 0 means no cap for this group."
|
||||
error={checkError('max_concurrent') ?? fieldError('max_concurrent')}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Use only these upstream resolvers"
|
||||
hint="One per line, written exactly as on the Upstreams page (e.g. 1.1.1.1:53). Empty means the normal upstreams."
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.upstreams}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
|
||||
error={checkError('upstreams') ?? fieldError('upstreams')}
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.skip_cache}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
|
||||
label="Bypass the cache"
|
||||
description="Every query goes upstream and nothing is cached. Slower; for debugging."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.log_queries}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
|
||||
label="Always log their queries"
|
||||
description="Written to the access log even when logging is otherwise limited to blocked queries."
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<CheckPanel check={check} failed={checkFailed} />
|
||||
|
||||
{fieldErrors.length > 0 ? (
|
||||
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||
<div className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger">
|
||||
<p className="font-medium">The server rejected these fields:</p>
|
||||
<ul className="mt-1 list-inside list-disc font-mono text-xs">
|
||||
{fieldErrors.map((f) => (
|
||||
@@ -632,3 +792,86 @@ export default function Clients() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A titled block of the editor, with an optional line saying what it is for. */
|
||||
function FormSection({
|
||||
title,
|
||||
help,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
help?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
{help ? <p className="mt-0.5 text-xs text-fg-subtle">{help}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingRows({ items, tone }: { items: GroupFinding[]; tone: 'danger' | 'warn' | 'info' }) {
|
||||
if (items.length === 0) return null
|
||||
const Icon = tone === 'info' ? Info : AlertTriangle
|
||||
const colour = tone === 'danger' ? 'text-danger' : tone === 'warn' ? 'text-warn' : 'text-fg-subtle'
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f, i) => (
|
||||
<li key={`${f.field}-${i}`} className={`flex gap-2 text-sm ${colour}`}>
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{f.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server made of the draft: the group in plain English first, then
|
||||
* what blocks a save, what is probably a mistake, and what is just worth
|
||||
* knowing. Fields that have an input show their errors there as well.
|
||||
*/
|
||||
function CheckPanel({ check, failed }: { check: GroupValidation | null; failed: string | null }) {
|
||||
if (failed) {
|
||||
return (
|
||||
<p className="rounded-md border border-line px-3 py-2 text-sm text-fg-subtle">
|
||||
Could not check this group against the server ({failed}). Saving still validates it.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!check) return null
|
||||
return (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className="space-y-3 rounded-md border border-line bg-surface-2 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{check.ok ? (
|
||||
<Check className="size-4 text-ok" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 text-danger" aria-hidden="true" />
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">What this group will do</h4>
|
||||
</div>
|
||||
{check.summary.length > 0 ? (
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-fg-muted">
|
||||
{check.summary.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<FindingRows items={check.errors} tone="danger" />
|
||||
{check.unreachable ? (
|
||||
<p className="text-sm font-medium text-warn">
|
||||
As ordered, this group would never be used. Move the group above it, or narrow that one.
|
||||
</p>
|
||||
) : null}
|
||||
<FindingRows items={check.warnings} tone="warn" />
|
||||
<FindingRows items={check.notes} tone="info" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user