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,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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user