milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { LocalRecord, LocalRecordInput } from "@/lib/types";
let records: LocalRecord[];
let fetchMock: ReturnType<typeof createFetchMock>;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function createFetchMock() {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (url === "/api/local-records" && method === "GET") return json({ local_records: records });
if (url === "/api/local-records" && method === "POST") {
const body = JSON.parse(String(init?.body)) as LocalRecordInput;
const created: LocalRecord = { id: 99, ttl: body.ttl ?? 300, ...body };
records = [...records, created];
return json(created, 201);
}
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
return json({ error: "not stubbed" }, 404);
});
}
beforeEach(() => {
records = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.10", ttl: 300 }];
fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/local-dns"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("renders the records table and switches to the forward zones tab", async () => {
renderPage();
await screen.findByRole("heading", { name: "Local DNS" });
await screen.findByText("nas.lan.home");
expect(screen.getByText("192.168.1.10")).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("printer.lan.home");
const post = fetchMock.mock.calls.find(
([input, init]) => init?.method === "POST" && String(input) === "/api/local-records",
);
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
+78
View File
@@ -0,0 +1,78 @@
import { useState } from "react";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
type Tab = "records" | "zones";
function TabButton({
id,
controls,
selected,
onClick,
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return (
<section>
<h1 className="text-2xl font-semibold">Local DNS</h1>
<div
role="tablist"
aria-label="Local DNS"
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
>
<TabButton
id="tab-records"
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
</section>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
localRecordUpdateMutation,
localRecordsQuery,
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
function RecordForm({
initial,
busy,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
}) {
const id = useId();
const [name, setName] = useState(initial?.name ?? "");
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
const [value, setValue] = useState(initial?.value ?? "");
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
if (ttl.trim() !== "") input.ttl = Number(ttl);
onSubmit(input);
}
return (
<form
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
Name
</label>
<input
id={`${id}-name`}
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
Type
</label>
<select
id={`${id}-rtype`}
value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
className={inputClass}
>
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
Value
</label>
<input
id={`${id}-value`}
required
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
TTL (seconds)
</label>
<input
id={`${id}-ttl`}
type="number"
min={0}
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
className={inputClass}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const queryClient = useQueryClient();
const create = useMutation(localRecordCreateMutation(queryClient));
const update = useMutation(localRecordUpdateMutation(queryClient));
const remove = useMutation(localRecordDeleteMutation(queryClient));
const [form, setForm] = useState<FormState | null>(null);
function openForm(next: FormState) {
create.reset();
update.reset();
setForm(next);
}
function onSubmit(input: LocalRecordInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(record: LocalRecord) {
if (!window.confirm(`Delete record "${record.name}"?`)) return;
remove.mutate(record.id);
}
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
)}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
Name
</th>
<th scope="col" className="py-2 pr-4 font-medium">
Type
</th>
<th scope="col" className="py-2 pr-4 font-medium">
Value
</th>
<th scope="col" className="py-2 pr-4 font-medium">
TTL
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} className="py-4 text-zinc-500">
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.record.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", record })}
className={rowButtonClass}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
forwardZoneUpdateMutation,
forwardZonesQuery,
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
function ZoneForm({
initial,
busy,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
}) {
const id = useId();
const [zone, setZone] = useState(initial?.zone ?? "");
const [resolver, setResolver] = useState(initial?.resolver ?? "");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
}
return (
<form
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
Zone
</label>
<input
id={`${id}-zone`}
required
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
Resolver
</label>
<input
id={`${id}-resolver`}
required
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
className={inputClass}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const queryClient = useQueryClient();
const create = useMutation(forwardZoneCreateMutation(queryClient));
const update = useMutation(forwardZoneUpdateMutation(queryClient));
const remove = useMutation(forwardZoneDeleteMutation(queryClient));
const [form, setForm] = useState<FormState | null>(null);
function openForm(next: FormState) {
create.reset();
update.reset();
setForm(next);
}
function onSubmit(input: ForwardZoneInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(zone: ForwardZone) {
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
remove.mutate(zone.id);
}
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
)}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
Zone
</th>
<th scope="col" className="py-2 pr-4 font-medium">
Resolver
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} className="py-4 text-zinc-500">
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.zone.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", zone })}
className={rowButtonClass}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}