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>
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { api, type AdminLink, type CurrentUser, type PortEntry, type ServerRecord } from "../api/client";
|
|
import { useSortable } from "../hooks/useSortable";
|
|
import SortableTh from "../components/SortableTh";
|
|
import { downloadCsv } from "../utils/csv";
|
|
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
|
import { confirmDialog } from "../utils/dialogs";
|
|
|
|
interface LinkForm {
|
|
serverId: number | "";
|
|
label: string;
|
|
url: string;
|
|
}
|
|
|
|
const emptyForm: LinkForm = { serverId: "", label: "", url: "" };
|
|
|
|
export default function AdminLinks({ user }: { user: CurrentUser }) {
|
|
const canEdit = user.role === "admin" || user.role === "operator";
|
|
|
|
const [links, setLinks] = useState<AdminLink[] | null>(null);
|
|
const [servers, setServers] = useState<ServerRecord[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const [editing, setEditing] = useState<AdminLink | null>(null);
|
|
const [adding, setAdding] = useState(false);
|
|
const [form, setForm] = useState<LinkForm>(emptyForm);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const [portOptions, setPortOptions] = useState<PortEntry[] | null>(null);
|
|
const [portAddress, setPortAddress] = useState<string | null>(null);
|
|
const [loadingPorts, setLoadingPorts] = useState(false);
|
|
const [portPick, setPortPick] = useState("");
|
|
|
|
function load() {
|
|
api.servers.allLinks()
|
|
.then((res) => setLinks(res.links))
|
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
}
|
|
|
|
useEffect(() => {
|
|
load();
|
|
api.servers
|
|
.list()
|
|
.then((res) => setServers(res.servers))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Once a server is picked in the add/edit form, offer to fill the URL from one of its own known
|
|
// ports (agent-reported or already noted on its Ports card) instead of typing it out by hand.
|
|
useEffect(() => {
|
|
setPortPick("");
|
|
if (form.serverId === "") {
|
|
setPortOptions(null);
|
|
setPortAddress(null);
|
|
return;
|
|
}
|
|
const serverId = form.serverId;
|
|
setLoadingPorts(true);
|
|
Promise.all([api.servers.detail(serverId), api.servers.ports.list(serverId)])
|
|
.then(([detail, portList]) => {
|
|
setPortAddress(detail.server.hostname || detail.ipAddresses[0] || null);
|
|
setPortOptions(portList.ports);
|
|
})
|
|
.catch(() => {
|
|
setPortOptions(null);
|
|
setPortAddress(null);
|
|
})
|
|
.finally(() => setLoadingPorts(false));
|
|
}, [form.serverId]);
|
|
|
|
function pickPort(key: string) {
|
|
setPortPick(key);
|
|
const entry = portOptions?.find((p) => `${p.protocol}:${p.port}` === key);
|
|
if (!entry) return;
|
|
const url = guessAdminUrl(portAddress, entry);
|
|
setForm((f) => ({ ...f, url: url ?? f.url, label: f.label || entry.label || entry.agent?.process || "" }));
|
|
}
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!links) return [];
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return links;
|
|
return links.filter((l) => [l.serverName, l.serverHostname, l.label, l.url].some((v) => (v ?? "").toLowerCase().includes(q)));
|
|
}, [links, search]);
|
|
|
|
const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered, "serverName");
|
|
|
|
function exportCsv() {
|
|
downloadCsv(
|
|
"admin-links.csv",
|
|
["Server", "Label", "URL"],
|
|
(sorted ?? []).map((l) => [l.serverName, l.label, l.url]),
|
|
);
|
|
}
|
|
|
|
function startAdd() {
|
|
setAdding(true);
|
|
setEditing(null);
|
|
setForm(emptyForm);
|
|
}
|
|
|
|
function startEdit(link: AdminLink) {
|
|
setEditing(link);
|
|
setAdding(false);
|
|
setForm({ serverId: link.serverId, label: link.label, url: link.url });
|
|
}
|
|
|
|
function cancelForm() {
|
|
setAdding(false);
|
|
setEditing(null);
|
|
setForm(emptyForm);
|
|
}
|
|
|
|
async function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (form.serverId === "") return;
|
|
setError(null);
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
await api.servers.updateLink(editing.serverId, editing.id, { label: form.label, url: form.url });
|
|
} else {
|
|
await api.servers.addLink(form.serverId, { label: form.label, url: form.url });
|
|
}
|
|
cancelForm();
|
|
load();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function remove(link: AdminLink) {
|
|
if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return;
|
|
setError(null);
|
|
try {
|
|
await api.servers.removeLink(link.serverId, link.id);
|
|
load();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
const showForm = adding || editing;
|
|
|
|
return (
|
|
<>
|
|
<h2 className="page-title mb-3">Admin Links</h2>
|
|
<p className="text-secondary">
|
|
Every admin bookmark added to a server's own page (Infrastructure → Servers → a server → Admin Links) — Dockge,
|
|
Webmin, Cockpit, and the like — in one place, instead of visiting each server to find them.
|
|
</p>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{canEdit && showForm && (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add an admin link"}</h3>
|
|
</div>
|
|
<form onSubmit={submit}>
|
|
<div className="card-body row g-3">
|
|
<div className="col-md-3">
|
|
<label className="form-label">Server</label>
|
|
<select
|
|
className="form-select"
|
|
required
|
|
disabled={!!editing}
|
|
value={form.serverId}
|
|
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : "" })}
|
|
>
|
|
<option value="" disabled>
|
|
— choose a server —
|
|
</option>
|
|
{(servers ?? []).map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{editing && <div className="form-hint">Remove and re-add to move it to a different server.</div>}
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Label</label>
|
|
<input
|
|
className="form-control"
|
|
required
|
|
maxLength={60}
|
|
placeholder="e.g. Dockge, Webmin, Cockpit"
|
|
value={form.label}
|
|
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">URL</label>
|
|
<input
|
|
type="url"
|
|
className="form-control"
|
|
required
|
|
placeholder="http://10.0.0.5:5001"
|
|
value={form.url}
|
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
|
/>
|
|
</div>
|
|
{form.serverId !== "" && (
|
|
<div className="col-12">
|
|
<label className="form-label">Fill from a known port (optional)</label>
|
|
<select
|
|
className="form-select"
|
|
style={{ maxWidth: 460 }}
|
|
value={portPick}
|
|
disabled={loadingPorts || !portOptions?.length}
|
|
onChange={(e) => pickPort(e.target.value)}
|
|
>
|
|
<option value="">
|
|
{loadingPorts
|
|
? "Loading this server's ports…"
|
|
: portOptions?.length
|
|
? "— pick a port to fill in the URL above —"
|
|
: "No known ports for this server yet"}
|
|
</option>
|
|
{portOptions?.map((p) => (
|
|
<option key={`${p.protocol}:${p.port}`} value={`${p.protocol}:${p.port}`}>
|
|
{portOptionLabel(p)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{portOptions && portOptions.length > 0 && !portAddress && (
|
|
<div className="form-hint">
|
|
This server has no known hostname or IP yet, so picking a port won't fill in an address —
|
|
see its Ports card under Infrastructure → Servers.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="card-footer d-flex gap-2">
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
{editing ? "Save changes" : "Add link"}
|
|
</button>
|
|
<button type="button" className="btn" onClick={cancelForm}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<input
|
|
className="form-control"
|
|
style={{ maxWidth: 280 }}
|
|
placeholder="Search server, label, URL…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
<div className="card-actions">
|
|
{canEdit && !showForm && (
|
|
<button className="btn btn-primary btn-sm" onClick={startAdd}>
|
|
Add link
|
|
</button>
|
|
)}
|
|
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableTh<AdminLink> label="Server" sortKeyName="serverName" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<AdminLink> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<th>URL</th>
|
|
{canEdit && <th className="w-1">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(sorted ?? []).map((l) => (
|
|
<tr key={l.id}>
|
|
<td>
|
|
<Link to={`/servers/${l.serverId}`}>{l.serverName}</Link>
|
|
{l.serverHostname && <div className="text-secondary small">{l.serverHostname}</div>}
|
|
</td>
|
|
<td>{l.label}</td>
|
|
<td>
|
|
<a href={l.url} target="_blank" rel="noopener noreferrer">
|
|
{l.url}
|
|
</a>
|
|
</td>
|
|
{canEdit && (
|
|
<td>
|
|
<div className="btn-list flex-nowrap">
|
|
<button className="btn btn-sm" onClick={() => startEdit(l)}>
|
|
Edit
|
|
</button>
|
|
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(l)}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
{links !== null && links.length === 0 && (
|
|
<tr>
|
|
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
|
|
No admin links added yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{links !== null && links.length > 0 && (sorted ?? []).length === 0 && (
|
|
<tr>
|
|
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
|
|
Nothing matches "{search}".
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|