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