Trim the Servers page down to a server list + add-server flow

Scheduled-task browsing/editing already lives on each server's detail
page (ServerDetail.tsx), so the overview page's filters, task-group
listing, and add/edit-task form were pure duplication -- the only
things it needs to do are list servers (as clickable cards) and let
an admin register new ones / issue or rotate agent tokens.

Renamed ServersTasks.tsx -> Servers.tsx to match its new, narrower
scope; updated the nav label, route, and ServerDetail's back-link
text from "Servers & Tasks" to "Servers" accordingly. No server-side
or API changes -- this only removes now-redundant frontend code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 18:38:46 +02:00
1 parent 9f2d27e2c4
commit a781df9c51
6 files changed
+319 -610

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom";
import { api, type CurrentUser, type ServerRecord } from "../api/client";
import CopyButton from "../components/CopyButton";
import { formatDateTime } from "../utils/date";
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` };
}
function installCommand(token: string, insecure: boolean): string {
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
const envVars = insecure
? `API_URL=${window.location.origin} API_TOKEN=${token} API_INSECURE=true`
: `API_URL=${window.location.origin} API_TOKEN=${token}`;
return `curl ${curlFlags} ${window.location.origin}/agent/linux/install.sh | sudo ${envVars} bash`;
}
function uninstallCommand(insecure: boolean): string {
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
return `curl ${curlFlags} ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
}
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>>({});
useEffect(() => {
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
}, []);
const [serverName, setServerName] = useState("");
const [serverHostname, setServerHostname] = useState("");
const [serverDescription, setServerDescription] = useState("");
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,
});
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 (!confirm("Remove this server and all its tracked tasks?")) return;
try {
await api.servers.remove(id);
await loadServers();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
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-4">
<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-4">
<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">
Install the agent on the server (run as root — put sudo right after the pipe, not before curl):
</p>
<div className="input-group">
<pre className="form-control text-wrap mb-0">{installCommand(newToken.token, insecureAgent)}</pre>
<CopyButton text={installCommand(newToken.token, insecureAgent)} />
</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">Run this on the server as root to stop and remove the agent (its entry here is kept):</p>
<div className="input-group">
<pre className="form-control text-wrap mb-0">{uninstallCommand(insecureAgent)}</pre>
<CopyButton text={uninstallCommand(insecureAgent)} />
</div>
</div>
<div className="card-footer">
<button className="btn" onClick={() => setUninstallFor(null)}>
Dismiss
</button>
</div>
</div>
)}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Token</th>
<th>Last seen</th>
<th className="w-1">Actions</th>
</tr>
</thead>
<tbody>
{servers.map((s) => (
<tr key={s.id}>
<td>
<Link to={`/servers/${s.id}`}>{s.name}</Link>
</td>
<td>{s.hostname ?? "—"}</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>
))}
{servers.length === 0 && (
<tr>
<td colSpan={5} className="text-secondary text-center">
No servers registered yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</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>
) : (
<div className="row row-cards">
{servers.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>
<div className="text-secondary small mt-2">
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
</div>
</div>
</Link>
</div>
))}
</div>
)}
</>
);
}