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:
27 files changed
+1344
-130
No files matched your search
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { getRecord } from "@/lib/records"
|
||||
import { RecordForm } from "../../_components/RecordForm"
|
||||
import { updateRecordAction } from "../../actions"
|
||||
|
||||
export const metadata = { title: "编辑记录" }
|
||||
|
||||
export default function EditRecordPage({ params }: PageProps<"/records/[id]">) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">编辑记录</h1>
|
||||
|
||||
{/* params are awaited inside Suspense so the shell can still be prerendered */}
|
||||
<Suspense fallback={<p className="text-sm text-zinc-500 dark:text-zinc-400">加载中…</p>}>
|
||||
<EditRecordForm params={params} />
|
||||
</Suspense>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
async function EditRecordForm({ params }: Pick<PageProps<"/records/[id]">, "params">) {
|
||||
const { id } = await params
|
||||
const numericId = Number(id)
|
||||
|
||||
// Guard against /records/abc: Number() would hand NaN to SQLite.
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) notFound()
|
||||
|
||||
const record = await getRecord(numericId)
|
||||
|
||||
if (!record) notFound()
|
||||
|
||||
return (
|
||||
<RecordForm
|
||||
action={updateRecordAction}
|
||||
record={record}
|
||||
submitLabel="保存修改"
|
||||
cancelHref="/records"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { ActionState } from "@/lib/action-state"
|
||||
import { writeManagedConfig } from "@/lib/dnsmasq-config"
|
||||
import { restartDnsmasqContainer } from "@/lib/docker"
|
||||
import {
|
||||
DuplicateDomainError,
|
||||
createRecord,
|
||||
listEnabledRecords,
|
||||
removeRecord,
|
||||
setRecordEnabled,
|
||||
updateRecord,
|
||||
} from "@/lib/records"
|
||||
import { parseRecordForm } from "@/lib/validate"
|
||||
|
||||
const RECORDS_PATH = "/records"
|
||||
|
||||
function readId(formData: FormData): number | null {
|
||||
const raw = formData.get("id")
|
||||
const id = typeof raw === "string" ? Number(raw) : NaN
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
function asDuplicateState(error: unknown): ActionState | null {
|
||||
if (error instanceof DuplicateDomainError) {
|
||||
return { ok: false, fieldErrors: { domain: `域名 ${error.domain} 已存在` } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createRecordAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const parsed = parseRecordForm(formData)
|
||||
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
|
||||
|
||||
try {
|
||||
createRecord(parsed.input)
|
||||
} catch (error) {
|
||||
const duplicate = asDuplicateState(error)
|
||||
if (duplicate) return duplicate
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath(RECORDS_PATH)
|
||||
redirect(RECORDS_PATH)
|
||||
}
|
||||
|
||||
export async function updateRecordAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const id = readId(formData)
|
||||
if (id === null) return { ok: false, message: "记录不存在,可能已被删除" }
|
||||
|
||||
const parsed = parseRecordForm(formData)
|
||||
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
|
||||
|
||||
try {
|
||||
updateRecord(id, parsed.input)
|
||||
} catch (error) {
|
||||
const duplicate = asDuplicateState(error)
|
||||
if (duplicate) return duplicate
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath(RECORDS_PATH)
|
||||
redirect(RECORDS_PATH)
|
||||
}
|
||||
|
||||
export async function deleteRecordAction(formData: FormData): Promise<void> {
|
||||
const id = readId(formData)
|
||||
if (id === null) return
|
||||
removeRecord(id)
|
||||
revalidatePath(RECORDS_PATH)
|
||||
}
|
||||
|
||||
export async function toggleRecordAction(formData: FormData): Promise<void> {
|
||||
const id = readId(formData)
|
||||
if (id === null) return
|
||||
// The hidden field carries the state this record should move *to*.
|
||||
setRecordEnabled(id, formData.get("enabled") === "1")
|
||||
revalidatePath(RECORDS_PATH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the managed dnsmasq config from SQLite and restart the container.
|
||||
*
|
||||
* Deliberately manual rather than automatic, so several edits can be batched into one
|
||||
* restart (see the plan: 手动应用并重启).
|
||||
*/
|
||||
export async function applyConfigAction(
|
||||
_prev: ActionState,
|
||||
_formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const written = await writeManagedConfig(await listEnabledRecords())
|
||||
const reload = await restartDnsmasqContainer()
|
||||
|
||||
revalidatePath(RECORDS_PATH)
|
||||
|
||||
const writtenMessage = `已写入 ${written.recordCount} 条记录到 ${written.path}`
|
||||
|
||||
if (reload.status === "restarted") {
|
||||
return { ok: true, message: `${writtenMessage},${reload.detail}` }
|
||||
}
|
||||
|
||||
if (reload.status === "skipped") {
|
||||
return { ok: true, message: writtenMessage, detail: reload.detail }
|
||||
}
|
||||
|
||||
return { ok: false, message: `${writtenMessage},但重启失败`, detail: reload.detail }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link"
|
||||
import { RecordForm } from "../_components/RecordForm"
|
||||
import { createRecordAction } from "../actions"
|
||||
|
||||
export const metadata = { title: "新增记录" }
|
||||
|
||||
export default function NewRecordPage() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<Link
|
||||
href="/records"
|
||||
className="text-sm text-zinc-500 transition-opacity hover:opacity-70 dark:text-zinc-400">
|
||||
← 返回列表
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">新增记录</h1>
|
||||
</header>
|
||||
|
||||
<RecordForm action={createRecordAction} submitLabel="创建记录" cancelHref="/records" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link"
|
||||
import { Suspense } from "react"
|
||||
import { env } from "@/lib/env"
|
||||
import { listRecords } from "@/lib/records"
|
||||
import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions"
|
||||
import { ApplyButton } from "./_components/ApplyButton"
|
||||
import { DeleteButton } from "./_components/DeleteButton"
|
||||
|
||||
export const metadata = { title: "域名记录" }
|
||||
|
||||
const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
|
||||
|
||||
export default function RecordsPage() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">域名记录</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
记录保存在 SQLite 中,修改后需要「应用并重启」才会写入 dnsmasq 配置。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/records/new"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
|
||||
新增记录
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className={cardClass}>
|
||||
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">生效</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
配置文件:<code>{env.confPath}</code> · 目标容器:<code>{env.containerName}</code>
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ApplyButton action={applyConfigAction} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<p className="text-sm text-zinc-500 dark:text-zinc-400">加载中…</p>}>
|
||||
<RecordTable />
|
||||
</Suspense>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
async function RecordTable() {
|
||||
const records = await listRecords()
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
还没有任何记录,点右上角「新增记录」开始。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-black/10 dark:border-white/15">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-black/10 bg-zinc-50 text-xs uppercase text-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">域名</th>
|
||||
<th className="px-4 py-2 font-medium">类型</th>
|
||||
<th className="px-4 py-2 font-medium">目标值</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium">备注</th>
|
||||
<th className="px-4 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-black/5 dark:divide-white/10">
|
||||
{records.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td className="px-4 py-2 font-mono text-zinc-900 dark:text-zinc-100">{record.domain}</td>
|
||||
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.type}</td>
|
||||
<td className="px-4 py-2 font-mono text-zinc-600 dark:text-zinc-400">{record.value}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
record.enabled
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: "text-zinc-400 dark:text-zinc-500"
|
||||
}>
|
||||
{record.enabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.comment || "—"}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/records/${record.id}`}
|
||||
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
|
||||
编辑
|
||||
</Link>
|
||||
<form action={toggleRecordAction} className="inline">
|
||||
<input type="hidden" name="id" value={record.id} />
|
||||
<input type="hidden" name="enabled" value={record.enabled ? "0" : "1"} />
|
||||
<button
|
||||
type="submit"
|
||||
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
|
||||
{record.enabled ? "禁用" : "启用"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteRecordAction} className="inline">
|
||||
<input type="hidden" name="id" value={record.id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user