All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.
A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.
Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.
Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Each server's detail page now has a Ports card. "Scan…" runs a TCP connect
scan of a chosen range from the app and shows what's open, along with the
ranges that were actually confirmed free; clicking a free range starts a
reservation. Any port can carry a service name and a comment, so the page
also answers "what is this port for". A port with a note counts as taken
even when nothing is listening, which is what makes a reservation work.
Operators can scan and edit; everyone can read. Scans and note changes are
audit-logged.
Details that matter for correctness:
- "Free" means the host actively refused the connection AND nobody has
claimed the port. A port that never answers (firewall drop, host down)
is reported as not answering, not as free.
- A scan from elsewhere can't see services bound to localhost only, so the
agent now also reports what is bound on the host (ss -tulnp) and those
ports are treated as taken. They show as "local only". Existing agents
keep working; re-run the install one-liner to add this. The field is
validated leniently so one odd line can never cost an agent its whole
report, tasks included.
- If nothing answers at all during a scan, existing results are left
alone instead of being marked all-closed.
- Scan targets are limited to private addresses (RFC1918, Tailscale
100.64/10, link-local, IPv6 ULA/link-local); loopback and public
addresses are refused. Ranges are capped at 20,000 ports, and only one
scan runs per server at a time.
- Rows exist only while they carry information: an open port, or one with
a note. A closed port with no note disappears on the next scan; one with
a note stays as "reserved".
New table server_ports plus two columns on servers (migration 0009).
Verified with 76 backend checks (scanner open/refused/filtered, address
rules, agent report leniency, note/reserve/clear semantics, free-range
calculation including the localhost-only case, roles, concurrency lock,
no-response guard, audit entries, cascade delete) and by driving the real
component against the real router in a browser. Real dev database mtime
untouched.
Not verified: the agent's ss/awk/jq pipeline on a real host — the awk step
was checked against sample ss output and the script passes bash -n, but
jq isn't available here to run the whole thing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>