Merge pull request 'mroe ui' (#2) from feat/ui-fix into main
build / build-push (push) Successful in 31s
build / build-push (push) Successful in 31s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
commit
92917fdbed
13 files changed
+542
-26
No files matched your search
Binary file not shown.
+23
-23
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
|
||||
"recent_files": [
|
||||
"internal/qlog/qlog_test.go",
|
||||
"internal/app/app.go",
|
||||
"web/src/pages/Zones.tsx",
|
||||
"internal/cluster/cluster_test.go",
|
||||
"README.md",
|
||||
"internal/server/shutdown_test.go",
|
||||
@@ -23,34 +26,13 @@
|
||||
"internal/blocklist/parse.go",
|
||||
"internal/app/memory.go",
|
||||
"internal/api/auth.go",
|
||||
"internal/app/app.go",
|
||||
"internal/api/api.go",
|
||||
"internal/zonefile/zonefile_test.go",
|
||||
"internal/engine/pins_test.go",
|
||||
"internal/engine/harness_test.go",
|
||||
"internal/metrics/metrics.go",
|
||||
"internal/qlog/counts.go",
|
||||
"internal/metrics/metrics_test.go"
|
||||
"internal/metrics/metrics.go"
|
||||
],
|
||||
"queries": [
|
||||
{
|
||||
"query": "func Load|KnownFields|yaml.NewDecoder|func Marshal",
|
||||
"command": "grep",
|
||||
"at": "2026-09-27T16:22:55.810381382Z",
|
||||
"focus_path": "internal/config/config.go"
|
||||
},
|
||||
{
|
||||
"query": "NSID|OutcomeLocal|ActionAllow|ActionLocal",
|
||||
"command": "grep",
|
||||
"at": "2026-09-27T16:22:55.85487649Z",
|
||||
"focus_path": "internal/engine/engine_test.go"
|
||||
},
|
||||
{
|
||||
"query": "^func Test",
|
||||
"command": "grep",
|
||||
"at": "2026-09-27T16:23:07.872849833Z",
|
||||
"focus_path": "internal/config/size_test.go"
|
||||
},
|
||||
{
|
||||
"query": "Outcome|func Record\\(|local",
|
||||
"command": "grep",
|
||||
@@ -329,8 +311,26 @@
|
||||
"query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written",
|
||||
"command": "grep",
|
||||
"at": "2026-09-27T17:14:02.764934975Z"
|
||||
},
|
||||
{
|
||||
"query": "(?i)name:|zone:",
|
||||
"command": "grep",
|
||||
"at": "2026-09-28T08:14:05.218032007Z",
|
||||
"focus_path": "web/src/pages/Zones.tsx"
|
||||
},
|
||||
{
|
||||
"query": "func \\(a Action\\) String|StringToType|func \\(s \\*Server\\) request\\(",
|
||||
"command": "grep",
|
||||
"at": "2026-09-28T08:14:17.238295025Z",
|
||||
"focus_path": "internal/app/app.go"
|
||||
},
|
||||
{
|
||||
"query": "Action|type Action",
|
||||
"command": "grep",
|
||||
"at": "2026-09-28T08:15:04.827470149Z",
|
||||
"focus_path": "internal/qlog/qlog_test.go"
|
||||
}
|
||||
],
|
||||
"ast_pins": [],
|
||||
"updated_at": "2026-09-27T17:14:02.764941775Z"
|
||||
"updated_at": "2026-09-28T08:15:04.827473319Z"
|
||||
}
|
||||
@@ -543,6 +543,7 @@ is on.
|
||||
| GET | `/api/domains?q=&action=&list=&page=&per_page=` | — | page the compiled rules |
|
||||
| GET | `/api/domains/check?name=…` | — | explain how a name would be treated (the fastest "why isn't this blocked" tool) |
|
||||
| POST | `/api/domains` | yes | add an inline rule |
|
||||
| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request |
|
||||
| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule |
|
||||
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (`error`) |
|
||||
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
|
||||
|
||||
@@ -65,6 +65,9 @@ type Hooks struct {
|
||||
ToggleBlocklist func(name string, enabled bool) error
|
||||
// DomainCheck explains how a name would be treated.
|
||||
DomainCheck func(name string) (any, error)
|
||||
// Resolve runs one query through the live pipeline and reports the
|
||||
// outcome, so the console can test the server it is talking to.
|
||||
Resolve func(ctx context.Context, name, qtype string) (any, error)
|
||||
// ListDomains pages the compiled rules.
|
||||
ListDomains func(q DomainQuery) (any, error)
|
||||
// AddDomain adds an inline rule and persists it.
|
||||
@@ -286,6 +289,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
|
||||
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
|
||||
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
|
||||
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
|
||||
mux.Handle("POST "+base+"dns/test", s.authed(s.handleDNSTest, false))
|
||||
mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false))
|
||||
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
|
||||
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
|
||||
|
||||
@@ -177,6 +177,37 @@ func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
// handleDNSTest answers one query through the live pipeline. It is the
|
||||
// console's "is my DNS actually answering" button: the answer comes from the
|
||||
// same engine the listeners use, so what it shows is what a client gets, and
|
||||
// a query error is reported inside the payload rather than as a status code —
|
||||
// a failed lookup is a result here, not a failed request.
|
||||
func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) {
|
||||
if s.hooks.Resolve == nil {
|
||||
writeError(w, http.StatusNotImplemented, "dns test not available")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
QType string `json:"type"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
v, err := s.hooks.Resolve(r.Context(), name, in.QType)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) {
|
||||
if s.hooks.LocalZones == nil {
|
||||
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
|
||||
|
||||
+58
-1
@@ -2,7 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
||||
@@ -35,6 +38,7 @@ func (a *App) hooks() api.Hooks {
|
||||
RefreshBlocklists: a.refreshBlocklists,
|
||||
ToggleBlocklist: a.toggleBlocklist,
|
||||
DomainCheck: a.domainCheck,
|
||||
Resolve: a.resolveTest,
|
||||
ListDomains: a.listDomains,
|
||||
AddDomain: a.addDomain,
|
||||
DeleteDomain: a.deleteDomain,
|
||||
@@ -398,7 +402,60 @@ func (a *App) domainCheck(name string) (any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listDomains pages the compiled rules. Walking a million-entry map on every
|
||||
// resolveTest runs one query through the live pipeline. It goes through the
|
||||
// engine rather than straight to an upstream: a test that skipped the
|
||||
// pipeline would report a healthy upstream while the blocklist, the ACL or the
|
||||
// local zones were what a client actually hit.
|
||||
func (a *App) resolveTest(ctx context.Context, name, qtype string) (any, error) {
|
||||
if a.engine == nil {
|
||||
return nil, errors.New("the query engine is not running")
|
||||
}
|
||||
qt := dns.TypeA
|
||||
if qtype = strings.TrimSpace(qtype); qtype != "" {
|
||||
var ok bool
|
||||
qt, ok = dns.StringToType[strings.ToUpper(qtype)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown record type %q", qtype)
|
||||
}
|
||||
}
|
||||
q := new(dns.Msg)
|
||||
q.SetQuestion(dns.Fqdn(name), qt)
|
||||
q.RecursionDesired = true
|
||||
res := a.engine.Handle(ctx, engine.Request{
|
||||
Msg: q,
|
||||
Remote: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
|
||||
Transport: "api",
|
||||
Start: time.Now(),
|
||||
})
|
||||
out := map[string]any{
|
||||
"name": dns.Fqdn(name),
|
||||
"type": dns.TypeToString[qt],
|
||||
"rcode": "SERVFAIL",
|
||||
"action": string(res.Action),
|
||||
"duration_ms": res.Duration.Milliseconds(),
|
||||
"upstream": res.Upstream,
|
||||
"group": res.Group,
|
||||
"cache": res.CacheKind,
|
||||
"local": res.Local,
|
||||
"truncated": res.Truncated,
|
||||
"blocked": res.BlockSource != "",
|
||||
"block_source": res.BlockSource,
|
||||
"answers": []string{},
|
||||
}
|
||||
if res.Msg == nil {
|
||||
out["error"] = "no response"
|
||||
return out, nil
|
||||
}
|
||||
out["rcode"] = dns.RcodeToString[res.Msg.Rcode]
|
||||
answers := make([]string, 0, len(res.Msg.Answer))
|
||||
for _, rr := range res.Msg.Answer {
|
||||
answers = append(answers, strings.TrimSpace(rr.String()))
|
||||
}
|
||||
out["answers"] = answers
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listDomains pages the compiled rules.
|
||||
// request would be too slow, so the filter runs over a bounded sample unless
|
||||
// the query is a prefix/exact lookup, which the LRU can answer directly.
|
||||
func (a *App) listDomains(q api.DomainQuery) (any, error) {
|
||||
|
||||
@@ -608,6 +608,57 @@ func TestDomainAddInvalid(t *testing.T) {
|
||||
|
||||
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
|
||||
// saved group is enforced immediately.
|
||||
// The console's "Test" button goes through the live pipeline, so this pins
|
||||
// the whole path: a name the fake upstream answers must come back with the
|
||||
// answer records, and one it does not must come back as a result (a 200 with
|
||||
// an rcode) rather than as a failed request.
|
||||
func TestDNSTestEndpoint(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
code, body := h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"A"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("POST /api/dns/test = %d: %s", code, body)
|
||||
}
|
||||
var hit struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
RCode string `json:"rcode"`
|
||||
Action string `json:"action"`
|
||||
Answers []string `json:"answers"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &hit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hit.Name != "www.example.test." || hit.Type != "A" {
|
||||
t.Errorf("canonical name = %q type = %q", hit.Name, hit.Type)
|
||||
}
|
||||
if hit.RCode != "NOERROR" {
|
||||
t.Fatalf("rcode = %q, want NOERROR: %s", hit.RCode, body)
|
||||
}
|
||||
if len(hit.Answers) != 1 || !strings.Contains(hit.Answers[0], "203.0.113.7") {
|
||||
t.Errorf("answers = %v, want the upstream A record", hit.Answers)
|
||||
}
|
||||
|
||||
// A name the upstream does not hold is still a successful test.
|
||||
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"nothing.example.com","type":"A"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("POST /api/dns/test (miss) = %d: %s", code, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "NXDOMAIN") {
|
||||
t.Errorf("a miss is not reported as a result: %s", body)
|
||||
}
|
||||
|
||||
// An unknown record type is a bad request the caller can fix, so it comes
|
||||
// back as an error in the payload rather than as a bogus lookup.
|
||||
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"NOTATYPE"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("POST /api/dns/test (bad type) = %d: %s", code, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "error") {
|
||||
t.Errorf("a bad type is not reported: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLUpdateRoundTrip(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
// Write a deny group through the API.
|
||||
|
||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<script type="module" crossorigin src="/assets/index-BFc4v6ED.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYPXMp15.css">
|
||||
<script type="module" crossorigin src="/assets/index-CzcgOHn-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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