milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderAt } from "./testFixtures";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("a cold deep link fetches the list once and renders the client (D9)", async () => {
|
||||
const { fetchMock } = renderAt("/clients/1", BASE);
|
||||
|
||||
await screen.findByRole("heading", { name: "laptop" });
|
||||
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||
const listCalls = fetchMock.mock.calls.filter(([input]) => String(input) === "/api/clients");
|
||||
expect(listCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("identity carries the name, its provenance and both timestamps", async () => {
|
||||
renderAt("/clients/2", BASE);
|
||||
|
||||
// The unnamed row is the learned one, and Clients is where the marker shows.
|
||||
const heading = await screen.findByRole("heading", { name: /kids-tablet\.lan/ });
|
||||
expect(within(heading).getByText("learned")).toBeTruthy();
|
||||
expect(screen.getByText(/This client appeared from DNS traffic/)).toBeTruthy();
|
||||
expect(screen.getByText("First seen")).toBeTruthy();
|
||||
expect(screen.getByText("Last seen")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a hand-set row reads as edited under database authority", async () => {
|
||||
renderAt("/clients/1", BASE);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/The name and group were set here/)).toBeTruthy());
|
||||
});
|
||||
|
||||
test("the same row reads as declared under file authority", async () => {
|
||||
renderAt("/clients/1", { ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
|
||||
await screen.findByText(/Declared in \/etc\/nxdns\/config\.zon/);
|
||||
});
|
||||
|
||||
test("an unresolved authority names the doubt instead of picking a provenance", async () => {
|
||||
renderAt("/clients/1", { ...BASE, "GET /api/config/status": NEVER });
|
||||
|
||||
await screen.findByText(/Either declared in the configuration file or edited here/);
|
||||
});
|
||||
|
||||
test("an id the list does not contain renders the missing-client state (D9)", async () => {
|
||||
renderAt("/clients/99", BASE);
|
||||
|
||||
await screen.findByRole("heading", { name: "No such client" });
|
||||
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("policy links to the group that filters this client", async () => {
|
||||
renderAt("/clients/2", BASE);
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Group settings" });
|
||||
expect(link.getAttribute("href")).toBe("/configuration/protection?tab=groups&group=2");
|
||||
});
|
||||
|
||||
test("the Activity affordance is a real link, with bounds minted at interaction time", async () => {
|
||||
const { router } = renderAt("/clients/1", BASE);
|
||||
const link = await screen.findByRole("link", { name: /last 24 hours/i });
|
||||
|
||||
// A real <a href> is what makes middle-click, copy-link and open-in-new-tab
|
||||
// work; the bounds must be in that href before the click, not minted by a
|
||||
// handler the browser never runs on those gestures.
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toMatch(/^\/activity\?/);
|
||||
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
fireEvent.pointerDown(link);
|
||||
fireEvent.click(link);
|
||||
const after = Math.floor(Date.now() / 1000);
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
|
||||
const search = router.state.location.search as { mode: string; client: string; since: number; until: number };
|
||||
expect(search.mode).toBe("history");
|
||||
expect(search.client).toBe("192.168.1.10");
|
||||
expect(search.until).toBeGreaterThanOrEqual(before - 1);
|
||||
expect(search.until).toBeLessThanOrEqual(after + 1);
|
||||
expect(search.until - search.since).toBe(24 * 60 * 60);
|
||||
});
|
||||
|
||||
test("keyboard activation re-mints the bounds, not the focus that preceded it by hours", async () => {
|
||||
const { router } = renderAt("/clients/1", BASE);
|
||||
const link = await screen.findByRole("link", { name: /last 24 hours/i });
|
||||
|
||||
// Focus can be hours old by the time Enter lands, so the window it minted is
|
||||
// stale; the keypress itself is what must be bracketed.
|
||||
fireEvent.focus(link);
|
||||
const pressedAt = Date.now() + 3 * 60 * 60 * 1000;
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(pressedAt);
|
||||
try {
|
||||
fireEvent.keyDown(link, { key: "Enter" });
|
||||
// jsdom does not synthesize the click Enter produces on a real anchor.
|
||||
fireEvent.click(link);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
|
||||
const search = router.state.location.search as { client: string; since: number; until: number };
|
||||
expect(search.client).toBe("192.168.1.10");
|
||||
expect(search.until).toBe(Math.floor(pressedAt / 1000));
|
||||
expect(search.until - search.since).toBe(24 * 60 * 60);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import { useAuthority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
|
||||
|
||||
/** The Activity window this page offers: the last day, in seconds. */
|
||||
const ACTIVITY_WINDOW_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const nowInSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
heading: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
address: {
|
||||
marginTop: "0.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: "1rem",
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
gap: "0.5rem 1rem",
|
||||
gridTemplateColumns: {
|
||||
default: "auto",
|
||||
"@media (min-width: 640px)": "max-content 1fr",
|
||||
},
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
term: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
value: {
|
||||
margin: 0,
|
||||
},
|
||||
provenanceDetail: {
|
||||
marginTop: "0.125rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
sectionHeading: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
prose: {
|
||||
marginTop: "0.5rem",
|
||||
maxWidth: "48rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.5rem",
|
||||
},
|
||||
links: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All clients
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One client, addressed by the row id `PUT /api/clients/{id}` already uses.
|
||||
*
|
||||
* No endpoint answers for a single client, so the list is the source: the route
|
||||
* loader ensures it, which makes a cold deep link one fetch rather than a
|
||||
* miss. An id the loaded list does not contain is a row that was deleted or
|
||||
* never existed — a state this page explains once, with no refetch behind it.
|
||||
*/
|
||||
export default function ClientDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/clients/$id" });
|
||||
const clientId = Number(id);
|
||||
const { data, error, isPending, refetch } = useQuery(clientsQuery());
|
||||
const authority = useAuthority();
|
||||
// The Activity window is relative to now, and a real link must carry its
|
||||
// bounds in the href before the click — that is what makes middle-click,
|
||||
// copy-link and open-in-new-tab work. A page left open would otherwise link
|
||||
// to yesterday's day, so the window is re-minted when the link is about to be
|
||||
// used: pointerdown precedes the click, and keydown precedes the click Enter
|
||||
// synthesizes. Focus re-mints too, but it cannot be the last word — a link can
|
||||
// hold focus for hours before the keypress. All three are discrete events, so
|
||||
// the href is fresh by the time navigation reads it.
|
||||
const [activityUntil, setActivityUntil] = useState(nowInSeconds);
|
||||
const freshenActivityWindow = () => setActivityUntil(nowInSeconds());
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading client…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<BackLink />
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const client = data.find((row) => row.id === clientId);
|
||||
if (client === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<BackLink />
|
||||
<h1 {...stylex.props(styles.heading)}>No such client</h1>
|
||||
<p {...stylex.props(styles.prose)}>
|
||||
nxdns has no client with id {id}. It was deleted, or the link was to a row that never existed. A
|
||||
device that is still on the network reappears in the list on its next DNS query.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const provenance = provenanceOf(client, authority);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<BackLink />
|
||||
<h1 {...stylex.props(styles.heading)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</h1>
|
||||
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
|
||||
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>Name</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</dd>
|
||||
<dt {...stylex.props(styles.term)}>Provenance</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
{provenance.label}
|
||||
<span {...stylex.props(styles.provenanceDetail)}> — {provenance.detail}</span>
|
||||
</dd>
|
||||
<dt {...stylex.props(styles.term)}>First seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(client.first_seen)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
|
||||
<p {...stylex.props(styles.prose)}>
|
||||
Filtering for this client follows the <strong>{client.group}</strong> group: its safe search setting,
|
||||
its blocklist sources and its rules.
|
||||
</p>
|
||||
<p {...stylex.props(styles.links)}>
|
||||
<Link
|
||||
to="/configuration/protection"
|
||||
search={{ tab: "groups", group: client.group_id }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Group settings
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Activity</h2>
|
||||
<p {...stylex.props(styles.links)}>
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{
|
||||
mode: "history",
|
||||
client: client.ip,
|
||||
since: activityUntil - ACTIVITY_WINDOW_SECONDS,
|
||||
until: activityUntil,
|
||||
domain: undefined,
|
||||
blocked: undefined,
|
||||
}}
|
||||
onPointerDown={freshenActivityWindow}
|
||||
onFocus={freshenActivityWindow}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") freshenActivityWindow();
|
||||
}}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Queries from this client, last 24 hours
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useReadOnlyConfig } from "@/features/configuration/authority";
|
||||
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";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
@@ -39,20 +41,30 @@ const styles = stylex.create({
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
lockNote: {
|
||||
marginRight: "auto",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
// Adopting the learned name as a typed one is the natural gesture.
|
||||
const [name, setName] = useState(client.name === "" ? client.learned_name : client.name);
|
||||
const [groupId, setGroupId] = useState(client.group_id);
|
||||
// The dialog opens only where the save can land, but authority is polled and
|
||||
// can turn under an open dialog. So the save path consults it on every render
|
||||
// rather than trusting the state that was true when the dialog opened; the
|
||||
// draft stays on screen, locked, instead of vanishing mid-edit.
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
@@ -61,6 +73,9 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
// Enter in the name field submits without the Save button, so
|
||||
// the lock lives here too, not only in what is rendered.
|
||||
if (readOnly) return;
|
||||
mutation.mutate(
|
||||
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||
{ onSuccess: onClose },
|
||||
@@ -86,17 +101,24 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
/>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
{readOnly && (
|
||||
<span {...stylex.props(styles.lockNote)}>
|
||||
This edit can no longer be saved.
|
||||
<ConfigLockIndicator />
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,121 +1,78 @@
|
||||
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";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ClientName, type ClientNames } from "./clientNames";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
|
||||
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" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
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" });
|
||||
function assignmentsSection(): HTMLElement {
|
||||
const heading = screen.getByRole("heading", { name: /Network assignments/ });
|
||||
const section = heading.closest("section");
|
||||
if (section === null) throw new Error("no network assignments section");
|
||||
return section;
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/version": VERSION,
|
||||
};
|
||||
/**
|
||||
* Editing a client writes configuration, so the affordance is absent until the
|
||||
* status query says the database owns it — never disabled, never assumed.
|
||||
*/
|
||||
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
|
||||
const buttons = await screen.findAllByRole("button", { name: "Edit" });
|
||||
return buttons[index] as HTMLButtonElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders the client table with group names and one hand-edited badge", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
test("renders the identity-first table: address, name, group, first and last seen", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
|
||||
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
|
||||
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);
|
||||
await renderClientsPage();
|
||||
|
||||
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);
|
||||
test("an unnamed row shows the learned name with the learned marker", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
// The cell holds the learned name followed by the tag, so the match is on
|
||||
// The cell holds the learned name followed by the marker, 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.
|
||||
// The marker is text, not colour, so a screen reader announces it too.
|
||||
expect(within(learned).getByText("learned")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => {
|
||||
const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]);
|
||||
render(<ClientName ip="192.168.1.11" names={names} />);
|
||||
|
||||
expect(screen.getByText("kids-tablet.lan")).toBeTruthy();
|
||||
expect(screen.queryByText("learned")).toBeNull();
|
||||
});
|
||||
|
||||
test("the address links to the client's detail page", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" });
|
||||
expect(link.getAttribute("href")).toBe("/clients/1");
|
||||
});
|
||||
|
||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
@@ -124,9 +81,9 @@ test("shows the DNS-activity empty state when there are no clients", async () =>
|
||||
});
|
||||
|
||||
test("edit opens a dialog seeded with the client's name and group", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
await renderClientsPage();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
fireEvent.click(await unlockedEdit(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");
|
||||
@@ -135,9 +92,9 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
|
||||
});
|
||||
|
||||
test("the group picker offers every group and reports the choice", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
await renderClientsPage();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
fireEvent.click(await unlockedEdit(0));
|
||||
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
||||
|
||||
@@ -148,14 +105,178 @@ test("the group picker offers every group and reports the choice", async () => {
|
||||
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
||||
});
|
||||
|
||||
test("prefix editor starts clean and dirties on add", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
test("network assignments start clean and dirty on add", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
|
||||
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
expect(range.value).toBe("192.168.1.0/24");
|
||||
const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||
expect(save.disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
|
||||
expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("?group= filters the list to that group", async () => {
|
||||
const { router } = await renderClientsPage();
|
||||
await router.navigate({ to: "/clients", search: { group: 2 } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull());
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(screen.getByText("Showing clients in kids.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unknown group id filters to nothing and offers a way out", async () => {
|
||||
const { router } = await renderClientsPage();
|
||||
await router.navigate({ to: "/clients", search: { group: 999 } });
|
||||
|
||||
await screen.findByText("No group with id 999 exists.");
|
||||
expect(screen.getByText("No clients match this filter.")).toBeTruthy();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Clear filter" }));
|
||||
await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy());
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
const section = await waitFor(() => {
|
||||
const found = assignmentsSection();
|
||||
if (found.querySelector("table") === null) throw new Error("still editing");
|
||||
return found;
|
||||
});
|
||||
|
||||
expect(within(section).getByText("192.168.1.0/24")).toBeTruthy();
|
||||
expect(
|
||||
section.querySelectorAll(
|
||||
"input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
||||
await screen.findAllByLabelText(/Configuration status unavailable/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy();
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
const declared = clientRow("192.168.1.10");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
// The status query polls, so authority can turn while a dialog or a delete
|
||||
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
||||
// answers 404 for a key it does not hold.
|
||||
//
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
describe.each([
|
||||
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
||||
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
||||
])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => {
|
||||
test("locks the open edit dialog's save path and leaves cancel working", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient } = await renderClientsPage(map);
|
||||
|
||||
fireEvent.click(await unlockedEdit(0));
|
||||
expect(
|
||||
within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", {
|
||||
name: "Save",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
// The draft survives; only the save path goes, and it says why.
|
||||
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
|
||||
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
// The Edit affordance appearing is the proof authority resolved to
|
||||
// database; the transition under test starts from there.
|
||||
await unlockedEdit(0);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
const confirming = clientRow("192.168.1.10");
|
||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
||||
);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
// The Edit affordance appearing is the proof authority resolved to
|
||||
// database; the transition under test starts from there.
|
||||
await unlockedEdit(0);
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"),
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a pending config status holds the same line as a failed one (R3-4)", async () => {
|
||||
// The status request never settles, so authority stays pending for the whole
|
||||
// test: nothing configuration owns may be offered on that guess.
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
||||
await screen.findAllByLabelText(/Checking which configuration source/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy();
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
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 type { Client, Group } from "@/lib/types";
|
||||
import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import NetworkAssignments from "./NetworkAssignments";
|
||||
import { ClientDisplayName } from "./clientIdentity";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||
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).
|
||||
* it stays live under every authority; deleting a declared row contradicts the
|
||||
* file and is the one client DELETE the server answers 403 (ruling 7).
|
||||
*
|
||||
* The lock fails closed, so it also holds while authority is pending or
|
||||
* unreachable. There the file is a possibility and not a fact — `hand_edited`
|
||||
* alone cannot say which operator surface set the row — so the sentence names
|
||||
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
function declaredDeleteNote(authority: Authority): string {
|
||||
if (authority.state === "resolved") {
|
||||
return "This client is declared in the configuration file; remove it there and restart.";
|
||||
}
|
||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
@@ -28,6 +41,15 @@ const styles = stylex.create({
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
filterBar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "48rem",
|
||||
@@ -53,19 +75,9 @@ const styles = stylex.create({
|
||||
right: {
|
||||
textAlign: "right",
|
||||
},
|
||||
dash: {
|
||||
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,
|
||||
addressLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
@@ -76,6 +88,7 @@ const styles = stylex.create({
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
@@ -89,36 +102,54 @@ const styles = stylex.create({
|
||||
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 { group } = useSearch({ from: "/shell/clients" });
|
||||
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();
|
||||
const authority = useAuthority();
|
||||
|
||||
// A well-formed id the group list does not contain is a link to a group that
|
||||
// has since been deleted. It filters to nothing, which is the truth, and the
|
||||
// notice says so rather than quietly showing every client.
|
||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
{group !== undefined && (
|
||||
<div {...stylex.props(styles.filterBar)}>
|
||||
<span>
|
||||
{filterGroup !== undefined
|
||||
? `Showing clients in ${filterGroup.name}.`
|
||||
: `No group with id ${group} exists.`}
|
||||
</span>
|
||||
<Link to="/clients" search={{}} {...stylex.props(shared.linkButton, shared.focusRing)}>
|
||||
Clear filter
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
) : rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No clients match this filter.</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)}>Address</th>
|
||||
<th {...stylex.props(styles.cell)}>Name</th>
|
||||
<th {...stylex.props(styles.cell)}>Group</th>
|
||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||
@@ -129,21 +160,19 @@ export default function ClientsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
{rows.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, shared.mono)}>
|
||||
<Link
|
||||
to="/clients/$id"
|
||||
params={{ id: String(client.id) }}
|
||||
{...stylex.props(styles.addressLink, shared.focusRing)}
|
||||
>
|
||||
{client.ip}
|
||||
</Link>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
{client.name !== "" ? (
|
||||
client.name
|
||||
) : client.learned_name !== "" ? (
|
||||
<span {...stylex.props(shared.learnedName)}>
|
||||
{client.learned_name}
|
||||
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.dash)}>—</span>
|
||||
)}
|
||||
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
||||
<ClientDisplayName client={client} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
@@ -151,23 +180,32 @@ export default function ClientsPage() {
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
{/* Authority is polled, so it can turn while a confirmation
|
||||
sits open. The confirm path reads it on every render
|
||||
rather than trusting the state that opened it. */}
|
||||
<span {...stylex.props(styles.note)}>
|
||||
Deleted clients re-materialize on their next DNS query.
|
||||
{readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: "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>
|
||||
{readOnly && client.hand_edited ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<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)}
|
||||
@@ -178,26 +216,26 @@ export default function ClientsPage() {
|
||||
</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>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? DECLARED_CLIENT_NOTE
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
@@ -220,7 +258,7 @@ export default function ClientsPage() {
|
||||
)}
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+87
-18
@@ -6,10 +6,10 @@ 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 AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||
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[];
|
||||
@@ -25,6 +25,13 @@ const styles = stylex.create({
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
headingKey: {
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -80,16 +87,84 @@ const styles = stylex.create({
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
/**
|
||||
* Address ranges that assign a group to every device inside them.
|
||||
*
|
||||
* The section is a configuration rendering, so it goes through the authority
|
||||
* gate: the editor exists only where a save can land, and file authority gets
|
||||
* the assignments as a table rather than a form nobody may submit.
|
||||
*/
|
||||
export default function NetworkAssignments({ prefixes, groups }: Props) {
|
||||
return (
|
||||
<section {...stylex.props(styles.section)}>
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Network assignments
|
||||
<code {...stylex.props(shared.mono, styles.headingKey)}>client_prefixes</code>
|
||||
</h2>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
An address range assigns its group to every device inside it, for devices with no row of their own. The
|
||||
highest priority match wins.
|
||||
</p>
|
||||
<AuthorityGate>
|
||||
{(status) =>
|
||||
status.authority === "database" ? (
|
||||
<AssignmentsEditor prefixes={prefixes} groups={groups} />
|
||||
) : (
|
||||
<AssignmentsTable prefixes={prefixes} />
|
||||
)
|
||||
}
|
||||
</AuthorityGate>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
|
||||
return (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Range</th>
|
||||
<th {...stylex.props(shared.th)}>Group</th>
|
||||
<th {...stylex.props(shared.th)}>Priority</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prefixes.map((prefix) => (
|
||||
<tr key={prefix.id}>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
|
||||
<td {...stylex.props(shared.td)}>{prefix.group}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list is saved as a whole, so the editor holds every row and the PUT
|
||||
* replaces the set. Reached only under resolved database authority: the gate
|
||||
* above owns that decision, and no control here consults it a second time.
|
||||
*/
|
||||
function AssignmentsEditor({ 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 = () => {
|
||||
@@ -102,21 +177,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
};
|
||||
|
||||
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>
|
||||
<p {...stylex.props(styles.empty)}>No network assignments 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}`}
|
||||
aria-label={`Range ${index + 1}`}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
@@ -125,7 +195,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`Group for prefix ${index + 1}`}
|
||||
aria-label={`Group for range ${index + 1}`}
|
||||
variant="inline"
|
||||
value={String(row.group_id)}
|
||||
onChange={(value) =>
|
||||
@@ -136,7 +206,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for prefix ${index + 1}`}
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
@@ -167,16 +237,15 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Add prefix
|
||||
Add range
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
disabled={!dirty || mutation.isPending}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save prefixes
|
||||
Save assignments
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
@@ -191,6 +260,6 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* How a client says who it is, shared by the list and the detail page.
|
||||
*
|
||||
* The learned marker lives here and nowhere else. A client is named once on
|
||||
* each of these two pages, so the tag is information; the query tables render
|
||||
* the same muted name through `ClientName` without it, because repeating the
|
||||
* tag down every row of a log is noise.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { Client } from "@/lib/types";
|
||||
import type { Authority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
unnamed: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export function ClientDisplayName({ client }: { client: Client }) {
|
||||
if (client.name !== "") return <>{client.name}</>;
|
||||
if (client.learned_name !== "") {
|
||||
return (
|
||||
<span {...stylex.props(shared.learnedName)}>
|
||||
{client.learned_name}
|
||||
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span {...stylex.props(styles.unnamed)}>—</span>;
|
||||
}
|
||||
|
||||
export interface Provenance {
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this row's name and group came from.
|
||||
*
|
||||
* `hand_edited` records that an operator settled this client, but not which
|
||||
* operator surface did: under file authority the reconciler sets it from the
|
||||
* declaration, and in database mode the admin's own edit does. Authority is the
|
||||
* only way to tell them apart, so while it is pending or unreachable the page
|
||||
* says what it knows and names the doubt rather than picking one.
|
||||
*/
|
||||
export function provenanceOf(client: Client, authority: Authority): Provenance {
|
||||
if (!client.hand_edited) {
|
||||
return {
|
||||
label: "Learned",
|
||||
detail: "This client appeared from DNS traffic. Its name, if any, comes from reverse DNS.",
|
||||
};
|
||||
}
|
||||
if (authority.state === "resolved" && authority.status.authority === "managed_file") {
|
||||
return {
|
||||
label: "Declared",
|
||||
detail: `Declared in ${authority.status.path ?? "the configuration file"}. Reverse DNS does not overwrite it.`,
|
||||
};
|
||||
}
|
||||
if (authority.state === "resolved") {
|
||||
return {
|
||||
label: "Edited",
|
||||
detail: "The name and group were set here. Reverse DNS does not overwrite them.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Set by hand",
|
||||
detail: "Either declared in the configuration file or edited here — nxdns cannot say which until it reports its configuration status.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The Clients pages under a real router: both of them read the same list, and
|
||||
* the detail route is reached by deep link as often as by click, so the tests
|
||||
* drive the router rather than the components.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
export const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
export 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export 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 };
|
||||
|
||||
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
|
||||
export const MANAGED_FILE = {
|
||||
authority: "managed_file",
|
||||
path: "/etc/nxdns/config.zon",
|
||||
reconciled_at: 1754899200,
|
||||
restart_pending: false,
|
||||
};
|
||||
|
||||
export const BASE: Record<string, unknown> = {
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/config/status": DATABASE,
|
||||
};
|
||||
|
||||
/** A request that never settles, so its query stays pending for the whole test. */
|
||||
export const NEVER = Symbol("never");
|
||||
|
||||
export function stubFetch(map: Record<string, unknown>): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === NEVER) return new Promise<Response>(() => {});
|
||||
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" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
export function renderAt(path: string, map: Record<string, unknown>) {
|
||||
const fetchMock = stubFetch(map);
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { router, fetchMock, queryClient };
|
||||
}
|
||||
|
||||
export async function renderClientsPage(map: Record<string, unknown> = BASE) {
|
||||
const handles = renderAt("/clients", map);
|
||||
await screen.findByRole("heading", { name: "Clients" });
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns configuration authority under a mounted page, the way the status
|
||||
* query's own poll would: the stub reads the map on each call, so replacing the
|
||||
* entry and refetching is the whole transition. `undefined` stands for a failed
|
||||
* status — the stub answers 404 for a key it does not hold.
|
||||
*/
|
||||
export async function setConfigStatus(
|
||||
map: Record<string, unknown>,
|
||||
queryClient: QueryClient,
|
||||
status: unknown,
|
||||
): Promise<void> {
|
||||
map["GET /api/config/status"] = status;
|
||||
await act(async () => {
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.configStatus });
|
||||
// The cache lands before its observers are notified — react-query defers
|
||||
// that notification — so the render pass needs one more turn inside act.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user