diff --git a/.binary-allowlist b/.binary-allowlist
new file mode 100644
index 0000000..719e5af
--- /dev/null
+++ b/.binary-allowlist
@@ -0,0 +1,4 @@
+# The management page's only non-text asset. An SVG so it stays reviewable
+# in a diff: the gate blocks the extension, not the content, and a PNG would
+# need a negation in .gitignore as well as an entry here.
+web/public/*.svg
diff --git a/.env.sample b/.env.sample
index 3729ca7..99d83e3 100644
--- a/.env.sample
+++ b/.env.sample
@@ -74,7 +74,7 @@ SELFTEST_GROUPS=
# Optional, and private. The album the per-person photo commands draw
# from: a directory holding people.json, tags.json and media/. Everything
# identifying lives in there and nothing about it is in this repository --
-# see DOCS-photo-pack.md, and tool/tagger.py for how it gets filled in.
+# see DOCS-photo-pack.md, and ./web.sh for how it gets filled in.
#
# Leave it empty and the bot looks in $STATE_DIRECTORY/pack (systemd) and
# then $XDG_DATA_HOME/signal-bot/pack. Not finding one is normal: the
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb76331..dd736ed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,7 +54,7 @@ jobs:
- name: shellcheck
# --severity=style is the floor CI enforces; the local pre-commit hook
# uses the same flag so a version skew cannot pass locally and fail here.
- run: shellcheck -x --severity=style run.sh scripts/*.sh
+ run: shellcheck -x --severity=style ./*.sh scripts/*.sh
- name: pytest (100% branch coverage)
run: .venv/bin/python -m pytest -q
@@ -68,3 +68,24 @@ jobs:
# for eight hours on 2026-08-30.
- name: cold start
run: ./scripts/cold_start.sh
+
+ # The management page. Its own job rather than more steps on `check`: it
+ # needs a Node toolchain that the Python job has no use for, and the two
+ # halves fail for unrelated reasons.
+ web:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24.20.0"
+ - run: corepack enable
+ - name: install (exact pins, from the lockfile)
+ run: pnpm install --frozen-lockfile
+ - name: tsc --noEmit and eslint
+ run: pnpm lint
+ - name: vitest (100% coverage, no excludes)
+ run: pnpm coverage
diff --git a/.gitignore b/.gitignore
index 9441140..19de4bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -194,3 +194,9 @@ adds.json
# Wine/Bottles scratch dir, never part of the bot.
bottles/
+
+# The management page's build inputs and reports. dist/ is already covered by
+# the bare entry above, which matches at any depth.
+node_modules/
+web/coverage/
+*.tsbuildinfo
diff --git a/DOCS-add-photo.md b/DOCS-add-photo.md
index 75d88e5..ef4293f 100644
--- a/DOCS-add-photo.md
+++ b/DOCS-add-photo.md
@@ -104,7 +104,7 @@ answers that directly. Do not collapse the two jobs back together.
still reporting one has a metadata track the re-mux should have removed, and
refusing it is the correct outcome.
-**Both entry points obey this.** `tool/tagger.py --import` used to be a
+**Both entry points obey this.** `./web.sh --import` used to be a
verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the
guarantee on this page covers the whole album and not just the chat path.
diff --git a/DOCS-photo-pack.md b/DOCS-photo-pack.md
index 640104d..0e787bd 100644
--- a/DOCS-photo-pack.md
+++ b/DOCS-photo-pack.md
@@ -175,13 +175,13 @@ and retrievable immediately. That path has its own document, because the
metadata handling and the privacy invariants it has to keep are longer than
the album format itself: **`DOCS-add-photo.md`**.
-## Filling it in: tool/tagger.py
+## Filling it in: ./web.sh
```sh
-./tag.sh --import ~/photos # dedup, strip, import, then tag
-./tag.sh --import ~/photos --dry-run # the report only
-./tag.sh --untagged # only items with no tags yet
-./tag.sh # carry on where you left off
+./web.sh --import ~/photos # dedup, strip, import, then tag
+./web.sh --import ~/photos --dry-run # the report only
+./web.sh --untagged # only items with no tags yet
+./web.sh # carry on where you left off
```
`tag.sh` sets up `.venv` on first use, so this works on a fresh clone with
diff --git a/DOCS-web-ui.md b/DOCS-web-ui.md
new file mode 100644
index 0000000..5415c0e
--- /dev/null
+++ b/DOCS-web-ui.md
@@ -0,0 +1,152 @@
+# The management page
+
+`./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what
+it is configured with, and the photo album's tagger. It replaces
+`tool/tagger.py`, which served the album half of this on the same port.
+
+```sh
+./web.sh # the configured pack
+./web.sh --dir ~/pack --port 8140
+./web.sh --import ~/photos # dedup, strip and import, then serve
+./web.sh --import ~/photos --dry-run # the report, changing nothing
+./web.sh --untagged # only items with no tags yet
+```
+
+## Why it is started by hand
+
+A systemd unit would be more convenient and is the wrong shape. This page can
+rewrite `.env` — the file that decides which groups the bot answers in and who
+may add it to one — and a unit would keep that writable on loopback all day
+for the sake of a bookmark. Started by hand, the window is the few minutes
+somebody is actually looking at it.
+
+It is also a **second process**, not more routes on the bot. The bot's own
+port already serves `POST /_selftest`, which posts for real into a real group;
+putting a configuration writer beside it would put both behind one mistake.
+
+## Why loopback is not the whole defence
+
+Binding to `127.0.0.1` stops the network. It does not stop the browser already
+running on this machine, and two attacks reach a loopback port from an
+ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty
+lines, with nothing to configure and no session to keep:
+
+- **Cross-origin form posts.** Any site can POST a `
cannot set
+// this header and that is what stops a web page from writing .env.
+const JSON_HEADERS = { "content-type": "application/json" };
+
+export class ApiError extends Error {
+ readonly status: number;
+
+ constructor(message: string, options: ErrorOptions & { status: number }) {
+ super(message, options);
+ this.name = "ApiError";
+ this.status = options.status;
+ }
+}
+
+async function unwrap(response: Response): Promise {
+ const body: unknown = await response.json();
+ if (!response.ok) {
+ // FastAPI puts the reason in `detail`, and it is usually load_config's
+ // own message -- the sentence the journal would have shown at the next
+ // restart. Showing anything else would be inventing a second wording.
+ const detail =
+ typeof body === "object" && body !== null && "detail" in body
+ ? String((body).detail)
+ : response.statusText;
+ throw new ApiError(detail, { status: response.status });
+ }
+ return body as T;
+}
+
+async function get(path: string): Promise {
+ return unwrap(await fetch(path));
+}
+
+async function send(path: string, method: string, body: unknown): Promise {
+ return unwrap(
+ await fetch(path, {
+ body: JSON.stringify(body),
+ headers: JSON_HEADERS,
+ method,
+ }),
+ );
+}
+
+export const api = {
+ album: async (): Promise => get("/api/album/state"),
+ env: async (): Promise => get("/api/env"),
+ groups: async (): Promise<{ groups: Group[] }> =>
+ get<{ groups: Group[] }>("/api/groups"),
+ restart: async (): Promise =>
+ send("/api/restart-container", "POST", {}),
+ saveEnv: async (values: Record): Promise =>
+ send("/api/env", "PUT", { values }),
+ status: async (): Promise => get("/api/status"),
+ tag: async (file: string, keys: string[]): Promise =>
+ send("/api/album/tag", "POST", { file, keys }),
+};
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
new file mode 100644
index 0000000..d40b5e8
--- /dev/null
+++ b/web/src/api/types.ts
@@ -0,0 +1,57 @@
+// What the management app answers with. Hand-written rather than generated:
+// the backend is eleven small modules in this same repository, and a
+// generator would be a build step to keep working for no new information.
+
+export interface BotHealth {
+ papacy?: string;
+ pope?: string;
+ reason?: string;
+ status?: string;
+ version?: string;
+}
+
+export interface Status {
+ bot: BotHealth;
+ reachable: boolean;
+}
+
+export interface Group {
+ id: string;
+ name: string;
+}
+
+export interface EnvironmentKey {
+ editable: boolean;
+ key: string;
+ value: string;
+}
+
+export interface EnvironmentView {
+ groups_known: boolean;
+ keys: EnvironmentKey[];
+ needs_groups: string[];
+}
+
+export interface AlbumFile {
+ keys: string[];
+ name: string;
+}
+
+export interface Person {
+ key: string;
+ triggers: string[];
+}
+
+export interface AlbumState {
+ combos: Person[];
+ configured: boolean;
+ files: AlbumFile[];
+ known_groups: Group[];
+ people: Person[];
+}
+
+export interface Restart {
+ ok: boolean;
+ stderr: string;
+ stdout: string;
+}
diff --git a/web/src/app.test.tsx b/web/src/app.test.tsx
new file mode 100644
index 0000000..731d340
--- /dev/null
+++ b/web/src/app.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+
+import { App } from "./app.tsx";
+import { ALBUM, ENV, HEALTHY } from "./test/fixtures.ts";
+import { serve } from "./test/http.ts";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function everythingAnswers(): void {
+ serve({
+ "/api/album/state": { body: ALBUM },
+ "/api/env": { body: ENV },
+ "/api/status": { body: HEALTHY },
+ });
+}
+
+it("opens on health, because that is the question being asked", async () => {
+ everythingAnswers();
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("reachable")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("button", { name: "Health" })).toHaveAttribute(
+ "aria-current",
+ "true",
+ );
+});
+
+it("switches to settings and to the photos", async () => {
+ everythingAnswers();
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: "Settings" }));
+ await waitFor(() => {
+ expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByRole("button", { name: "Photos" }));
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/app.tsx b/web/src/app.tsx
new file mode 100644
index 0000000..b024a9c
--- /dev/null
+++ b/web/src/app.tsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+
+import { Album } from "./views/album.tsx";
+import { Health } from "./views/health.tsx";
+import { Settings } from "./views/settings.tsx";
+
+const VIEWS = {
+ Health: Health,
+ Photos: Album,
+ Settings: Settings,
+} as const;
+
+type ViewName = keyof typeof VIEWS;
+
+const NAMES = Object.keys(VIEWS) as ViewName[];
+
+export function App(): React.JSX.Element {
+ const [view, setView] = useState("Health");
+ const Current = VIEWS[view];
+ return (
+
+ signal-bot
+
+ {NAMES.map((name) => {
+ return (
+ {
+ setView(name);
+ }}
+ type="button"
+ >
+ {name}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/web/src/main.test.tsx b/web/src/main.test.tsx
new file mode 100644
index 0000000..d2cd9eb
--- /dev/null
+++ b/web/src/main.test.tsx
@@ -0,0 +1,30 @@
+import { afterEach, expect, it, vi } from "vitest";
+
+import { HEALTHY } from "./test/fixtures.ts";
+import { serve } from "./test/http.ts";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ document.body.replaceChildren();
+});
+
+it("mounts the app into the root the page provides", async () => {
+ serve({ "/api/status": { body: HEALTHY } });
+ const root = document.createElement("div");
+ root.id = "root";
+ document.body.replaceChildren(root);
+ await import("./main.tsx");
+ await vi.waitFor(() => {
+ expect(document.querySelector("h1")).toHaveTextContent("signal-bot");
+ });
+});
+
+it("does nothing at all when there is no root to mount into", async () => {
+ // Not a hypothetical: this module is imported by its own test file, and a
+ // bootstrap that assumed the element existed would throw during collection
+ // rather than at run time.
+ serve({ "/api/status": { body: HEALTHY } });
+ await import("./main.tsx");
+ expect(document.querySelector("h1")).toBeNull();
+});
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..b0aa1cd
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,15 @@
+import { createRoot } from "react-dom/client";
+
+import { App } from "./app.tsx";
+
+// Thin enough to test rather than exclude. This repository treats a coverage
+// omit as a suppression, and "the DOM bootstrap is awkward to assert" is the
+// same sentence as "this code is untested".
+export function mount(container: Element): void {
+ createRoot(container).render( );
+}
+
+const root = document.querySelector("#root");
+if (root !== null) {
+ mount(root);
+}
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
new file mode 100644
index 0000000..1a012a9
--- /dev/null
+++ b/web/src/test/fixtures.ts
@@ -0,0 +1,51 @@
+// Every value here is invented. Two constraints beyond "do not use real
+// data": the repository's personal-data gate refuses any file containing a
+// string matching group.<16+ base64 chars>, so the fixture group ids are
+// short and obviously fake; and the real album's people are private, so the
+// names here are letters of the NATO alphabet, as tests/pack_fixture.py does.
+import type { AlbumState, EnvironmentView as EnvironmentView, Status } from "../api/types.ts";
+
+export const GROUP = { id: "group.FIXTURE-ALPHA", name: "alfa" };
+
+export const HEALTHY: Status = {
+ bot: { papacy: "nr 1", pope: "Petrus", status: "ok", version: "0.0.0" },
+ reachable: true,
+};
+
+export const DEAF: Status = {
+ bot: { reason: "no frame in 120s", status: "deaf" },
+ reachable: true,
+};
+
+export const UNREACHABLE: Status = { bot: {}, reachable: false };
+
+// Answering, but with nothing to say for itself. Older releases predate the
+// version/pope fields, so the page must not render "undefined" at somebody.
+export const TERSE: Status = { bot: {}, reachable: true };
+
+export const ENV: EnvironmentView = {
+ groups_known: true,
+ keys: [
+ { editable: true, key: "GROUP_IDS", value: "" },
+ { editable: true, key: "COOLDOWN_SECONDS", value: "10" },
+ { editable: false, key: "PHONE_NUMBER", value: "+15550000000" },
+ ],
+ needs_groups: ["GROUP_IDS"],
+};
+
+export const ALBUM: AlbumState = {
+ combos: [],
+ configured: true,
+ files: [
+ { keys: ["p1"], name: "one.png" },
+ { keys: [], name: "two.png" },
+ ],
+ known_groups: [GROUP],
+ people: [
+ { key: "p1", triggers: ["!alfa"] },
+ { key: "p2", triggers: ["!beta"] },
+ // Half-added, which the tagger allows: add_person writes the key first
+ // and the page has to show something for it.
+ { key: "p3", triggers: [] },
+ ],
+};
diff --git a/web/src/test/http.ts b/web/src/test/http.ts
new file mode 100644
index 0000000..c509016
--- /dev/null
+++ b/web/src/test/http.ts
@@ -0,0 +1,25 @@
+import { vi } from "vitest";
+
+export interface Reply {
+ body: unknown;
+ status?: number;
+}
+
+/**
+Answer each request by path, so a test says what the server is doing.
+*/
+export function serve(routes: Record): ReturnType {
+ const fetcher = vi.fn((input: string) => {
+ const { pathname: path } = new URL(input, "http://127.0.0.1:8140");
+ const reply = routes[path] ?? { body: {}, status: 404 };
+ const status = reply.status ?? 200;
+ return Promise.resolve({
+ json: () => Promise.resolve(reply.body),
+ ok: status < 400,
+ status,
+ statusText: `status ${status}`,
+ } as Response);
+ });
+ vi.stubGlobal("fetch", fetcher);
+ return fetcher;
+}
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/web/src/views/album.test.tsx b/web/src/views/album.test.tsx
new file mode 100644
index 0000000..0aafa18
--- /dev/null
+++ b/web/src/views/album.test.tsx
@@ -0,0 +1,103 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+
+import { ALBUM } from "../test/fixtures.ts";
+import { serve } from "../test/http.ts";
+import { Album } from "./album.tsx";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+it("says it is opening before the album arrives", () => {
+ serve({ "/api/album/state": { body: ALBUM } });
+ render( );
+ expect(screen.getByText("Opening the album…")).toBeInTheDocument();
+});
+
+it("says so when there is nothing to tag yet", async () => {
+ serve({ "/api/album/state": { body: { ...ALBUM, files: [] } } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("empty")).toBeInTheDocument();
+ });
+});
+
+it("asks for media by position, never by name", async () => {
+ // The server takes an int for exactly this reason: a filename from a
+ // client is a path, and this directory holds private photographs.
+ serve({ "/api/album/state": { body: ALBUM } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
+ });
+});
+
+it("shows who is already tagged on the current item", async () => {
+ serve({ "/api/album/state": { body: ALBUM } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByLabelText("1. !alfa")).toBeChecked();
+ });
+ expect(screen.getByLabelText("2. !beta")).not.toBeChecked();
+});
+
+it("shows a half-added person by key rather than by nothing", async () => {
+ serve({ "/api/album/state": { body: ALBUM } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByLabelText("3. p3")).toBeInTheDocument();
+ });
+});
+
+it("saves on the click rather than on a done button", async () => {
+ // The tab gets closed. A "done" button is a way to lose an evening's work.
+ const fetcher = serve({
+ "/api/album/state": { body: ALBUM },
+ "/api/album/tag": { body: { status: "ok" } },
+ });
+ render( );
+ await userEvent.click(await screen.findByLabelText("2. !beta"));
+ await waitFor(() => {
+ expect(screen.getByLabelText("2. !beta")).toBeChecked();
+ });
+ const [path, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
+ expect(path).toBe("/api/album/tag");
+ expect(JSON.parse(init.body)).toEqual({
+ file: "one.png",
+ keys: ["p1", "p2"],
+ });
+});
+
+it("untags somebody who was tagged by mistake", async () => {
+ const fetcher = serve({
+ "/api/album/state": { body: ALBUM },
+ "/api/album/tag": { body: { status: "ok" } },
+ });
+ render( );
+ await userEvent.click(await screen.findByLabelText("1. !alfa"));
+ await waitFor(() => {
+ expect(screen.getByLabelText("1. !alfa")).not.toBeChecked();
+ });
+ const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
+ expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: [] });
+});
+
+it("walks the album forwards and back", async () => {
+ serve({ "/api/album/state": { body: ALBUM } });
+ render( );
+ const previous = await screen.findByRole("button", { name: "Previous" });
+ expect(previous).toBeDisabled();
+
+ await userEvent.click(screen.getByRole("button", { name: "Next" }));
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1");
+ });
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
+
+ await userEvent.click(screen.getByRole("button", { name: "Previous" }));
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
+ });
+});
diff --git a/web/src/views/album.tsx b/web/src/views/album.tsx
new file mode 100644
index 0000000..342b89f
--- /dev/null
+++ b/web/src/views/album.tsx
@@ -0,0 +1,93 @@
+import { useCallback, useEffect, useState } from "react";
+
+import type { AlbumFile, Person } from "../api/types.ts";
+
+import { api } from "../api/client.ts";
+
+// The file list is held apart from the rest of the album state so that
+// recording a tag is a plain list update. Folding it back into a nullable
+// object would add a "what if it is null" branch that cannot happen -- this
+// only renders once the album has loaded -- and an untestable branch is the
+// same thing as an untested one.
+function tagged(files: AlbumFile[], name: string, keys: string[]): AlbumFile[] {
+ return files.map((entry) => (entry.name === name ? { ...entry, keys } : entry));
+}
+
+export function Album(): React.JSX.Element {
+ const [people, setPeople] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [at, setAt] = useState(0);
+
+ useEffect(() => {
+ void api.album().then((state) => {
+ setPeople(state.people);
+ setFiles(state.files);
+ });
+ }, []);
+
+ const toggle = useCallback((file: AlbumFile, personKey: string) => {
+ const keys = file.keys.includes(personKey)
+ ? file.keys.filter((held) => held !== personKey)
+ : [...file.keys, personKey];
+ // Saved on the click, not on a "done" button: the tab gets closed, and
+ // a "done" button is a way to lose an evening's work.
+ void api.tag(file.name, keys).then(() => {
+ setFiles((current) => tagged(current, file.name, keys));
+ });
+ }, []);
+
+ if (people === null) {
+ return Opening the album…
;
+ }
+ // One list, one index space. The server serves media by position and tags
+ // by name against the same ordering, so filtering here would show item i
+ // of the album while tagging item i of a shorter list.
+ const file = files[at];
+ if (file === undefined) {
+ return This album has no items yet.
;
+ }
+ return (
+
+
+ Photos — {at + 1} of {files.length}
+
+
+
+ {
+ setAt((current) => current - 1);
+ }}
+ type="button"
+ >
+ Previous
+
+ = files.length - 1}
+ onClick={() => {
+ setAt((current) => current + 1);
+ }}
+ type="button"
+ >
+ Next
+
+
+ );
+}
diff --git a/web/src/views/health.test.tsx b/web/src/views/health.test.tsx
new file mode 100644
index 0000000..ad1c738
--- /dev/null
+++ b/web/src/views/health.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+
+import { DEAF, HEALTHY, TERSE, UNREACHABLE } from "../test/fixtures.ts";
+import { serve } from "../test/http.ts";
+import { Health } from "./health.tsx";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+it("says it is asking before the answer arrives", () => {
+ serve({ "/api/status": { body: HEALTHY } });
+ render( );
+ expect(screen.getByText("Asking the bot…")).toBeInTheDocument();
+});
+
+it("shows the release the bot is actually running", async () => {
+ serve({ "/api/status": { body: HEALTHY } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("reachable")).toHaveTextContent("0.0.0");
+ });
+});
+
+it("never renders undefined at somebody", async () => {
+ // A release older than the version/pope fields still answers /healthz.
+ serve({ "/api/status": { body: TERSE } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("reachable")).toHaveTextContent("?");
+ });
+ expect(screen.getByTestId("reachable")).not.toHaveTextContent("undefined");
+});
+
+it("says plainly when the bot is not answering", async () => {
+ // The single most important thing this page can tell you, so it must not
+ // be the thing that stops the page rendering.
+ serve({ "/api/status": { body: UNREACHABLE } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("reachable")).toHaveTextContent("not answering");
+ });
+});
+
+it("shows why a bot that is up has gone deaf", async () => {
+ serve({ "/api/status": { body: DEAF } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("reason")).toHaveTextContent("no frame in 120s");
+ });
+});
+
+it("reports a restart that worked", async () => {
+ serve({
+ "/api/restart-container": { body: { ok: true, stderr: "", stdout: "" } },
+ "/api/status": { body: HEALTHY },
+ });
+ render( );
+ await userEvent.click(await screen.findByRole("button"));
+ await waitFor(() => {
+ expect(screen.getByTestId("restart")).toHaveTextContent("restarted");
+ });
+});
+
+it("does not let a restart that failed look like success", async () => {
+ serve({
+ "/api/restart-container": {
+ body: { ok: false, stderr: "no such container", stdout: "" },
+ },
+ "/api/status": { body: HEALTHY },
+ });
+ render( );
+ await userEvent.click(await screen.findByRole("button"));
+ await waitFor(() => {
+ expect(screen.getByTestId("restart")).toHaveTextContent("no such container");
+ });
+});
diff --git a/web/src/views/health.tsx b/web/src/views/health.tsx
new file mode 100644
index 0000000..70ad064
--- /dev/null
+++ b/web/src/views/health.tsx
@@ -0,0 +1,51 @@
+import { useCallback, useEffect, useState } from "react";
+
+import type { Restart, Status } from "../api/types.ts";
+
+import { api } from "../api/client.ts";
+
+// The page is most useful exactly when something is down, so every read here
+// degrades to a state rather than an error.
+export function Health(): React.JSX.Element {
+ const [status, setStatus] = useState(null);
+ const [restart, setRestart] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const refresh = useCallback(() => {
+ void api.status().then(setStatus);
+ }, []);
+
+ useEffect(refresh, [refresh]);
+
+ const doRestart = useCallback(() => {
+ setBusy(true);
+ void api.restart().then((result) => {
+ setRestart(result);
+ setBusy(false);
+ refresh();
+ });
+ }, [refresh]);
+
+ if (status === null) {
+ return Asking the bot…
;
+ }
+ return (
+
+ Health
+
+ {status.reachable
+ ? `${status.bot.status ?? "?"} — ${status.bot.version ?? "?"} «${status.bot.pope ?? "?"}»`
+ : "the bot is not answering on its loopback port"}
+
+ {status.bot.reason !== undefined && {status.bot.reason}
}
+
+ Restart signal-api
+
+ {restart !== null && (
+
+ {restart.ok ? "restarted" : `failed: ${restart.stderr}`}
+
+ )}
+
+ );
+}
diff --git a/web/src/views/settings.test.tsx b/web/src/views/settings.test.tsx
new file mode 100644
index 0000000..247623d
--- /dev/null
+++ b/web/src/views/settings.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+
+import { ENV } from "../test/fixtures.ts";
+import { serve } from "../test/http.ts";
+import { Settings } from "./settings.tsx";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+it("says it is reading before the answer arrives", () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ expect(screen.getByText("Reading .env…")).toBeInTheDocument();
+});
+
+it("will not let a key the server refuses even be typed into", async () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled();
+ });
+ expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeEnabled();
+});
+
+it("locks the group fields while no listing has been read", async () => {
+ // parse_group_ids only checks syntax. Writing one blind with the container
+ // down turns a scoping mistake into a unit that will not start.
+ serve({ "/api/env": { body: { ...ENV, groups_known: false } } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-locked")).toBeInTheDocument();
+ });
+ expect(screen.getByLabelText("GROUP_IDS")).toBeDisabled();
+});
+
+it("asks for a restart once something has been written", async () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => {
+ expect(screen.getByTestId("note")).toHaveTextContent("Restart the bot");
+ });
+});
+
+it("shows the server's refusal verbatim", async () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
+ serve({
+ "/api/env": { body: { detail: "COOLDOWN_SECONDS must be an integer" }, status: 400 },
+ });
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => {
+ expect(screen.getByTestId("note")).toHaveTextContent("must be an integer");
+ });
+});
+
+it("reports a failure that is not the server's own sentence", async () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.reject(new Error("network is down"))),
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => {
+ expect(screen.getByTestId("note")).toHaveTextContent("could not save");
+ });
+});
+
+it("has nothing to save until something is edited", async () => {
+ serve({ "/api/env": { body: ENV } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+});
diff --git a/web/src/views/settings.tsx b/web/src/views/settings.tsx
new file mode 100644
index 0000000..4b92104
--- /dev/null
+++ b/web/src/views/settings.tsx
@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useState } from "react";
+
+import type { EnvironmentView as EnvironmentView } from "../api/types.ts";
+
+import { api, ApiError } from "../api/client.ts";
+
+// Editing .env from a browser is the whole reason the server refuses
+// cross-origin forms. The form's own job is smaller: never offer a key the
+// server would refuse, so a refusal is a bug rather than a normal outcome.
+export function Settings(): React.JSX.Element {
+ const [view, setView] = useState(null);
+ const [edits, setEdits] = useState>({});
+ const [note, setNote] = useState("");
+
+ useEffect(() => {
+ void api.env().then(setView);
+ }, []);
+
+ const save = useCallback(() => {
+ void api
+ .saveEnv(edits)
+ .then(() => {
+ // The running process loaded the old values and keeps using them.
+ // Saying so is the whole contract; restarting is done from the
+ // terminal this page was started in.
+ setNote("Written. Restart the bot for it to take effect.");
+ setEdits({});
+ })
+ .catch((error: unknown) => {
+ setNote(error instanceof ApiError ? error.message : "could not save");
+ });
+ }, [edits]);
+
+ if (view === null) {
+ return Reading .env…
;
+ }
+ return (
+
+ );
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..6a975f3
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "types": [
+ "vitest/globals",
+ "node"
+ ],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "exactOptionalPropertyTypes": true,
+ "useUnknownInCatchVariables": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "allowImportingTsExtensions": true,
+ "erasableSyntaxOnly": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": [
+ "src",
+ "*.ts",
+ "*.config.ts"
+ ]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..e9a0571
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,35 @@
+///
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// The page is served from the root of its own loopback port, and from
+// nowhere else, so there is no subpath to be agnostic about.
+export default defineConfig({
+ build: { outDir: "dist" },
+ plugins: [react()],
+ server: {
+ // `pnpm dev` talks to the real management app rather than a mock: the
+ // guard refuses anything whose Host is not loopback, so the proxy has
+ // to preserve it.
+ proxy: { "/api": "http://127.0.0.1:8140" },
+ },
+ test: {
+ coverage: {
+ // No exclude list. This repository's rule is that coverage omits are
+ // suppressions, and "the bootstrap is hard to test" is the same
+ // sentence as "this line is untested" -- so main.tsx is a shim thin
+ // enough to test in jsdom instead.
+ include: ["src/**/*.{ts,tsx}"],
+ provider: "v8",
+ thresholds: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ },
+});