feat: add dnsmasq DNS record admin UI

- store records in SQLite via built-in node:sqlite, no native deps
- wrap DB reads in await io() so they stay out of the static shell
- add CRUD pages backed by Server Actions with per-request data
- generate managed config and restart container via Docker socket
- add /resolve page to query dnsmasq for A and CNAME records
- add Dockerfile and compose setup with shared config volume
This commit is contained in:
siujamo committed 2026-10-09 16:47:31 +08:00
1 parent c5585f2b13
commit 97ad09ad8a
27 files changed
+1344 -130

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
"use client"
import { useActionState } from "react"
import type { ActionState } from "@/lib/action-state"
export interface ApplyButtonProps {
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
}
export function ApplyButton({ action }: ApplyButtonProps) {
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
return (
<div className="space-y-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "应用中…" : "应用并重启 dnsmasq"}
</button>
</form>
{state.message ? (
<div className="space-y-1 text-sm">
<p
className={state.ok ? "text-emerald-700 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
{state.message}
</p>
{state.detail ? (
<p className="text-xs text-zinc-500 dark:text-zinc-400">{state.detail}</p>
) : null}
</div>
) : null}
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
"use client"
export function DeleteButton() {
return (
<button
type="submit"
onClick={(event) => {
if (!window.confirm("确定要删除这条记录吗?")) event.preventDefault()
}}
className="text-sm text-red-600 transition-opacity hover:opacity-70 dark:text-red-400">
删除
</button>
)
}
+127
View File
@@ -0,0 +1,127 @@
"use client"
import Link from "next/link"
import { useActionState } from "react"
import type { ActionState } from "@/lib/action-state"
import type { DnsRecord } from "@/lib/db"
const fieldClass =
"w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100"
const labelClass = "block text-sm font-medium text-zinc-700 dark:text-zinc-300"
function FieldError({ message }: { message?: string }) {
if (!message) return null
return <p className="mt-1 text-xs text-red-600 dark:text-red-400">{message}</p>
}
export interface RecordFormProps {
/** Server Action to run on submit: either create or update, both bound to this form. */
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
record?: DnsRecord
submitLabel: string
cancelHref: string
}
export function RecordForm({ action, record, submitLabel, cancelHref }: RecordFormProps) {
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
const errors = state.fieldErrors ?? {}
return (
<form action={formAction} className="max-w-xl space-y-4">
{record ? <input type="hidden" name="id" value={record.id} /> : null}
{state.message ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-300">
{state.message}
</p>
) : null}
<div>
<label className={labelClass} htmlFor="domain">
域名
</label>
<input
id="domain"
name="domain"
className={fieldClass}
defaultValue={record?.domain}
placeholder="nas.home"
autoComplete="off"
spellCheck={false}
/>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
无需通配符:dnsmasq 的 <code>address=/域名/</code> 已自动覆盖其所有子域名。
</p>
<FieldError message={errors.domain} />
</div>
<div>
<label className={labelClass} htmlFor="type">
记录类型
</label>
<select id="type" name="type" className={fieldClass} defaultValue={record?.type ?? "A"}>
<option value="A">A(域名 → IPv4)</option>
<option value="CNAME">CNAME(别名 → 域名)</option>
</select>
</div>
<div>
<label className={labelClass} htmlFor="value">
目标值
</label>
<input
id="value"
name="value"
className={fieldClass}
defaultValue={record?.value}
placeholder="192.168.1.10"
autoComplete="off"
spellCheck={false}
/>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
A 记录填 IPv4 地址,CNAME 记录填目标域名。
</p>
<FieldError message={errors.value} />
</div>
<div>
<label className={labelClass} htmlFor="comment">
备注
</label>
<input
id="comment"
name="comment"
className={fieldClass}
defaultValue={record?.comment}
placeholder="选填"
/>
<FieldError message={errors.comment} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
name="enabled"
defaultChecked={record ? record.enabled : true}
className="size-4 accent-zinc-900 dark:accent-zinc-100"
/>
启用(取消勾选则不会写入配置文件)
</label>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "保存中…" : submitLabel}
</button>
<Link
href={cancelHref}
className="rounded-md border border-black/10 px-4 py-2 text-sm text-zinc-700 transition-colors hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/5">
取消
</Link>
</div>
</form>
)
}