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>
508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { api, type PortEntry, type PortList, type PortScanResult } from "../api/client";
|
||
import { sortItems, type SortDirection } from "../hooks/useSortable";
|
||
import { usePagination } from "../hooks/usePagination";
|
||
import { formatDateTime } from "../utils/date";
|
||
import { downloadCsv } from "../utils/csv";
|
||
import { readableError } from "../utils/errors";
|
||
import Pagination from "./Pagination";
|
||
import SortableTh from "./SortableTh";
|
||
import { confirmDialog } from "../utils/dialogs";
|
||
|
||
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
|
||
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
|
||
{ key: "common", label: "1–10000 (most services)", from: 1, to: 10000 },
|
||
{ key: "high1", label: "10001–30000", from: 10001, to: 30000 },
|
||
{ key: "high2", label: "30001–50000", from: 30001, to: 50000 },
|
||
{ key: "high3", label: "50001–65535", from: 50001, to: 65535 },
|
||
{ key: "custom", label: "Custom range…", from: 8000, to: 8999 },
|
||
];
|
||
|
||
const MAX_SHOWN_RANGES = 24;
|
||
|
||
interface NoteForm {
|
||
editingId: number | null;
|
||
port: string;
|
||
protocol: "tcp" | "udp";
|
||
label: string;
|
||
comment: string;
|
||
}
|
||
|
||
function rangeText([from, to]: [number, number]) {
|
||
return from === to ? String(from) : `${from}–${to}`;
|
||
}
|
||
|
||
function StatusBadges({ entry }: { entry: PortEntry }) {
|
||
if (entry.state === "reserved") return <span className="badge bg-secondary-lt text-secondary">Reserved</span>;
|
||
return (
|
||
<div className="d-flex flex-wrap gap-1">
|
||
{entry.scanOpen && (
|
||
<span className="badge bg-green-lt text-green" title="A scan from this app connected to it">
|
||
Reachable
|
||
</span>
|
||
)}
|
||
{entry.agent && (
|
||
<span className="badge bg-blue-lt text-blue" title={`Bound on: ${entry.agent.addresses.join(", ")}`}>
|
||
Listening
|
||
</span>
|
||
)}
|
||
{entry.agent?.localOnly && (
|
||
<span className="badge bg-yellow-lt text-yellow" title="Bound to localhost only — other machines can't connect, but the port is still taken">
|
||
local only
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ServerPorts({
|
||
serverId,
|
||
addresses,
|
||
canEdit,
|
||
}: {
|
||
serverId: number;
|
||
/** Addresses this server is known by, offered as scan targets. */
|
||
addresses: string[];
|
||
canEdit: boolean;
|
||
}) {
|
||
const [data, setData] = useState<PortList | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState("");
|
||
const [sortKey, setSortKey] = useState<keyof PortEntry | undefined>("port");
|
||
const [sortDir, setSortDir] = useState<SortDirection>("asc");
|
||
|
||
const [showScan, setShowScan] = useState(false);
|
||
const [address, setAddress] = useState("");
|
||
const [presetKey, setPresetKey] = useState("common");
|
||
const [customFrom, setCustomFrom] = useState("8000");
|
||
const [customTo, setCustomTo] = useState("8999");
|
||
const [scanning, setScanning] = useState(false);
|
||
const [result, setResult] = useState<PortScanResult | null>(null);
|
||
const [showAllRanges, setShowAllRanges] = useState(false);
|
||
|
||
const [note, setNote] = useState<NoteForm | null>(null);
|
||
const [savingNote, setSavingNote] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api.servers.ports
|
||
.list(serverId)
|
||
.then(setData)
|
||
.catch((err) => setError(readableError(err)));
|
||
}, [serverId]);
|
||
|
||
// Default the scan target to the server's first known address once we have one.
|
||
useEffect(() => {
|
||
if (!address && addresses.length > 0) setAddress(addresses[0]);
|
||
}, [addresses, address]);
|
||
|
||
const visible = useMemo(() => {
|
||
if (!data) return null;
|
||
const q = filter.trim().toLowerCase();
|
||
const rows = q
|
||
? data.ports.filter((p) =>
|
||
[String(p.port), p.protocol, p.label, p.comment, p.agent?.process].some((v) => v?.toLowerCase().includes(q)),
|
||
)
|
||
: data.ports;
|
||
return sortItems(rows, sortKey, sortDir);
|
||
}, [data, filter, sortKey, sortDir]);
|
||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(visible);
|
||
|
||
function requestSort(key: keyof PortEntry) {
|
||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
else {
|
||
setSortKey(key);
|
||
setSortDir("asc");
|
||
}
|
||
}
|
||
|
||
const preset = PRESETS.find((p) => p.key === presetKey)!;
|
||
const from = presetKey === "custom" ? Number(customFrom) : preset.from;
|
||
const to = presetKey === "custom" ? Number(customTo) : preset.to;
|
||
|
||
async function runScan(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
setScanning(true);
|
||
setResult(null);
|
||
setShowAllRanges(false);
|
||
try {
|
||
const res = await api.servers.ports.scan(serverId, { address: address.trim(), from, to });
|
||
setResult(res);
|
||
setData({ ports: res.ports, agentReporting: res.agentReporting, agentReportedAt: res.agentReportedAt, lastScan: res.scan });
|
||
} catch (err) {
|
||
setError(readableError(err));
|
||
} finally {
|
||
setScanning(false);
|
||
}
|
||
}
|
||
|
||
function openNote(initial: Partial<NoteForm> = {}) {
|
||
setError(null);
|
||
setNote({ editingId: null, port: "", protocol: "tcp", label: "", comment: "", ...initial });
|
||
}
|
||
|
||
function editEntry(entry: PortEntry) {
|
||
openNote({
|
||
editingId: entry.id ?? -1, // -1: a port with no row yet — a note creates one
|
||
port: String(entry.port),
|
||
protocol: entry.protocol,
|
||
label: entry.label ?? "",
|
||
comment: entry.comment ?? "",
|
||
});
|
||
}
|
||
|
||
async function saveNote(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!note) return;
|
||
setSavingNote(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api.servers.ports.save(serverId, {
|
||
port: Number(note.port),
|
||
protocol: note.protocol,
|
||
label: note.label.trim(),
|
||
comment: note.comment.trim(),
|
||
});
|
||
setData((prev) => (prev ? { ...prev, ports: res.ports } : prev));
|
||
// A port that was just reserved is no longer "available" in the last scan's result.
|
||
setResult((prev) => {
|
||
if (!prev) return prev;
|
||
const port = Number(note.port);
|
||
const ranges = prev.freeRanges.flatMap(([a, b]): [number, number][] => {
|
||
if (port < a || port > b) return [[a, b]];
|
||
const out: [number, number][] = [];
|
||
if (port > a) out.push([a, port - 1]);
|
||
if (port < b) out.push([port + 1, b]);
|
||
return out;
|
||
});
|
||
const removed = prev.freeRanges.some(([a, b]) => port >= a && port <= b);
|
||
return { ...prev, freeRanges: ranges, freeCount: prev.freeCount - (removed && note.protocol === "tcp" ? 1 : 0) };
|
||
});
|
||
setNote(null);
|
||
} catch (err) {
|
||
setError(readableError(err));
|
||
} finally {
|
||
setSavingNote(false);
|
||
}
|
||
}
|
||
|
||
async function removeNote(entry: PortEntry) {
|
||
if (entry.id === null) return;
|
||
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
|
||
const reserved = entry.state === "reserved";
|
||
if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return;
|
||
try {
|
||
await api.servers.ports.remove(serverId, entry.id);
|
||
setData(await api.servers.ports.list(serverId));
|
||
} catch (err) {
|
||
setError(readableError(err));
|
||
}
|
||
}
|
||
|
||
function exportCsv() {
|
||
if (!visible) return;
|
||
downloadCsv(
|
||
"ports.csv",
|
||
["Port", "Protocol", "Status", "Service", "Process", "Comment"],
|
||
visible.map((p) => [p.port, p.protocol, p.state, p.label ?? "", p.agent?.process ?? "", p.comment ?? ""]),
|
||
);
|
||
}
|
||
|
||
const shownRanges = result ? (showAllRanges ? result.freeRanges : result.freeRanges.slice(0, MAX_SHOWN_RANGES)) : [];
|
||
const editing = note !== null && note.editingId !== null;
|
||
|
||
return (
|
||
<div className="card mb-3">
|
||
<div className="card-header">
|
||
<h3 className="card-title">Ports</h3>
|
||
<div className="card-actions btn-list flex-nowrap">
|
||
{data && data.ports.length > 0 && (
|
||
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv}>
|
||
Export CSV
|
||
</button>
|
||
)}
|
||
{canEdit && (
|
||
<>
|
||
<button className="btn btn-outline-secondary btn-sm" onClick={() => openNote()}>
|
||
Add note
|
||
</button>
|
||
<button className="btn btn-primary btn-sm" onClick={() => setShowScan((s) => !s)}>
|
||
Scan…
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card-body pb-2">
|
||
{error && <div className="alert alert-danger">{error}</div>}
|
||
|
||
{data && (
|
||
<div className="text-secondary small mb-2">
|
||
{data.agentReporting ? (
|
||
<>Listening ports reported by the agent{data.agentReportedAt ? ` as of ${formatDateTime(new Date(data.agentReportedAt))}` : ""}. </>
|
||
) : (
|
||
<>
|
||
The agent hasn't reported listening ports (update it to see services bound to localhost only, which a scan can't
|
||
detect).{" "}
|
||
</>
|
||
)}
|
||
{data.lastScan ? (
|
||
<>
|
||
Last scan {formatDateTime(new Date(data.lastScan.at))}: {data.lastScan.address}, ports {data.lastScan.from}–
|
||
{data.lastScan.to}.
|
||
</>
|
||
) : (
|
||
<>Not scanned yet.</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{canEdit && showScan && (
|
||
<form onSubmit={runScan} className="row g-2 align-items-end border rounded p-3 mb-3">
|
||
<div className="col-md-4">
|
||
<label className="form-label">Address to scan</label>
|
||
<input
|
||
className="form-control"
|
||
required
|
||
list={`scan-addresses-${serverId}`}
|
||
placeholder="e.g. 10.0.0.5"
|
||
value={address}
|
||
onChange={(e) => setAddress(e.target.value)}
|
||
/>
|
||
<datalist id={`scan-addresses-${serverId}`}>
|
||
{addresses.map((a) => (
|
||
<option key={a} value={a} />
|
||
))}
|
||
</datalist>
|
||
</div>
|
||
<div className="col-md-4">
|
||
<label className="form-label">Ports</label>
|
||
<select className="form-select" value={presetKey} onChange={(e) => setPresetKey(e.target.value)}>
|
||
{PRESETS.map((p) => (
|
||
<option key={p.key} value={p.key}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{presetKey === "custom" && (
|
||
<div className="col-md-2 d-flex gap-1 align-items-end">
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={65535}
|
||
required
|
||
aria-label="First port"
|
||
value={customFrom}
|
||
onChange={(e) => setCustomFrom(e.target.value)}
|
||
/>
|
||
<span className="pb-2">–</span>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={65535}
|
||
required
|
||
aria-label="Last port"
|
||
value={customTo}
|
||
onChange={(e) => setCustomTo(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="col-md-2">
|
||
<button type="submit" className="btn btn-primary w-100" disabled={scanning || !address.trim()}>
|
||
{scanning ? "Scanning…" : "Scan"}
|
||
</button>
|
||
</div>
|
||
<div className="col-12 text-secondary small">
|
||
A TCP scan from this app: it can see what's reachable from here, not services bound to localhost or blocked by a
|
||
firewall. Only private addresses can be scanned, up to 20,000 ports at a time.
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="border rounded p-3 mb-3">
|
||
{!result.scan.responded ? (
|
||
<div className="text-warning">
|
||
Nothing answered on {result.scan.address} — the host may be down, or a firewall is dropping everything. Existing
|
||
results were left as they were.
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="mb-2">
|
||
<strong>{result.scan.open}</strong> open in {result.scan.from}–{result.scan.to} on {result.scan.address}.{" "}
|
||
<strong>{result.freeCount}</strong> available
|
||
{result.scan.filtered > 0 && (
|
||
<span className="text-secondary">
|
||
{" "}
|
||
({result.scan.filtered} didn't answer at all, so can't be called available)
|
||
</span>
|
||
)}
|
||
.
|
||
</div>
|
||
{result.freeRanges.length > 0 ? (
|
||
<div className="d-flex flex-wrap gap-1 align-items-center">
|
||
{shownRanges.map((r) => (
|
||
<button
|
||
key={r[0]}
|
||
type="button"
|
||
className="btn btn-sm btn-outline-secondary"
|
||
title="Reserve this port"
|
||
disabled={!canEdit}
|
||
onClick={() => openNote({ port: String(r[0]) })}
|
||
>
|
||
{rangeText(r)}
|
||
</button>
|
||
))}
|
||
{result.freeRanges.length > MAX_SHOWN_RANGES && (
|
||
<button type="button" className="btn btn-link btn-sm" onClick={() => setShowAllRanges((s) => !s)}>
|
||
{showAllRanges ? "Show fewer" : `Show all ${result.freeRanges.length} ranges`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-secondary">No port in this range was confirmed free.</div>
|
||
)}
|
||
{result.freeRanges.length > 0 && canEdit && (
|
||
<div className="text-secondary small mt-2">Click a range to reserve a port from it with a note.</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{canEdit && note && (
|
||
<form onSubmit={saveNote} className="row g-2 align-items-end border rounded p-3 mb-3">
|
||
<div className="col-md-2">
|
||
<label className="form-label">Port</label>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={65535}
|
||
required
|
||
disabled={editing}
|
||
value={note.port}
|
||
onChange={(e) => setNote({ ...note, port: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="col-md-2">
|
||
<label className="form-label">Protocol</label>
|
||
<select
|
||
className="form-select"
|
||
disabled={editing}
|
||
value={note.protocol}
|
||
onChange={(e) => setNote({ ...note, protocol: e.target.value as "tcp" | "udp" })}
|
||
>
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
</select>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Service</label>
|
||
<input
|
||
className="form-control"
|
||
maxLength={100}
|
||
placeholder="e.g. Grafana"
|
||
value={note.label}
|
||
onChange={(e) => setNote({ ...note, label: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="col-md-5">
|
||
<label className="form-label">Comment</label>
|
||
<input
|
||
className="form-control"
|
||
maxLength={500}
|
||
placeholder="e.g. proxied by nginx, or: reserved for the new monitoring stack"
|
||
value={note.comment}
|
||
onChange={(e) => setNote({ ...note, comment: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="col-12 d-flex gap-2">
|
||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingNote}>
|
||
{editing ? "Save" : "Add"}
|
||
</button>
|
||
<button type="button" className="btn btn-sm" onClick={() => setNote(null)}>
|
||
Cancel
|
||
</button>
|
||
{!editing && <span className="text-secondary small align-self-center">A port with a note counts as taken, even when nothing is listening.</span>}
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{data && data.ports.length > 0 && (
|
||
<input
|
||
className="form-control form-control-sm mb-2"
|
||
style={{ maxWidth: 280 }}
|
||
placeholder="Filter by port, service, or comment"
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{data && data.ports.length === 0 && (
|
||
<div className="card-body pt-0 text-secondary">
|
||
No ports recorded yet. {canEdit ? "Run a scan to find what's open, or add a note to reserve a port." : ""}
|
||
</div>
|
||
)}
|
||
|
||
{pageItems && pageItems.length > 0 && (
|
||
<div className="table-responsive">
|
||
<table className="table table-vcenter card-table">
|
||
<thead>
|
||
<tr>
|
||
<SortableTh<PortEntry> label="Port" sortKeyName="port" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||
<SortableTh<PortEntry> label="Proto" sortKeyName="protocol" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||
<SortableTh<PortEntry> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||
<SortableTh<PortEntry> label="Service" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||
<SortableTh<PortEntry> label="Comment" sortKeyName="comment" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||
{canEdit && <th className="w-1">Actions</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pageItems.map((p) => (
|
||
<tr key={`${p.protocol}:${p.port}`}>
|
||
<td>
|
||
<code>{p.port}</code>
|
||
</td>
|
||
<td className="text-secondary">{p.protocol}</td>
|
||
<td>
|
||
<StatusBadges entry={p} />
|
||
</td>
|
||
<td>
|
||
{p.label ?? <span className="text-secondary">—</span>}
|
||
{p.agent?.process && <div className="text-secondary small">{p.agent.process}</div>}
|
||
</td>
|
||
<td className="text-secondary">{p.comment ?? "—"}</td>
|
||
{canEdit && (
|
||
<td>
|
||
<div className="btn-list flex-nowrap">
|
||
<button className="btn btn-sm btn-outline-secondary" onClick={() => editEntry(p)}>
|
||
{p.label || p.comment ? "Edit" : "Add note"}
|
||
</button>
|
||
{(p.label || p.comment) && (
|
||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeNote(p)}>
|
||
{p.state === "reserved" ? "Remove" : "Clear"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{visible && visible.length === 0 && data && data.ports.length > 0 && (
|
||
<div className="card-body pt-0 text-secondary">Nothing matches that filter.</div>
|
||
)}
|
||
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||
</div>
|
||
);
|
||
}
|