From 52dc7ed1f55fdda79e3fc4bf12290aa43acac23a Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 23:57:11 +0200 Subject: [PATCH] Move Dockhand container management to its own top-level Docker page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container management was buried inside the generic Integrations browsing view, mixed in with five unrelated integration types behind a single "pick any integration" dropdown. Give it a dedicated page, matching how Servers & Tasks and DNS already get their own top-level spot instead of living inside Integrations. New web/src/pages/Docker.tsx reuses the existing, unchanged Dockhand API routes and adapter (no server changes) — it just has its own integration picker scoped to Dockhand only, rather than sharing Integrations' any-type dropdown. Added a Docker nav item (right after Integrations) and route. Removed the Dockhand-specific state/handlers/table from Integrations entirely; adding, editing, enabling/disabling, and deleting the Dockhand credential itself still happens under Integrations -> Manage integrations like every other integration. Selecting a Dockhand row in Integrations' generic browsing dropdown now points to the Docker page instead of rendering a container table there too. Co-Authored-By: Claude Sonnet 5 --- README.md | 9 +- web/src/App.tsx | 2 + web/src/layout/AppShell.tsx | 2 + web/src/pages/Docker.tsx | 216 +++++++++++++++++++++++++++++++++ web/src/pages/Integrations.tsx | 148 ++-------------------- 5 files changed, 233 insertions(+), 144 deletions(-) create mode 100644 web/src/pages/Docker.tsx diff --git a/README.md b/README.md index c0e4e10..82ce16a 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,12 @@ All modules from the original plan are built: - **Integrations → Gitea** — repo list with each repo's last CI run status, and re-running just the failed jobs in a run; a live repo-count widget (with a failing-build warning). -- **Integrations → Dockhand** — container status across every Docker host - Dockhand manages (one credential covers all of them), with - start/stop/restart actions; a live running/total widget. +- **Docker** (its own top-level page, backed by the Dockhand + integration) — container status across every Docker host Dockhand + manages (one credential covers all of them), with start/stop/restart + actions and a live running/total widget. Add/edit/remove the + Dockhand credential itself under Integrations → Manage integrations + as with every other integration. - **Integrations → 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). diff --git a/web/src/App.tsx b/web/src/App.tsx index c2009c9..feef29e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; import ServerDetail from "./pages/ServerDetail"; import Integrations from "./pages/Integrations"; +import Docker from "./pages/Docker"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -72,6 +73,7 @@ export default function App() { } /> } /> } /> + } /> }, { to: "/secrets", label: "Secrets", icon: }, { to: "/integrations", label: "Integrations", icon: }, + { to: "/docker", label: "Docker", 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/Docker.tsx b/web/src/pages/Docker.tsx new file mode 100644 index 0000000..1c10353 --- /dev/null +++ b/web/src/pages/Docker.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { + api, + type CurrentUser, + type DockhandContainer, + type DockhandContainersResponse, + type IntegrationSummary, +} from "../api/client"; + +function containerStateBadge(state: string) { + switch (state) { + case "running": + return Running; + case "exited": + case "dead": + return {state === "dead" ? "Dead" : "Exited"}; + case "paused": + return Paused; + case "restarting": + return Restarting; + default: + return {state}; + } +} + +export default function Docker({ 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 [actingOnContainer, setActingOnContainer] = useState(null); + + useEffect(() => { + api.integrations + .list() + .then((res) => { + const dockhandIntegrations = res.integrations.filter((i) => i.type === "dockhand"); + setIntegrations(dockhandIntegrations); + if (!selectedId && dockhandIntegrations.length > 0) setSelectedId(dockhandIntegrations[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 loadContainers(id: number) { + setLoading(true); + setError(null); + api.integrations.dockhand + .containers(id) + .then((res) => setData(res)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + if (selected?.enabled) { + loadContainers(selected.id); + } else { + setData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") { + if (!selectedId) return; + if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return; + setActingOnContainer(c.id); + setError(null); + try { + await api.integrations.dockhand[action](selectedId, c.environmentId, c.id); + loadContainers(selectedId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setActingOnContainer(null); + } + } + + return ( + <> +

Docker

+ {error &&
{error}
} + + {integrations?.length === 0 ? ( +
+
+ No Dockhand 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 + containers. +
+
+ ) : ( +
+
+

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

+
+ +
+
+
+ + + + + + + + {canEdit && } + + + + {data?.containers.map((c) => ( + + + + + + {canEdit && ( + + )} + + ))} + {data?.containers.length === 0 && ( + + + + )} + +
ContainerHostImageStatusActions
{c.name}{c.environmentName}{c.image} + {containerStateBadge(c.state)} +
{c.status}
+
+
+ {c.state === "running" ? ( + <> + + + + ) : ( + + )} +
+
+ No containers found across any environment. +
+
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 48e0e6b..507970e 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -1,9 +1,8 @@ import { useEffect, useState, type CSSProperties } from "react"; +import { Link } from "react-router-dom"; import { api, type CurrentUser, - type DockhandContainer, - type DockhandContainersResponse, type GiteaRepo, type IntegrationSummary, type IntegrationType, @@ -81,22 +80,6 @@ function semaphoreStatusBadge(template: SemaphoreTemplate) { } } -function containerStateBadge(state: string) { - switch (state) { - case "running": - return Running; - case "exited": - case "dead": - return {state === "dead" ? "Dead" : "Exited"}; - case "paused": - return Paused; - case "restarting": - return Restarting; - default: - return {state}; - } -} - function runStatusBadge(repo: GiteaRepo) { const run = repo.latestRun; if (!run) return No runs; @@ -161,11 +144,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [loadingRepos, setLoadingRepos] = useState(false); const [rerunningRun, setRerunningRun] = useState(null); - const [dockhandData, setDockhandData] = useState(null); - const [dockhandError, setDockhandError] = useState(null); - const [loadingContainers, setLoadingContainers] = useState(false); - const [actingOnContainer, setActingOnContainer] = useState(null); - const [semaphoreData, setSemaphoreData] = useState(null); const [semaphoreError, setSemaphoreError] = useState(null); const [loadingTemplates, setLoadingTemplates] = useState(false); @@ -214,16 +192,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { .finally(() => setLoadingRepos(false)); } - function loadDockhandContainers(id: number) { - setLoadingContainers(true); - setDockhandError(null); - api.integrations.dockhand - .containers(id) - .then((res) => setDockhandData(res)) - .catch((err) => setDockhandError(err instanceof Error ? err.message : String(err))) - .finally(() => setLoadingContainers(false)); - } - useEffect(() => { if (selected?.type === "tailscale" && selected.enabled && !managing) { loadTailscaleDevices(selected.id); @@ -235,11 +203,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { } else { setGiteaRepos(null); } - if (selected?.type === "dockhand" && selected.enabled && !managing) { - loadDockhandContainers(selected.id); - } else { - setDockhandData(null); - } if (selected?.type === "semaphore" && selected.enabled && !managing) { loadSemaphoreTemplates(selected.id); } else { @@ -343,21 +306,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } - async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") { - if (!selectedId) return; - if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return; - setActingOnContainer(c.id); - setDockhandError(null); - try { - await api.integrations.dockhand[action](selectedId, c.environmentId, c.id); - loadDockhandContainers(selectedId); - } catch (err) { - setDockhandError(err instanceof Error ? err.message : String(err)); - } finally { - setActingOnContainer(null); - } - } - async function runTemplate(t: SemaphoreTemplate) { if (!selectedId) return; if (!confirm(`Run "${t.name}" now?`)) return; @@ -650,94 +598,6 @@ export default function Integrations({ user }: { user: CurrentUser }) { - ) : selected?.type === "dockhand" ? ( -
-
-

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

-
- -
-
- {dockhandError &&
{dockhandError}
} -
- - - - - - - - {canEdit && } - - - - {dockhandData?.containers.map((c) => ( - - - - - - {canEdit && ( - - )} - - ))} - {dockhandData?.containers.length === 0 && ( - - - - )} - -
ContainerHostImageStatusActions
{c.name}{c.environmentName}{c.image} - {containerStateBadge(c.state)} -
{c.status}
-
-
- {c.state === "running" ? ( - <> - - - - ) : ( - - )} -
-
- No containers found across any environment. -
-
-
) : selected?.type === "semaphore" ? (
@@ -995,6 +855,12 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "dockhand" ? ( +
+
+ Manage this integration's containers on the Docker page. +
+
) : (