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>
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { api, type CurrentUser, type ServerRecord } from "../api/client";
|
|
import CopyButton from "../components/CopyButton";
|
|
import { formatDateTime } from "../utils/date";
|
|
import { useSortable } from "../hooks/useSortable";
|
|
import SortableTh from "../components/SortableTh";
|
|
import { usePagination } from "../hooks/usePagination";
|
|
import Pagination from "../components/Pagination";
|
|
import { downloadCsv } from "../utils/csv";
|
|
import { TagBadges } from "../components/ServerTags";
|
|
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
|
import { tagBadge, useTagColors } from "../utils/tags";
|
|
import { confirmDialog } from "../utils/dialogs";
|
|
|
|
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
|
const color = colors[type];
|
|
if (!color) return {};
|
|
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
|
|
}
|
|
|
|
|
|
export default function Servers({ user }: { user: CurrentUser }) {
|
|
const isAdmin = user.role === "admin";
|
|
|
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [managingServers, setManagingServers] = useState(false);
|
|
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
|
|
// The active tag filter lives in the URL, so a tag chip on a server's page can link straight to "everything with this tag".
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const selectedTags = searchParams.getAll("tag");
|
|
const tagColors = useTagColors();
|
|
|
|
useEffect(() => {
|
|
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
|
}, []);
|
|
|
|
const [serverName, setServerName] = useState("");
|
|
const [serverHostname, setServerHostname] = useState("");
|
|
const [serverDescription, setServerDescription] = useState("");
|
|
const [serverOs, setServerOs] = useState<AgentOs>("linux");
|
|
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
|
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
|
|
const [insecureAgent, setInsecureAgent] = useState(false);
|
|
|
|
const loadServers = useCallback(() => {
|
|
return api.servers
|
|
.list()
|
|
.then((res) => setServers(res.servers))
|
|
.catch((err) => setError(String(err)));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadServers();
|
|
}, [loadServers]);
|
|
|
|
async function createServer(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
try {
|
|
const result = await api.servers.create({
|
|
name: serverName,
|
|
hostname: serverHostname || undefined,
|
|
description: serverDescription || undefined,
|
|
osType: serverOs,
|
|
});
|
|
setNewToken(result);
|
|
setServerName("");
|
|
setServerHostname("");
|
|
setServerDescription("");
|
|
await loadServers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function rotateToken(id: number) {
|
|
try {
|
|
const result = await api.servers.rotateToken(id);
|
|
setNewToken(result);
|
|
await loadServers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function deleteServer(id: number) {
|
|
if (!(await confirmDialog({ title: "Remove server", message: "Remove this server and all its tracked tasks?", confirmLabel: "Remove", danger: true }))) return;
|
|
try {
|
|
await api.servers.remove(id);
|
|
await loadServers();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
const { sorted: sortedServers, sortKey, sortDir, requestSort } = useSortable(servers);
|
|
const { pageItems: serverPageItems, page: serverPage, setPage: setServerPage, pageCount: serverPageCount, totalCount: serverTotalCount } =
|
|
usePagination(sortedServers);
|
|
|
|
const tagCounts = new Map<string, number>();
|
|
for (const s of servers) for (const t of s.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
|
|
// A tag from the URL that no server has (any more) stays listed, so it can still be switched off.
|
|
for (const t of selectedTags) if (!tagCounts.has(t)) tagCounts.set(t, 0);
|
|
const allTags = [...tagCounts.keys()].sort((a, b) => a.localeCompare(b, "sv"));
|
|
const visibleServers = servers.filter((s) => selectedTags.every((t) => s.tags.includes(t)));
|
|
|
|
function toggleTag(tag: string) {
|
|
const next = selectedTags.includes(tag) ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag];
|
|
setSearchParams(next.length > 0 ? { tag: next } : {}, { replace: true });
|
|
}
|
|
|
|
function exportCsv() {
|
|
if (!sortedServers) return;
|
|
downloadCsv(
|
|
"servers.csv",
|
|
["Name", "Hostname", "Tags", "Token prefix", "Last seen"],
|
|
sortedServers.map((s) => [
|
|
s.name,
|
|
s.hostname ?? "",
|
|
s.tags.join(" "),
|
|
s.apiTokenPrefix,
|
|
s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never",
|
|
]),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="d-flex align-items-center mb-3">
|
|
<h2 className="page-title mb-0">Servers</h2>
|
|
{isAdmin && (
|
|
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManagingServers((v) => !v)}>
|
|
{managingServers ? "Back to servers" : "Manage servers"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{managingServers ? (
|
|
<>
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Add a server</h3>
|
|
</div>
|
|
<form onSubmit={createServer}>
|
|
<div className="card-body row g-3">
|
|
<div className="col-md-3">
|
|
<label className="form-label">Server name</label>
|
|
<input
|
|
className="form-control"
|
|
required
|
|
placeholder="e.g. nas01"
|
|
value={serverName}
|
|
onChange={(e) => setServerName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="col-md-2">
|
|
<label className="form-label">Operating system</label>
|
|
<select className="form-select" value={serverOs} onChange={(e) => setServerOs(e.target.value as AgentOs)}>
|
|
<option value="linux">Linux</option>
|
|
<option value="windows">Windows</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Hostname</label>
|
|
<input
|
|
className="form-control"
|
|
placeholder="optional"
|
|
value={serverHostname}
|
|
onChange={(e) => setServerHostname(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label">Description</label>
|
|
<input
|
|
className="form-control"
|
|
placeholder="optional"
|
|
value={serverDescription}
|
|
onChange={(e) => setServerDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="card-footer">
|
|
<button type="submit" className="btn btn-primary">
|
|
Add server
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{newToken && (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Agent token for {newToken.server.name}</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<p className="text-secondary">This token is only shown once — copy it now.</p>
|
|
<div className="input-group mb-3">
|
|
<code className="form-control">{newToken.token}</code>
|
|
<CopyButton text={newToken.token} />
|
|
</div>
|
|
<label className="form-check mb-2">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={insecureAgent}
|
|
onChange={(e) => setInsecureAgent(e.target.checked)}
|
|
/>
|
|
<span className="form-check-label">
|
|
This Homelab Manager instance uses a self-signed certificate (skip TLS verification on the agent)
|
|
</span>
|
|
</label>
|
|
<p className="text-secondary">{AGENT_RUN_HINT[agentOsOf(newToken.server.osType)].install}</p>
|
|
<div className="input-group">
|
|
<pre className="form-control text-wrap mb-0">
|
|
{installCommand(agentOsOf(newToken.server.osType), window.location.origin, newToken.token, insecureAgent)}
|
|
</pre>
|
|
<CopyButton text={installCommand(agentOsOf(newToken.server.osType), window.location.origin, newToken.token, insecureAgent)} />
|
|
</div>
|
|
{agentOsOf(newToken.server.osType) === "windows" && insecureAgent && (
|
|
<div className="text-secondary small mt-2">
|
|
This form is for Windows PowerShell 5.1, the one built into Windows. In PowerShell 7 the download step needs{" "}
|
|
<code>-SkipCertificateCheck</code> instead.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="card-footer">
|
|
<button className="btn" onClick={() => setNewToken(null)}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{uninstallFor && (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Uninstall agent from {uninstallFor.name}</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<p className="text-secondary">{AGENT_RUN_HINT[agentOsOf(uninstallFor.osType)].uninstall}</p>
|
|
<div className="input-group">
|
|
<pre className="form-control text-wrap mb-0">
|
|
{uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)}
|
|
</pre>
|
|
<CopyButton text={uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)} />
|
|
</div>
|
|
</div>
|
|
<div className="card-footer">
|
|
<button className="btn" onClick={() => setUninstallFor(null)}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="card-header justify-content-end">
|
|
<button className="btn btn-outline-secondary btn-sm" onClick={exportCsv} disabled={servers.length === 0}>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableTh<ServerRecord> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
|
<SortableTh<ServerRecord>
|
|
label="Hostname"
|
|
sortKeyName="hostname"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
<th>Tags</th>
|
|
<th>Token</th>
|
|
<SortableTh<ServerRecord>
|
|
label="Last seen"
|
|
sortKeyName="lastSeenAt"
|
|
activeKey={sortKey}
|
|
direction={sortDir}
|
|
onSort={requestSort}
|
|
/>
|
|
<th className="w-1">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{serverPageItems?.map((s) => (
|
|
<tr key={s.id}>
|
|
<td>
|
|
<Link to={`/servers/${s.id}`}>{s.name}</Link>
|
|
</td>
|
|
<td>{s.hostname ?? "—"}</td>
|
|
<td>
|
|
<div className="d-flex flex-wrap gap-1">
|
|
<TagBadges tags={s.tags} />
|
|
</div>
|
|
</td>
|
|
<td className="text-secondary">{s.apiTokenPrefix}…</td>
|
|
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
|
|
<td>
|
|
<div className="btn-list flex-nowrap">
|
|
<button className="btn btn-sm" onClick={() => rotateToken(s.id)}>
|
|
Rotate token
|
|
</button>
|
|
<button className="btn btn-sm" onClick={() => setUninstallFor(s)}>
|
|
Uninstall
|
|
</button>
|
|
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteServer(s.id)}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{sortedServers?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="text-secondary text-center">
|
|
No servers registered yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination page={serverPage} pageCount={serverPageCount} totalCount={serverTotalCount} onPageChange={setServerPage} />
|
|
</div>
|
|
</>
|
|
) : servers.length === 0 ? (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">
|
|
No servers registered yet.
|
|
{isAdmin && (
|
|
<>
|
|
{" "}
|
|
<button className="btn btn-link p-0 align-baseline" onClick={() => setManagingServers(true)}>
|
|
Add one
|
|
</button>
|
|
.
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{allTags.length > 0 && (
|
|
<div className="d-flex flex-wrap align-items-center gap-1 mb-3">
|
|
<span className="text-secondary small me-1">Tags</span>
|
|
{allTags.map((tag) => {
|
|
const active = selectedTags.includes(tag);
|
|
const look = tagBadge(tag, tagColors);
|
|
return (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
className={active ? "badge border-0 bg-primary text-white" : `${look.className} border-0`}
|
|
style={active ? undefined : look.style}
|
|
aria-pressed={active}
|
|
onClick={() => toggleTag(tag)}
|
|
>
|
|
{tag} <span className="opacity-75">· {tagCounts.get(tag)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{selectedTags.length > 0 && (
|
|
<button type="button" className="btn btn-link btn-sm p-0 ms-1" onClick={() => setSearchParams({}, { replace: true })}>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{visibleServers.length === 0 && (
|
|
<div className="card">
|
|
<div className="card-body text-secondary">No server has {selectedTags.length > 1 ? "all of those tags" : "that tag"}.</div>
|
|
</div>
|
|
)}
|
|
<div className="row row-cards">
|
|
{visibleServers.map((s) => (
|
|
<div className="col-sm-6 col-lg-3" key={s.id}>
|
|
<Link to={`/servers/${s.id}`} className="card card-sm text-reset text-decoration-none">
|
|
<div className="card-body">
|
|
<div className="d-flex align-items-center">
|
|
<div className="flex-fill">
|
|
<div className="font-weight-medium">{s.name}</div>
|
|
<div className="text-secondary small">{s.hostname ?? "no hostname reported"}</div>
|
|
</div>
|
|
{s.proxmoxIntegrationId ? (
|
|
<span className="badge" style={typeBadgeStyle(integrationColors, "proxmox")}>
|
|
Proxmox
|
|
</span>
|
|
) : s.lastSeenAt ? (
|
|
<span className="badge bg-green-lt text-green">Agent</span>
|
|
) : (
|
|
<span className="badge bg-secondary-lt text-secondary">No data</span>
|
|
)}
|
|
</div>
|
|
{s.tags.length > 0 && (
|
|
<div className="d-flex flex-wrap gap-1 mt-2">
|
|
<TagBadges tags={s.tags} />
|
|
</div>
|
|
)}
|
|
<div className="text-secondary small mt-2">
|
|
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|