diff --git a/README.md b/README.md index 1584696..97845b9 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,16 @@ All modules from the original plan are built: - **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure DNS, cPanel, and Technitium; providers are configured in-app (not via env vars) and their credentials are encrypted at rest -- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers - via a lightweight push agent (`agent/linux/`), plus manual entries for - things an agent can't see (Docker jobs, backups). A clickable server - overview opens a detail page with CPU/RAM/disk status, IP addresses, and - matching DNS names (looked up from the DNS module's cache) — live from - Proxmox for VM/LXC-backed 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. +- **Servers** — cron/systemd tracking across Debian/Raspbian servers via a + lightweight push agent (`agent/linux/`), plus manual entries for things an + agent can't see (Docker jobs, backups). The Servers page itself just lists + registered servers and (admin-only) adds new ones / issues agent tokens; + clicking a server opens its detail page with CPU/RAM/disk status, IP + addresses, matching DNS names (looked up from the DNS module's cache), and + its scheduled tasks — live hardware from Proxmox for VM/LXC-backed + 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**, and **Docker** each get their own top-level page (backed by the matching integration) instead of living inside a shared Integrations diff --git a/web/src/App.tsx b/web/src/App.tsx index a3a6d23..bdfb80e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,7 +8,7 @@ import AuditLog from "./pages/AuditLog"; import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; -import ServersTasks from "./pages/ServersTasks"; +import Servers from "./pages/Servers"; import ServerDetail from "./pages/ServerDetail"; import Integrations from "./pages/Integrations"; import Docker from "./pages/Docker"; @@ -72,7 +72,7 @@ export default function App() { } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 173b067..6b1b47a 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -29,7 +29,7 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { to: "/", label: "Dashboard", icon: }, - { to: "/servers", label: "Servers & Tasks", icon: }, + { to: "/servers", label: "Servers", icon: }, { to: "/dns", label: "DNS", icon: }, { to: "/ipam", label: "IP Addresses", icon: }, { to: "/secrets", label: "Secrets", icon: }, diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index 7f19662..09c9e14 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -255,7 +255,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { <>
- ← Servers & Tasks + ← Servers
diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx new file mode 100644 index 0000000..093d1fa --- /dev/null +++ b/web/src/pages/Servers.tsx @@ -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, 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([]); + const [error, setError] = useState(null); + const [managingServers, setManagingServers] = useState(false); + const [integrationColors, setIntegrationColors] = useState>({}); + + 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(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 ( + <> +
+

Servers

+ {isAdmin && ( + + )} +
+ {error &&
{error}
} + + {managingServers ? ( + <> +
+
+

Add a server

+
+
+
+
+ + setServerName(e.target.value)} + /> +
+
+ + setServerHostname(e.target.value)} + /> +
+
+ + setServerDescription(e.target.value)} + /> +
+
+
+ +
+
+
+ + {newToken && ( +
+
+

Agent token for {newToken.server.name}

+
+
+

This token is only shown once — copy it now.

+
+ {newToken.token} + +
+ +

+ Install the agent on the server (run as root — put sudo right after the pipe, not before curl): +

+
+
{installCommand(newToken.token, insecureAgent)}
+ +
+
+
+ +
+
+ )} + + {uninstallFor && ( +
+
+

Uninstall agent from {uninstallFor.name}

+
+
+

Run this on the server as root to stop and remove the agent (its entry here is kept):

+
+
{uninstallCommand(insecureAgent)}
+ +
+
+
+ +
+
+ )} + +
+
+ + + + + + + + + + + + {servers.map((s) => ( + + + + + + + + ))} + {servers.length === 0 && ( + + + + )} + +
NameHostnameTokenLast seenActions
+ {s.name} + {s.hostname ?? "—"}{s.apiTokenPrefix}…{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"} +
+ + + +
+
+ No servers registered yet. +
+
+
+ + ) : servers.length === 0 ? ( +
+
+ No servers registered yet. + {isAdmin && ( + <> + {" "} + + . + + )} +
+
+ ) : ( +
+ {servers.map((s) => ( +
+ +
+
+
+
{s.name}
+
{s.hostname ?? "no hostname reported"}
+
+ {s.proxmoxIntegrationId ? ( + + Proxmox + + ) : s.lastSeenAt ? ( + Agent + ) : ( + No data + )} +
+
+ {s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"} +
+
+ +
+ ))} +
+ )} + + ); +} diff --git a/web/src/pages/ServersTasks.tsx b/web/src/pages/ServersTasks.tsx deleted file mode 100644 index 73b150f..0000000 --- a/web/src/pages/ServersTasks.tsx +++ /dev/null @@ -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, 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([]); - const [tasks, setTasks] = useState(null); - const [error, setError] = useState(null); - const [managingServers, setManagingServers] = useState(false); - const [integrationColors, setIntegrationColors] = useState>({}); - - 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(null); - const [editingTask, setEditingTask] = useState(null); - const [newTaskServerId, setNewTaskServerId] = useState(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(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(); - 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 ( - <> -
-

Servers & Tasks

- {isAdmin && ( - - )} -
- {error &&
{error}
} - - {managingServers ? ( - <> -
-
-

Add a server

-
-
-
-
- - setServerName(e.target.value)} - /> -
-
- - setServerHostname(e.target.value)} - /> -
-
- - setServerDescription(e.target.value)} - /> -
-
-
- -
-
-
- - {newToken && ( -
-
-

Agent token for {newToken.server.name}

-
-
-

This token is only shown once — copy it now.

-
- {newToken.token} - -
- -

- Install the agent on the server (run as root — put sudo right after the pipe, not before curl): -

-
-
{installCommand(newToken.token, insecureAgent)}
- -
-
-
- -
-
- )} - - {uninstallFor && ( -
-
-

Uninstall agent from {uninstallFor.name}

-
-
-

Run this on the server as root to stop and remove the agent (its entry here is kept):

-
-
{uninstallCommand(insecureAgent)}
- -
-
-
- -
-
- )} - -
-
- - - - - - - - - - - - {servers.map((s) => ( - - - - - - - - ))} - {servers.length === 0 && ( - - - - )} - -
NameHostnameTokenLast seenActions
- {s.name} - {s.hostname ?? "—"}{s.apiTokenPrefix}…{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"} -
- - - -
-
- No servers registered yet. -
-
-
- - ) : ( - <> - {servers.length > 0 && ( -
- {servers.map((s) => ( -
- -
-
-
-
{s.name}
-
{s.hostname ?? "no hostname reported"}
-
- {s.proxmoxIntegrationId ? ( - - Proxmox - - ) : s.lastSeenAt ? ( - Agent - ) : ( - No data - )} -
-
- {s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"} -
-
- -
- ))} -
- )} - -
-
-
-
- - -
-
- - -
-
- - setFilters({ ...filters, search: e.target.value })} - /> -
-
- - {canEditTasks && ( - - )} -
-
-
-
- - {taskForm && ( -
-
-

{editingTask ? "Edit task" : "Add a task manually"}

-
-
-
- {!editingTask && ( -
- - -
- )} -
- - -
-
- - setTaskForm({ ...taskForm, name: e.target.value })} - /> -
-
- - setTaskForm({ ...taskForm, command: e.target.value })} - /> -
-
- - setTaskForm({ ...taskForm, scheduleExpression: e.target.value })} - /> -
-
- -
-
-
- - -
-
-
- )} - - {tasks && groups.length === 0 && ( -
-
- 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. -
-
- )} - - {groups.map((group) => ( -
-
-

- {group.serverName} -

-
- -
- ))} - - )} - - ); -}