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:
1 parent
9f2d27e2c4
commit
a781df9c51
6 files changed
+319
-610
No files matched your search
@@ -26,14 +26,16 @@ All modules from the original plan are built:
|
|||||||
- **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
|
- **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
|
||||||
DNS, cPanel, and Technitium; providers are configured in-app (not via env
|
DNS, cPanel, and Technitium; providers are configured in-app (not via env
|
||||||
vars) and their credentials are encrypted at rest
|
vars) and their credentials are encrypted at rest
|
||||||
- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers
|
- **Servers** — cron/systemd tracking across Debian/Raspbian servers via a
|
||||||
via a lightweight push agent (`agent/linux/`), plus manual entries for
|
lightweight push agent (`agent/linux/`), plus manual entries for things an
|
||||||
things an agent can't see (Docker jobs, backups). A clickable server
|
agent can't see (Docker jobs, backups). The Servers page itself just lists
|
||||||
overview opens a detail page with CPU/RAM/disk status, IP addresses, and
|
registered servers and (admin-only) adds new ones / issues agent tokens;
|
||||||
matching DNS names (looked up from the DNS module's cache) — live from
|
clicking a server opens its detail page with CPU/RAM/disk status, IP
|
||||||
Proxmox for VM/LXC-backed servers, or from the agent's own hardware
|
addresses, matching DNS names (looked up from the DNS module's cache), and
|
||||||
report for everything else. Proxmox-linked servers also get
|
its scheduled tasks — live hardware from Proxmox for VM/LXC-backed
|
||||||
start/stop/restart buttons right on the detail page.
|
servers, or from the agent's own hardware report for everything else.
|
||||||
|
Proxmox-linked servers also get start/stop/restart buttons right on the
|
||||||
|
detail page.
|
||||||
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
||||||
and **Docker** each get their own top-level page (backed by the
|
and **Docker** each get their own top-level page (backed by the
|
||||||
matching integration) instead of living inside a shared Integrations
|
matching integration) instead of living inside a shared Integrations
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@ import AuditLog from "./pages/AuditLog";
|
|||||||
import Secrets from "./pages/Secrets";
|
import Secrets from "./pages/Secrets";
|
||||||
import Ipam from "./pages/Ipam";
|
import Ipam from "./pages/Ipam";
|
||||||
import Dns from "./pages/Dns";
|
import Dns from "./pages/Dns";
|
||||||
import ServersTasks from "./pages/ServersTasks";
|
import Servers from "./pages/Servers";
|
||||||
import ServerDetail from "./pages/ServerDetail";
|
import ServerDetail from "./pages/ServerDetail";
|
||||||
import Integrations from "./pages/Integrations";
|
import Integrations from "./pages/Integrations";
|
||||||
import Docker from "./pages/Docker";
|
import Docker from "./pages/Docker";
|
||||||
@@ -72,7 +72,7 @@ export default function App() {
|
|||||||
<AppShell user={user}>
|
<AppShell user={user}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard user={user} />} />
|
<Route path="/" element={<Dashboard user={user} />} />
|
||||||
<Route path="/servers" element={<ServersTasks user={user} />} />
|
<Route path="/servers" element={<Servers user={user} />} />
|
||||||
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
||||||
<Route path="/dns" element={<Dns user={user} />} />
|
<Route path="/dns" element={<Dns user={user} />} />
|
||||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface NavItem {
|
|||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: "/", label: "Dashboard", icon: <IconLayoutDashboard size={20} /> },
|
{ to: "/", label: "Dashboard", icon: <IconLayoutDashboard size={20} /> },
|
||||||
{ to: "/servers", label: "Servers & Tasks", icon: <IconServer2 size={20} /> },
|
{ to: "/servers", label: "Servers", icon: <IconServer2 size={20} /> },
|
||||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||||
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Link to="/servers" className="text-secondary">
|
<Link to="/servers" className="text-secondary">
|
||||||
← Servers & Tasks
|
← Servers
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center mb-3">
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type CurrentUser,
|
|
||||||
type ManualTaskInput,
|
|
||||||
type ScheduleType,
|
|
||||||
type ServerRecord,
|
|
||||||
type TaskRecord,
|
|
||||||
} from "../api/client";
|
|
||||||
import CopyButton from "../components/CopyButton";
|
|
||||||
import ServerTaskTable from "../components/ServerTaskTable";
|
|
||||||
import { formatDateTime } from "../utils/date";
|
|
||||||
|
|
||||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
|
||||||
{ value: "docker", label: "Docker" },
|
|
||||||
{ value: "cron", label: "Cron" },
|
|
||||||
{ value: "systemd_timer", label: "systemd timer" },
|
|
||||||
{ value: "backup", label: "Backup" },
|
|
||||||
{ value: "update", label: "Update" },
|
|
||||||
{ value: "n8n_workflow", label: "n8n workflow" },
|
|
||||||
{ value: "manual", label: "Other / manual" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyTaskForm = {
|
|
||||||
scheduleType: "docker" as ScheduleType,
|
|
||||||
name: "",
|
|
||||||
command: "",
|
|
||||||
scheduleExpression: "",
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ServersTasks({ user }: { user: CurrentUser }) {
|
|
||||||
const isAdmin = user.role === "admin";
|
|
||||||
const canEditTasks = user.role === "admin" || user.role === "operator";
|
|
||||||
|
|
||||||
const [servers, setServers] = useState<ServerRecord[]>([]);
|
|
||||||
const [tasks, setTasks] = useState<TaskRecord[] | null>(null);
|
|
||||||
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 [filters, setFilters] = useState({
|
|
||||||
serverId: undefined as number | undefined,
|
|
||||||
scheduleType: undefined as string | undefined,
|
|
||||||
search: "",
|
|
||||||
includeStale: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [taskForm, setTaskForm] = useState<typeof emptyTaskForm | null>(null);
|
|
||||||
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
|
||||||
const [newTaskServerId, setNewTaskServerId] = useState<number | undefined>(undefined);
|
|
||||||
const [savingTask, setSavingTask] = useState(false);
|
|
||||||
|
|
||||||
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)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTasks = useCallback(() => {
|
|
||||||
return api.tasks
|
|
||||||
.list(filters)
|
|
||||||
.then((res) => setTasks(res.tasks))
|
|
||||||
.catch((err) => setError(String(err)));
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadServers();
|
|
||||||
}, [loadServers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(loadTasks, 200);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [loadTasks]);
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
|
||||||
for (const task of tasks ?? []) {
|
|
||||||
const entry = byServer.get(task.serverId) ?? { serverId: task.serverId, serverName: task.serverName, tasks: [] };
|
|
||||||
entry.tasks.push(task);
|
|
||||||
byServer.set(task.serverId, entry);
|
|
||||||
}
|
|
||||||
return [...byServer.values()];
|
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
function openAddTask() {
|
|
||||||
setEditingTask(null);
|
|
||||||
setNewTaskServerId(filters.serverId ?? servers[0]?.id);
|
|
||||||
setTaskForm({ ...emptyTaskForm });
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditTask(task: TaskRecord) {
|
|
||||||
setEditingTask(task);
|
|
||||||
setTaskForm({
|
|
||||||
scheduleType: task.scheduleType,
|
|
||||||
name: task.name,
|
|
||||||
command: task.command ?? "",
|
|
||||||
scheduleExpression: task.scheduleExpression ?? "",
|
|
||||||
enabled: task.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitTask(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!taskForm) return;
|
|
||||||
setSavingTask(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const serverId = editingTask?.serverId ?? newTaskServerId;
|
|
||||||
if (!serverId) {
|
|
||||||
setError("Choose a server first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data: ManualTaskInput = {
|
|
||||||
serverId,
|
|
||||||
scheduleType: taskForm.scheduleType,
|
|
||||||
name: taskForm.name,
|
|
||||||
command: taskForm.command || undefined,
|
|
||||||
scheduleExpression: taskForm.scheduleExpression || undefined,
|
|
||||||
enabled: taskForm.enabled,
|
|
||||||
};
|
|
||||||
if (editingTask) {
|
|
||||||
await api.tasks.update(editingTask.id, data);
|
|
||||||
} else {
|
|
||||||
await api.tasks.create(data);
|
|
||||||
}
|
|
||||||
setTaskForm(null);
|
|
||||||
setEditingTask(null);
|
|
||||||
await loadTasks();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setSavingTask(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTask(task: TaskRecord) {
|
|
||||||
if (!confirm(`Delete "${task.name}"?`)) return;
|
|
||||||
try {
|
|
||||||
await api.tasks.remove(task.id);
|
|
||||||
await loadTasks();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 & Tasks</h2>
|
|
||||||
{isAdmin && (
|
|
||||||
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManagingServers((v) => !v)}>
|
|
||||||
{managingServers ? "Back to tasks" : "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="row row-cards mb-3">
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card mb-3">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row g-2 align-items-end">
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Server</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={filters.serverId ?? ""}
|
|
||||||
onChange={(e) => setFilters({ ...filters, serverId: e.target.value ? Number(e.target.value) : undefined })}
|
|
||||||
>
|
|
||||||
<option value="">All servers</option>
|
|
||||||
{servers.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Schedule type</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={filters.scheduleType ?? ""}
|
|
||||||
onChange={(e) => setFilters({ ...filters, scheduleType: e.target.value || undefined })}
|
|
||||||
>
|
|
||||||
<option value="">All schedule types</option>
|
|
||||||
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Search</label>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Name or command…"
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3 d-flex align-items-center gap-3">
|
|
||||||
<label className="form-check mb-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input"
|
|
||||||
checked={filters.includeStale}
|
|
||||||
onChange={(e) => setFilters({ ...filters, includeStale: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span className="form-check-label">Show stale/missing</span>
|
|
||||||
</label>
|
|
||||||
{canEditTasks && (
|
|
||||||
<button className="btn btn-primary ms-auto" onClick={openAddTask} disabled={servers.length === 0}>
|
|
||||||
Add task
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{taskForm && (
|
|
||||||
<div className="card mb-3">
|
|
||||||
<div className="card-header">
|
|
||||||
<h3 className="card-title">{editingTask ? "Edit task" : "Add a task manually"}</h3>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={submitTask}>
|
|
||||||
<div className="card-body row g-3">
|
|
||||||
{!editingTask && (
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Server</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
required
|
|
||||||
value={newTaskServerId ?? ""}
|
|
||||||
onChange={(e) => setNewTaskServerId(e.target.value ? Number(e.target.value) : undefined)}
|
|
||||||
>
|
|
||||||
<option value="">Choose a server…</option>
|
|
||||||
{servers.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Schedule type</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={taskForm.scheduleType}
|
|
||||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleType: e.target.value as ScheduleType })}
|
|
||||||
>
|
|
||||||
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Name</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
required
|
|
||||||
placeholder="e.g. postgres-backup"
|
|
||||||
value={taskForm.name}
|
|
||||||
onChange={(e) => setTaskForm({ ...taskForm, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Command / description</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="e.g. docker exec pg-backup /backup.sh"
|
|
||||||
value={taskForm.command}
|
|
||||||
onChange={(e) => setTaskForm({ ...taskForm, command: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label className="form-label">Schedule</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
placeholder="e.g. daily at 02:00, or 0 2 * * *"
|
|
||||||
value={taskForm.scheduleExpression}
|
|
||||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleExpression: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input"
|
|
||||||
checked={taskForm.enabled}
|
|
||||||
onChange={(e) => setTaskForm({ ...taskForm, enabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span className="form-check-label">Enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-footer d-flex gap-2">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={savingTask}>
|
|
||||||
{editingTask ? "Save changes" : "Add task"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
onClick={() => {
|
|
||||||
setTaskForm(null);
|
|
||||||
setEditingTask(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tasks && groups.length === 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-body text-secondary">
|
|
||||||
No scheduled tasks found yet. Install the agent on a server to auto-report cron/systemd tasks, or use
|
|
||||||
"Add task" above for things like Docker-based backup jobs.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groups.map((group) => (
|
|
||||||
<div className="card mb-3" key={group.serverId}>
|
|
||||||
<div className="card-header">
|
|
||||||
<h3 className="card-title">
|
|
||||||
<Link to={`/servers/${group.serverId}`}>{group.serverName}</Link>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<ServerTaskTable tasks={group.tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user