From 31188e019b3b3beb1a61db9f5cb2b40a7f14fffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Sj=C3=B6beck?= Date: Fri, 1 May 2026 13:28:40 +0200 Subject: [PATCH] Import dropdown: pull a zone live from a public resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Topbar's "Import…" button is now a dropdown that offers the existing file-pick path plus three "From public DNS" entries — one per configured DoH resolver (Cloudflare, Google, DNS.SB). Picking a resolver opens an inline form for the domain. On submit we fan out DoH queries for the apex RR types (SOA, NS, A, AAAA, MX, TXT, CAA) plus a small set of well-known subdomain probes (www, _dmarc, mail, autodiscover) and assemble a Zone from the answers. SOA TTL becomes the zone $TTL; per-record TTLs come straight from the resolver's response. Per-type errors are collected and surfaced through the existing parseErrors panel so a partial import is still usable. The import button shows "Resolving…" and disables while a fan-out is in flight. New module \`src/dns/import.ts\` is the pure logic; \`useZoneFile\` gains \`importFromDns(resolverId, domain)\` and an \`isResolving\` flag. New component \`ImportMenu\` owns the dropdown + the inline domain form and replaces the inline file-input button in Topbar. 9 new tests cover the apex fan-out, MX/TXT/SOA shape, domain normalization, unknown resolver, empty domain, and network failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 13 ++- src/components/ImportMenu.tsx | 128 ++++++++++++++++++++++++++++ src/components/Topbar.tsx | 23 ++--- src/dns/import.ts | 152 ++++++++++++++++++++++++++++++++++ src/hooks/useZoneFile.ts | 29 ++++++- src/styles.css | 95 +++++++++++++++++++++ tests/dns-import.test.ts | 131 +++++++++++++++++++++++++++++ 7 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 src/components/ImportMenu.tsx create mode 100644 src/dns/import.ts create mode 100644 tests/dns-import.test.ts diff --git a/src/App.tsx b/src/App.tsx index 5c7e8f0..ed1d514 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,7 +29,7 @@ export function App() { const [zone, setZone] = useState(emptyZone); const [typeFilter, setTypeFilter] = useState("ALL"); const [selectedPath, setSelectedPath] = useState([]); - const { parseErrors, importedName, importFile, exportZone, reset } = useZoneFile(); + const { parseErrors, importedName, isResolving, importFile, importFromDns, exportZone, reset } = useZoneFile(); const dns = useDnsCheck(); const issues = useMemo(() => validateZone(zone), [zone]); @@ -72,6 +72,15 @@ export function App() { } }; + const handleImportFromDns = async (resolverId: string, domain: string) => { + dns.reset(); + const next = await importFromDns(resolverId, domain); + if (next) { + setZone(next); + setSelectedPath([]); + } + }; + const handleNew = () => { setZone(emptyZone()); reset(); @@ -127,7 +136,9 @@ export function App() { exportZone(zone)} onNew={handleNew} onAddRecord={handleAddRecord} diff --git a/src/components/ImportMenu.tsx b/src/components/ImportMenu.tsx new file mode 100644 index 0000000..719a675 --- /dev/null +++ b/src/components/ImportMenu.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { DEFAULT_RESOLVERS } from "../dns/resolvers"; + +interface ImportMenuProps { + onImportFile: (file: File) => void; + onImportFromDns: (resolverId: string, domain: string) => void; + isResolving: boolean; +} + +export function ImportMenu({ onImportFile, onImportFromDns, isResolving }: ImportMenuProps) { + const [open, setOpen] = useState(false); + const [resolverId, setResolverId] = useState(null); + const [domain, setDomain] = useState(""); + const fileInput = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!open && resolverId === null) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setResolverId(null); + } + }; + const keyHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setResolverId(null); + } + }; + window.addEventListener("mousedown", handler); + window.addEventListener("keydown", keyHandler); + return () => { + window.removeEventListener("mousedown", handler); + window.removeEventListener("keydown", keyHandler); + }; + }, [open, resolverId]); + + const handlePickResolver = (id: string) => { + setOpen(false); + setResolverId(id); + setDomain(""); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const trimmed = domain.trim(); + if (!trimmed || !resolverId) return; + onImportFromDns(resolverId, trimmed); + setResolverId(null); + setDomain(""); + }; + + const activeResolver = resolverId + ? DEFAULT_RESOLVERS.find((r) => r.id === resolverId) + : null; + + return ( +
+ + { + const f = e.target.files?.[0]; + if (f) onImportFile(f); + e.target.value = ""; + setOpen(false); + }} + /> + + {open && ( +
+ +
+
From public DNS
+ {DEFAULT_RESOLVERS.map((r) => ( + + ))} +
+ )} + + {activeResolver && ( +
+ Resolve from {activeResolver.name}: + setDomain(e.target.value)} + placeholder="example.com" + /> + + +
+ )} +
+ ); +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index c56b973..0d80527 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,11 +1,13 @@ -import { useRef } from "react"; import { GitHubLink } from "./GitHubLink"; +import { ImportMenu } from "./ImportMenu"; import { Logo } from "./Logo"; interface TopbarProps { canExport: boolean; isChecking: boolean; + isResolving: boolean; onImport: (file: File) => void; + onImportFromDns: (resolverId: string, domain: string) => void; onExport: () => void; onNew: () => void; onAddRecord: () => void; @@ -16,15 +18,15 @@ interface TopbarProps { export function Topbar({ canExport, isChecking, + isResolving, onImport, + onImportFromDns, onExport, onNew, onAddRecord, onCheck, onCancelCheck, }: TopbarProps) { - const fileInput = useRef(null); - return (
@@ -32,17 +34,10 @@ export function Topbar({

Codabind

- - { - const f = e.target.files?.[0]; - if (f) onImport(f); - e.target.value = ""; - }} +