mroe ui
This commit is contained in:
1 parent
d94e4cf6a8
commit
b2cd7d4b91
13 files changed
+542
-26
No files matched your search
@@ -20,6 +20,7 @@ import Domains from './pages/Domains.tsx'
|
||||
import Login from './pages/Login.tsx'
|
||||
import Logs from './pages/Logs.tsx'
|
||||
import LocalZones from './pages/LocalZones.tsx'
|
||||
import Lookup from './pages/Lookup.tsx'
|
||||
import NotFound from './pages/NotFound.tsx'
|
||||
import Settings from './pages/Settings.tsx'
|
||||
import Upstreams from './pages/Upstreams.tsx'
|
||||
@@ -52,6 +53,7 @@ function AppRoutes() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/blocklists" element={<Blocklists />} />
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/lookup" element={<Lookup />} />
|
||||
<Route path="/local-zones" element={<LocalZones />} />
|
||||
<Route path="/zones" element={<Zones />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/lookup', label: 'Lookup', icon: Search },
|
||||
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
||||
{ to: '/zones', label: 'Zones', icon: Layers },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
DomainCheckResponse,
|
||||
DomainFilters,
|
||||
DomainsResponse,
|
||||
DnsTestResponse,
|
||||
FlushCacheResponse,
|
||||
LoginInput,
|
||||
LoginResponse,
|
||||
@@ -278,6 +279,14 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
||||
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
|
||||
}
|
||||
|
||||
/** Runs one query through the live pipeline, the console's connectivity test. */
|
||||
export function testDns(name: string, qtype: string): Promise<DnsTestResponse> {
|
||||
return apiFetch<DnsTestResponse>('/api/dns/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type: qtype }),
|
||||
})
|
||||
}
|
||||
|
||||
export function getZones(): Promise<ZonesResponse> {
|
||||
return apiFetch<ZonesResponse>('/api/zones')
|
||||
}
|
||||
|
||||
@@ -315,6 +315,30 @@ export interface DomainCheckError {
|
||||
|
||||
export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | DomainCheckError
|
||||
|
||||
// --- POST /api/dns/test -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* The result of one query through the live pipeline. A failed lookup is a
|
||||
* 200 with an rcode and no answers, so `error` only appears when the query
|
||||
* never ran (bad name, engine down).
|
||||
*/
|
||||
export interface DnsTestResponse {
|
||||
name: string
|
||||
type: string
|
||||
rcode: string
|
||||
action: LogAction | string
|
||||
answers: string[]
|
||||
duration_ms: number
|
||||
upstream: string
|
||||
group: string
|
||||
cache: string
|
||||
local: boolean
|
||||
truncated: boolean
|
||||
blocked: boolean
|
||||
block_source: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// --- GET /api/clients ------------------------------------------------------
|
||||
|
||||
export interface ClientMatch {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Lookup — the console's dig, and the place a domain's config is copied from.
|
||||
*
|
||||
* A test goes through the live pipeline (blocklists, ACL, local zones, cache,
|
||||
* upstream), so it answers the question an operator actually has: "would a
|
||||
* client pointed at this server get an answer for this name". The entries list
|
||||
* is the same query over the record types a domain is normally configured
|
||||
* with, and every entry can be turned into a local-zone override from there.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
|
||||
import { addZoneRecord, testDns } from '../lib/api'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import type { DnsTestResponse } from '../lib/types'
|
||||
import { useAsyncAction } from '../hooks/useApi'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
/** The record types a "list the entries of this domain" lookup asks for. */
|
||||
const ENTRY_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA']
|
||||
|
||||
const TEST_TYPES = ['A', 'AAAA', 'TXT', 'MX', 'NS', 'SOA', 'CNAME', 'PTR', 'ANY']
|
||||
|
||||
interface Entry {
|
||||
key: string
|
||||
name: string
|
||||
type: string
|
||||
ttl: number
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one rendered answer into its parts. The server sends the record as it
|
||||
* appears in a zone file — `name ttl class type rdata` — so the rdata is
|
||||
* whatever follows the fourth field and keeps its spaces (a TXT value, a
|
||||
* quoted MX preference and exchange).
|
||||
*/
|
||||
function parseEntry(answer: string, type: string, index: number): Entry | null {
|
||||
const parts = answer.trim().split(/\s+/)
|
||||
if (parts.length < 5) return null
|
||||
const ttl = Number.parseInt(parts[1], 10)
|
||||
return {
|
||||
key: `${parts[0]}-${type}-${index}`,
|
||||
name: parts[0],
|
||||
type: parts[3].toUpperCase(),
|
||||
ttl: Number.isFinite(ttl) ? ttl : 0,
|
||||
value: parts.slice(4).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The apex to offer as the override zone. There is no public-suffix list in the
|
||||
* binary, so this takes the last two labels: for `www.example.com` that is
|
||||
* `example.com`, and the dialog opens with the field editable because
|
||||
* `example.co.uk` needs three.
|
||||
*/
|
||||
function defaultZone(name: string): string {
|
||||
return name.replace(/\.$/, '').split('.').slice(-2).join('.')
|
||||
}
|
||||
|
||||
function rcodeTone(rcode: string): 'default' | 'ok' | 'warn' | 'danger' {
|
||||
if (rcode === 'NOERROR') return 'ok'
|
||||
if (rcode === 'NXDOMAIN') return 'warn'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export default function Lookup() {
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useWhoami()
|
||||
const [name, setName] = useState('')
|
||||
const [qtype, setQtype] = useState('A')
|
||||
const [result, setResult] = useState<DnsTestResponse | null>(null)
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [listed, setListed] = useState('')
|
||||
|
||||
const [override, setOverride] = useState<Entry | null>(null)
|
||||
const [zone, setZone] = useState('')
|
||||
const [relName, setRelName] = useState('')
|
||||
const [ttl, setTtl] = useState('')
|
||||
|
||||
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type))
|
||||
const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) =>
|
||||
addZoneRecord({
|
||||
zone: input.zone,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
ttl: input.ttl,
|
||||
value: input.value,
|
||||
}),
|
||||
)
|
||||
|
||||
const trimmed = name.trim()
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
if (trimmed === '') return
|
||||
const res = await test.run(trimmed, qtype)
|
||||
if (res === null) {
|
||||
toast({ title: test.error ?? 'the test query failed', tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setResult(res)
|
||||
}
|
||||
|
||||
const listEntries = async (): Promise<void> => {
|
||||
if (trimmed === '') return
|
||||
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t)))
|
||||
const found: Entry[] = []
|
||||
for (const [i, res] of results.entries()) {
|
||||
if (res.error !== undefined) continue
|
||||
for (const [j, answer] of res.answers.entries()) {
|
||||
const entry = parseEntry(answer, ENTRY_TYPES[i], j)
|
||||
if (entry !== null) found.push(entry)
|
||||
}
|
||||
}
|
||||
setEntries(found)
|
||||
setListed(trimmed)
|
||||
toast({
|
||||
title: found.length === 0 ? `no records for ${trimmed}` : `${found.length} record${found.length === 1 ? '' : 's'} for ${trimmed}`,
|
||||
tone: found.length === 0 ? 'warn' : 'ok',
|
||||
})
|
||||
}
|
||||
|
||||
const openOverride = (entry: Entry): void => {
|
||||
const apex = defaultZone(entry.name)
|
||||
setOverride(entry)
|
||||
setZone(apex)
|
||||
setRelName(entry.name === `${apex}.` || entry.name === apex ? '@' : entry.name.replace(/\.$/, '').slice(0, -(apex.length + 1)))
|
||||
setTtl(entry.ttl > 0 ? String(entry.ttl) : '300')
|
||||
}
|
||||
|
||||
const saveOverride = async (): Promise<void> => {
|
||||
const target = override
|
||||
if (target === null) return
|
||||
const res = await write.run({
|
||||
zone: zone.trim(),
|
||||
name: relName.trim() === '' ? '@' : relName.trim(),
|
||||
type: target.type,
|
||||
ttl: Number.parseInt(ttl, 10) || target.ttl,
|
||||
value: target.value,
|
||||
})
|
||||
if (res === null) {
|
||||
toast({ title: write.error ?? 'the server rejected that record', tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setOverride(null)
|
||||
toast({ title: `override created in ${res.zone}`, description: `${target.type} ${target.name}`, tone: 'ok' })
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', header: 'Name', width: '40%' },
|
||||
{ key: 'type', header: 'Type', width: '10%' },
|
||||
{ key: 'value', header: 'Value', width: '38%' },
|
||||
{ key: 'ttl', header: 'TTL', width: '6%', align: 'right' as const },
|
||||
{ key: 'override', header: '', width: '6%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const rows: Row[] = entries.map((entry) => ({
|
||||
__key: entry.key,
|
||||
name: <span className="block max-w-[280px] truncate font-mono text-xs" title={entry.name}>{entry.name}</span>,
|
||||
type: <Badge tone="info">{entry.type}</Badge>,
|
||||
value: <span className="block max-w-[320px] truncate font-mono text-xs" title={entry.value}>{entry.value}</span>,
|
||||
ttl: <span className="font-mono text-xs">{entry.ttl || '—'}</span>,
|
||||
override: (
|
||||
<IconButton
|
||||
icon={Plus}
|
||||
label={`create an override for ${entry.name} ${entry.type}`}
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
onClick={() => openOverride(entry)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Lookup"
|
||||
description="Test the server, list a domain's records, and copy one into a local zone as an override."
|
||||
/>
|
||||
|
||||
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it.">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="example.com or www.example.com"
|
||||
icon={Search}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void runTest()
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
value={qtype}
|
||||
onChange={(e) => setQtype(e.target.value)}
|
||||
options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="mt-4 space-y-2 border-t border-slate-200 pt-4 dark:border-slate-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={rcodeTone(result.rcode)}>{result.rcode}</Badge>
|
||||
<Badge tone="muted">{result.type}</Badge>
|
||||
<Badge tone={result.blocked ? 'danger' : 'default'}>{result.action}</Badge>
|
||||
{result.local ? <Badge tone="ok">local zone</Badge> : null}
|
||||
{result.cache === 'hit' || result.cache === 'stale' ? <Badge tone="info">cache {result.cache}</Badge> : null}
|
||||
{result.truncated ? <Badge tone="warn">truncated</Badge> : null}
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{result.duration_ms} ms{result.upstream ? ` · ${result.upstream}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{result.error ? (
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{result.error}</p>
|
||||
) : null}
|
||||
{result.block_source ? (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">Blocked by {result.block_source}</p>
|
||||
) : null}
|
||||
{result.answers.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{result.answers.map((answer) => (
|
||||
<li key={answer} className="break-all font-mono text-xs">{answer}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : result.error === undefined ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No answer records.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Domain entries"
|
||||
subtitle={listed === '' ? undefined : `Records for ${listed}`}
|
||||
padded={false}
|
||||
actions={
|
||||
<Button variant="secondary" onClick={() => void listEntries()} loading={test.pending} disabled={trimmed === ''}>
|
||||
List entries
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
empty={
|
||||
<EmptyState
|
||||
title={listed === '' ? 'No domain looked up yet' : 'No records found'}
|
||||
description="Enter a name above and list its entries. Every entry can become a local-zone override."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={override !== null}
|
||||
onClose={() => setOverride(null)}
|
||||
title="Create an override"
|
||||
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void saveOverride()} loading={write.pending} disabled={!canWrite}>
|
||||
Create override
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Zone"
|
||||
hint="The apex to make authoritative."
|
||||
value={zone}
|
||||
onChange={(e) => setZone(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
label="Name"
|
||||
hint="Relative to the zone; @ is the apex."
|
||||
value={relName}
|
||||
onChange={(e) => setRelName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
label="Type"
|
||||
value={override?.type ?? ''}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="TTL"
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-100 px-3 py-2 font-mono text-xs break-all dark:bg-slate-800">
|
||||
{override?.name} {override?.ttl} IN {override?.type} {override?.value}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user