Files
nxdns/admin/src/features/configuration/RecordsTab.tsx
T
mokhtar 85b8be50a0
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
admin: overview redesign, device scope, one formatting contract (milestone 39)
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
2026-09-07 23:11:50 +02:00

319 lines
8.3 KiB
TypeScript

import { useId, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
localRecordUpdateMutation,
localRecordsQuery,
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
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} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div>
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
id={`${id}-name`}
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<Select
label="Type"
value={rtype}
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
</div>
<div>
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
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"
}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds)
</label>
<input
id={`${id}-ttl`}
type="number"
min={0}
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function RecordsTab() {
const query = useQuery(localRecordsQuery());
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
return (
<div>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
)}
<QueryPanel query={query}>
{(records) => (
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</QueryPanel>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}