rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import Dialog from "@/ui/Dialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
groups: Group[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
dialogInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
// Adopting the learned name as a typed one is the natural gesture, but only
|
||||
// where the save can land: under file authority the PUT answers 403, and the
|
||||
// file's declared name is the one that wins.
|
||||
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
|
||||
const [groupId, setGroupId] = useState(client.group_id);
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||
<form
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate(
|
||||
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
label="Group"
|
||||
variant="compactField"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen, within } 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";
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
learned_name: "laptop-1.lan",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
learned_name: "kids-tablet.lan",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PREFIXES = {
|
||||
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderClientsPage(map: Record<string, unknown>) {
|
||||
stubFetch(map);
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Clients" });
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/version": VERSION,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders the client table with group names and one hand-edited badge", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(screen.getAllByText("edited")).toHaveLength(1);
|
||||
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a named row shows the typed name and hides the learned one", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(screen.queryByText("laptop-1.lan")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unnamed row shows the learned name with the learned affordance", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
// The cell holds the learned name followed by the tag, so the match is on
|
||||
// the containing span rather than on a bare text node.
|
||||
const learned = screen.getByText(
|
||||
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
|
||||
);
|
||||
// The affordance is text, not colour, so a screen reader announces it too.
|
||||
expect(within(learned).getByText("learned")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
});
|
||||
|
||||
test("edit opens a dialog seeded with the client's name and group", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
// The dialog portals out of the table, so every field query is scoped to it.
|
||||
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
// The RAC Select names its trigger with the value and then the label.
|
||||
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
|
||||
});
|
||||
|
||||
test("the group picker offers every group and reports the choice", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
||||
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "kids" }));
|
||||
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
||||
});
|
||||
|
||||
test("prefix editor starts clean and dirties on add", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
|
||||
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
|
||||
expect(save.disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Client } from "@/lib/types";
|
||||
import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
/**
|
||||
* Deleting an observed row discards runtime state the file never declared, so
|
||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "48rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
right: {
|
||||
textAlign: "right",
|
||||
},
|
||||
dash: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* A learned name is runtime state, not something the operator typed, so it
|
||||
* reads muted and carries an outlined "learned" tag. The tag is real text —
|
||||
* a screen reader announces it — because colour alone is not an affordance.
|
||||
*/
|
||||
learnedTag: {
|
||||
marginLeft: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.primary,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
/**
|
||||
* The accessible name of the actions column, kept out of the visual table
|
||||
* without leaving the accessibility tree.
|
||||
*/
|
||||
});
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
{clients.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
nothing to create by hand.
|
||||
</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th {...stylex.props(styles.cell)}>IP</th>
|
||||
<th {...stylex.props(styles.cell)}>Name</th>
|
||||
<th {...stylex.props(styles.cell)}>Group</th>
|
||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||
<th {...stylex.props(styles.cell)}>Last seen</th>
|
||||
<th {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
{client.name !== "" ? (
|
||||
client.name
|
||||
) : client.learned_name !== "" ? (
|
||||
<span {...stylex.props(styles.dash)}>
|
||||
{client.learned_name}
|
||||
<span {...stylex.props(styles.learnedTag)}>learned</span>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.dash)}>—</span>
|
||||
)}
|
||||
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
<span {...stylex.props(styles.note)}>
|
||||
Deleted clients re-materialize on their next DNS query.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? DECLARED_CLIENT_NOTE
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useReducer, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.25rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
rows: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
prefixInput: {
|
||||
width: "13rem",
|
||||
},
|
||||
priorityInput: {
|
||||
width: "5rem",
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
validation: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
if (problem !== null) return;
|
||||
mutation.mutate(toInputs(state.rows), {
|
||||
onSuccess: (stored) => dispatch({ type: "reset", prefixes: stored }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.section)}>
|
||||
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
|
||||
match wins.
|
||||
</p>
|
||||
{state.rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
|
||||
) : (
|
||||
<ul {...stylex.props(styles.rows)}>
|
||||
{state.rows.map((row, index) => (
|
||||
<li key={index} {...stylex.props(styles.row)}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Prefix ${index + 1}`}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
||||
}
|
||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`Group for prefix ${index + 1}`}
|
||||
variant="inline"
|
||||
value={String(row.group_id)}
|
||||
onChange={(value) =>
|
||||
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
|
||||
}
|
||||
options={groupOptions}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for prefix ${index + 1}`}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
||||
}
|
||||
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "remove", index })}
|
||||
{...stylex.props(styles.removeButton, shared.focusRing)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Add prefix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save prefixes
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ClientPrefix } from "@/lib/types";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
isDirty,
|
||||
prefixEditorReducer,
|
||||
toInputs,
|
||||
type PrefixEditorState,
|
||||
} from "./prefixEditor";
|
||||
|
||||
const server: ClientPrefix[] = [
|
||||
{ id: 1, prefix: "192.168.1.0/24", group_id: 1, group: "default", priority: 100 },
|
||||
{ id: 2, prefix: "10.0.0.0/8", group_id: 2, group: "kids", priority: 50 },
|
||||
];
|
||||
|
||||
test("init mirrors the server rows into baseline and rows", () => {
|
||||
const state = initPrefixEditor(server);
|
||||
expect(state.rows).toEqual([
|
||||
{ prefix: "192.168.1.0/24", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" },
|
||||
]);
|
||||
expect(state.baseline).toEqual(state.rows);
|
||||
expect(isDirty(state)).toBe(false);
|
||||
});
|
||||
|
||||
test("add appends an empty row with the given group and marks dirty", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||
expect(state.rows).toHaveLength(3);
|
||||
expect(state.rows[2]).toEqual({ prefix: "", group_id: 1, priority: "" });
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("remove drops the row at the index", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), { type: "remove", index: 0 });
|
||||
expect(state.rows).toEqual([{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" }]);
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("edit patches a single row", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), {
|
||||
type: "edit",
|
||||
index: 1,
|
||||
patch: { group_id: 1, priority: "10" },
|
||||
});
|
||||
expect(state.rows[1]).toEqual({ prefix: "10.0.0.0/8", group_id: 1, priority: "10" });
|
||||
expect(state.rows[0]).toEqual(state.baseline[0]);
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("editing a field back to its baseline value is clean again", () => {
|
||||
let state: PrefixEditorState = initPrefixEditor(server);
|
||||
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "7" } });
|
||||
expect(isDirty(state)).toBe(true);
|
||||
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "100" } });
|
||||
expect(isDirty(state)).toBe(false);
|
||||
});
|
||||
|
||||
test("reset adopts new server rows and clears dirtiness", () => {
|
||||
let state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||
state = prefixEditorReducer(state, { type: "reset", prefixes: server });
|
||||
expect(isDirty(state)).toBe(false);
|
||||
expect(state.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||
expect(
|
||||
toInputs([
|
||||
{ prefix: " 192.168.1.0/24 ", group_id: 1, priority: "25" },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2, priority: "" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ prefix: "192.168.1.0/24", group_id: 1, priority: 25 },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||
expect(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||
]),
|
||||
).toBe("Row 2: priority must be a whole number.");
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||
|
||||
export interface PrefixRow {
|
||||
prefix: string;
|
||||
group_id: number;
|
||||
/** Raw input text; empty means "use the server default (100)". */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface PrefixEditorState {
|
||||
baseline: PrefixRow[];
|
||||
rows: PrefixRow[];
|
||||
}
|
||||
|
||||
export type PrefixEditorAction =
|
||||
| { type: "reset"; prefixes: ClientPrefix[] }
|
||||
| { type: "add"; groupId: number }
|
||||
| { type: "remove"; index: number }
|
||||
| { type: "edit"; index: number; patch: Partial<PrefixRow> };
|
||||
|
||||
function fromServer(prefixes: ClientPrefix[]): PrefixRow[] {
|
||||
return prefixes.map((p) => ({ prefix: p.prefix, group_id: p.group_id, priority: String(p.priority) }));
|
||||
}
|
||||
|
||||
export function initPrefixEditor(prefixes: ClientPrefix[]): PrefixEditorState {
|
||||
const rows = fromServer(prefixes);
|
||||
return { baseline: rows, rows };
|
||||
}
|
||||
|
||||
export function prefixEditorReducer(state: PrefixEditorState, action: PrefixEditorAction): PrefixEditorState {
|
||||
switch (action.type) {
|
||||
case "reset":
|
||||
return initPrefixEditor(action.prefixes);
|
||||
case "add":
|
||||
return { ...state, rows: [...state.rows, { prefix: "", group_id: action.groupId, priority: "" }] };
|
||||
case "remove":
|
||||
return { ...state, rows: state.rows.filter((_, i) => i !== action.index) };
|
||||
case "edit":
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((row, i) => (i === action.index ? { ...row, ...action.patch } : row)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sameRow(a: PrefixRow, b: PrefixRow): boolean {
|
||||
return a.prefix === b.prefix && a.group_id === b.group_id && a.priority === b.priority;
|
||||
}
|
||||
|
||||
export function isDirty(state: PrefixEditorState): boolean {
|
||||
if (state.rows.length !== state.baseline.length) return true;
|
||||
return state.rows.some((row, i) => {
|
||||
const base = state.baseline[i];
|
||||
return base === undefined || !sameRow(row, base);
|
||||
});
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toInputs(rows: PrefixRow[]): ClientPrefixInput[] {
|
||||
return rows.map((row) => {
|
||||
const input: ClientPrefixInput = { prefix: row.prefix.trim(), group_id: row.group_id };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "") input.priority = Number(priority);
|
||||
return input;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user