From 95bd831d81d9fc35b1edfbec3242ae805a367d43 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Wed, 16 Sep 2026 00:21:07 +0200 Subject: [PATCH] Move Proxmox and Synology to their own top-level pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same move as Docker/Tailscale/Semaphore/Gitea: each was buried inside the generic Integrations browsing view. Give them dedicated pages too — this was the last pair, so every integration type with a browsing UI now has its own page. New web/src/pages/{Proxmox,Synology}.tsx reuse the existing, unchanged API routes and adapters (no server changes) with their own integration picker scoped to just that type. Added matching nav items and routes. Synology stays read-only, matching its original design. Removed the corresponding state/handlers/tables from Integrations.tsx (296 lines). It's now down to just the "Manage integrations" CRUD table plus a browsing dropdown whose six branches are all redirects to the type's own page — genuinely pointless now that every type has moved out, worth simplifying separately. Co-Authored-By: Claude Sonnet 5 --- README.md | 20 +-- web/src/App.tsx | 4 + web/src/layout/AppShell.tsx | 4 + web/src/pages/Integrations.tsx | 296 +-------------------------------- web/src/pages/Proxmox.tsx | 227 +++++++++++++++++++++++++ web/src/pages/Synology.tsx | 200 ++++++++++++++++++++++ 6 files changed, 451 insertions(+), 300 deletions(-) create mode 100644 web/src/pages/Proxmox.tsx create mode 100644 web/src/pages/Synology.tsx 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. +
+
+
+
+
+ )} + + )} + + ); +}