diff --git a/README.md b/README.md index 5dfccd9..1584696 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,17 @@ All modules from the original plan are built: 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**, **Semaphore**, **Gitea**, and **Docker** each get their - own top-level page (backed by the matching integration) instead of - living inside a shared Integrations browsing view: +- **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 + browsing view: - **Tailscale** — device list with online/authorized status, and authorize/deauthorize/remove actions; a live device-count widget. + - **Proxmox** — VM/LXC status across every node in the cluster, with + start/restart/shutdown/stop actions; a live running/total widget. + Supports self-signed certificates (common in homelab setups). + - **Synology** — volume and disk health (read-only by design). + Supports self-signed certificates. - **Semaphore** — Ansible run status per template across every project, with a "Run" action to trigger a template; a live template-count widget (with a last-failed warning). @@ -51,13 +57,7 @@ All modules from the original plan are built: running/total widget. Add/edit/enable/disable/remove each integration's credential itself - under Integrations → Manage integrations, same as Proxmox and - Synology (which stay on the Integrations page). -- **Integrations → Proxmox** — VM/LXC status across every node in the - cluster, with start/stop/restart actions; a live running/total widget. - Supports self-signed certificates (common in homelab Proxmox setups). -- **Integrations → Synology** — volume and disk health (read-only by - design). Supports self-signed certificates. + under Integrations → Manage integrations. - **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP, generic webhook) with per-channel test buttons, per-event toggles (DNS record added/updated/deleted, daily secret-expiry reminder with a diff --git a/web/src/App.tsx b/web/src/App.tsx index 1761646..a3a6d23 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,8 @@ import Docker from "./pages/Docker"; import Tailscale from "./pages/Tailscale"; import Semaphore from "./pages/Semaphore"; import Gitea from "./pages/Gitea"; +import Proxmox from "./pages/Proxmox"; +import Synology from "./pages/Synology"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -80,6 +82,8 @@ export default function App() { } /> } /> } /> + } /> + } /> }, { to: "/semaphore", label: "Semaphore", icon: }, { to: "/gitea", label: "Gitea", icon: }, + { to: "/proxmox", label: "Proxmox", icon: }, + { to: "/synology", label: "Synology", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, { to: "/settings", label: "Settings", icon: , minRole: "admin" }, diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 2f81a75..f5c7e42 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -1,54 +1,8 @@ import { useEffect, useState, type CSSProperties } from "react"; import { Link } from "react-router-dom"; -import { - api, - type CurrentUser, - type IntegrationSummary, - type IntegrationType, - type ProxmoxGuest, - type ProxmoxGuestsResponse, - type SynologyStorageResponse, -} from "../api/client"; +import { api, type CurrentUser, type IntegrationSummary, type IntegrationType } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; import IntegrationEditForm from "../components/IntegrationEditForm"; -import { formatDateTime } from "../utils/date"; - -function proxmoxStatusBadge(status: string) { - return status === "running" ? ( - Running - ) : ( - Stopped - ); -} - -function healthBadge(status: string) { - return status === "normal" ? ( - Normal - ) : ( - {status} - ); -} - -function formatBytes(bytes: number | null): string { - if (bytes === null) return "—"; - const units = ["B", "KB", "MB", "GB", "TB", "PB"]; - let value = bytes; - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit++; - } - return `${value.toFixed(1)} ${units[unit]}`; -} - -function formatUptime(seconds: number | null): string { - if (!seconds) return "—"; - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - if (days > 0) return `${days}d ${hours}h`; - const minutes = Math.floor((seconds % 3600) / 60); - return `${hours}h ${minutes}m`; -} const TYPE_LABELS: Record = { tailscale: "Tailscale", @@ -80,15 +34,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {}); }, []); - const [proxmoxData, setProxmoxData] = useState(null); - const [proxmoxError, setProxmoxError] = useState(null); - const [loadingGuests, setLoadingGuests] = useState(false); - const [actingOnGuest, setActingOnGuest] = useState(null); - - const [synologyData, setSynologyData] = useState(null); - const [synologyError, setSynologyError] = useState(null); - const [loadingStorage, setLoadingStorage] = useState(false); - function loadIntegrations() { api.integrations .list() @@ -103,40 +48,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { const selected = integrations?.find((i) => i.id === selectedId) ?? null; - useEffect(() => { - if (selected?.type === "proxmox" && selected.enabled && !managing) { - loadProxmoxGuests(selected.id); - } else { - setProxmoxData(null); - } - if (selected?.type === "synology" && selected.enabled && !managing) { - loadSynologyStorage(selected.id); - } else { - setSynologyData(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedId, managing, integrations]); - - function loadSynologyStorage(id: number) { - setLoadingStorage(true); - setSynologyError(null); - api.integrations.synology - .storage(id) - .then((res) => setSynologyData(res)) - .catch((err) => setSynologyError(err instanceof Error ? err.message : String(err))) - .finally(() => setLoadingStorage(false)); - } - - function loadProxmoxGuests(id: number) { - setLoadingGuests(true); - setProxmoxError(null); - api.integrations.proxmox - .guests(id) - .then((res) => setProxmoxData(res)) - .catch((err) => setProxmoxError(err instanceof Error ? err.message : String(err))) - .finally(() => setLoadingGuests(false)); - } - async function toggleEnabled(i: IntegrationSummary) { try { await api.integrations.update(i.id, { enabled: !i.enabled }); @@ -157,22 +68,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } - async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") { - if (!selectedId) return; - if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`)) - return; - setActingOnGuest(g.vmid); - setProxmoxError(null); - try { - await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid); - loadProxmoxGuests(selectedId); - } catch (err) { - setProxmoxError(err instanceof Error ? err.message : String(err)); - } finally { - setActingOnGuest(null); - } - } - return ( <>
@@ -304,193 +199,14 @@ export default function Integrations({ user }: { user: CurrentUser }) {
) : selected?.type === "proxmox" ? (
-
-

- VMs & Containers - {proxmoxData && ( - - {proxmoxData.summary.running}/{proxmoxData.summary.total} running - - )} -

-
- -
-
- {proxmoxError &&
{proxmoxError}
} -
- - - - - - - - - {canEdit && } - - - - {proxmoxData?.guests.map((g) => ( - - - - - - - {canEdit && ( - - )} - - ))} - {proxmoxData?.guests.length === 0 && ( - - - - )} - -
NameNodeTypeUptimeStatusActions
- {g.name} #{g.vmid} - {g.node} - {g.type === "qemu" ? "VM" : "LXC"} - {formatUptime(g.uptime)}{proxmoxStatusBadge(g.status)} -
- {g.status === "running" ? ( - <> - - - - - ) : ( - - )} -
-
- No VMs or containers found across any node. -
+
+ Manage this integration's VMs & containers on the Proxmox page.
) : selected?.type === "synology" ? ( -
-
-
-
-

Volumes

-
- -
-
- {synologyError &&
{synologyError}
} -
- - - - - - - - - - - {synologyData?.volumes.map((v) => ( - - - - - - - ))} - {synologyData?.volumes.length === 0 && ( - - - - )} - -
VolumeTypeUsedStatus
{v.id}{v.deviceType} - {formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)} - {healthBadge(v.status)}
- No volumes found. -
-
-
-
-
-
-
-

Disks

-
-
- - - - - - - - - - - - {synologyData?.disks.map((d) => ( - - - - - - - - ))} - {synologyData?.disks.length === 0 && ( - - - - )} - -
DiskDeviceTempS.M.A.R.T.Status
{d.name}{d.device}{d.temp !== null ? `${d.temp}°C` : "—"}{healthBadge(d.smartStatus)} - {healthBadge(d.status)} - {d.exceedBadSectorThreshold && Bad sectors} - {d.belowRemainLifeThreshold && Low life} -
- No disks found. -
-
-
+
+
+ View this integration's storage on the Synology page.
) : selected?.type === "dockhand" ? ( diff --git a/web/src/pages/Proxmox.tsx b/web/src/pages/Proxmox.tsx new file mode 100644 index 0000000..f46ee61 --- /dev/null +++ b/web/src/pages/Proxmox.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type ProxmoxGuest, + type ProxmoxGuestsResponse, +} from "../api/client"; + +function proxmoxStatusBadge(status: string) { + return status === "running" ? ( + Running + ) : ( + Stopped + ); +} + +function formatUptime(seconds: number | null): string { + if (!seconds) return "—"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; +} + +export default function Proxmox({ user }: { user: CurrentUser }) { + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [actingOnGuest, setActingOnGuest] = useState(null); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const proxmoxIntegrations = res.integrations.filter((i) => i.type === "proxmox"); + setIntegrations(proxmoxIntegrations); + if (!selectedId && proxmoxIntegrations.length > 0) setSelectedId(proxmoxIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadGuests(id: number) { + setLoading(true); + setError(null); + api.integrations.proxmox + .guests(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadGuests(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") { + if (!selectedId) return; + if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`)) + return; + setActingOnGuest(g.vmid); + setError(null); + try { + await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid); + loadGuests(selectedId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setActingOnGuest(null); + } + } + + return ( + <> +

Proxmox

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Proxmox integration configured yet. Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + VMs and containers. +
+
+ ) : ( +
+
+

+ VMs & Containers + {data && ( + + {data.summary.running}/{data.summary.total} running + + )} +

+
+ +
+
+
+ + + + + + + + + {canEdit && } + + + + {data?.guests.map((g) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {data?.guests.length === 0 && ( + + + + )} + +
NameNodeTypeUptimeStatusActions
+ {g.name} #{g.vmid} + {g.node} + {g.type === "qemu" ? "VM" : "LXC"} + {formatUptime(g.uptime)}{proxmoxStatusBadge(g.status)} +
+ {g.status === "running" ? ( + <> + + + + + ) : ( + + )} +
+
+ No VMs or containers found across any node. +
+
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/Synology.tsx b/web/src/pages/Synology.tsx new file mode 100644 index 0000000..933e3ed --- /dev/null +++ b/web/src/pages/Synology.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type IntegrationSummary, type SynologyStorageResponse } from "../api/client"; + +function healthBadge(status: string) { + return status === "normal" ? ( + Normal + ) : ( + {status} + ); +} + +function formatBytes(bytes: number | null): string { + if (bytes === null) return "—"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +export default function Synology({ user: _user }: { user: CurrentUser }) { + const [integrations, setIntegrations] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const synologyIntegrations = res.integrations.filter((i) => i.type === "synology"); + setIntegrations(synologyIntegrations); + if (!selectedId && synologyIntegrations.length > 0) setSelectedId(synologyIntegrations[0].id); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadStorage(id: number) { + setLoading(true); + setError(null); + api.integrations.synology + .storage(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadStorage(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + return ( + <> +

Synology

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Synology integration configured yet. Add one under Integrations. +
+
+ ) : ( + <> + {integrations && integrations.length > 1 && ( +
+ +
+ )} + + {selected && !selected.enabled ? ( +
+
+ "{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its + storage. +
+
+ ) : ( +
+
+
+
+

Volumes

+
+ +
+
+
+ + + + + + + + + + + {data?.volumes.map((v) => ( + + + + + + + ))} + {data?.volumes.length === 0 && ( + + + + )} + +
VolumeTypeUsedStatus
{v.id}{v.deviceType} + {formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)} + {healthBadge(v.status)}
+ No volumes found. +
+
+
+
+
+
+
+

Disks

+
+
+ + + + + + + + + + + + {data?.disks.map((d) => ( + + + + + + + + ))} + {data?.disks.length === 0 && ( + + + + )} + +
DiskDeviceTempS.M.A.R.T.Status
{d.name}{d.device}{d.temp !== null ? `${d.temp}°C` : "—"}{healthBadge(d.smartStatus)} + {healthBadge(d.status)} + {d.exceedBadSectorThreshold && Bad sectors} + {d.belowRemainLifeThreshold && Low life} +
+ No disks found. +
+
+
+
+
+ )} + + )} + + ); +}