Add Dockhand integration
Third live integration: container status across every Docker host Dockhand manages, with start/stop/restart actions — matching the "dashboard + basic actions" depth from the plan. Talks to Dockhand's own aggregating REST API (bearer tokens, dh_...) rather than the raw Docker Engine API on each host directly, so one credential covers every host Dockhand is already connected to. Adapter built against Dockhand's real published OpenAPI spec (fetched from https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json, 257 documented paths) since the instance itself isn't reachable from here — same "verify against the real shape, don't guess" approach as Gitea, just via the spec instead of a live instance: - GET /api/environments — the Docker hosts Dockhand knows about - GET /api/containers?env=<id>&all=true — containers per host ({id, name, image, state, status}) - POST /api/containers/{id}/{start,stop,restart}?env=<id> server/src/integrations/dockhand/adapter.ts fans the environments call out to one containers call per host (Promise.all) and flattens the result, tagging each container with its environment; one unreachable host returns an empty list for that host rather than failing the whole dashboard view. Follows the same config-in-UI + encrypted-credential pattern as Tailscale and Gitea. Verified: full build passes. Since Dockhand isn't reachable from here, ran a 14-check HTTP test against the live server instead of the real API — role gating, credential non-leakage, disabled-integration blocking, and (critically) re-confirmed the wrong_type crash-safety pattern holds for this third adapter type: hitting the Dockhand routes on a differently-typed integration row returns a clean 400 rather than crashing the process, and the server keeps responding to /health afterward. Real container data and the start/stop/restart actions still need verification once this app runs on the user's LAN where Dockhand is reachable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f54709c7a8
commit
9c45a806c8
7 files changed
+407
-1
No files matched your search
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Dockhand adapter — uses Dockhand's own aggregating REST API (not the raw
|
||||||
|
* Docker Engine API on each host directly). Requires config: url, token
|
||||||
|
*
|
||||||
|
* Dockhand is a multi-host Docker manager; "environments" are the individual
|
||||||
|
* Docker hosts/agents it's connected to, and containers are listed/controlled
|
||||||
|
* per environment.
|
||||||
|
*
|
||||||
|
* API reference verified against Dockhand's published OpenAPI spec
|
||||||
|
* (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DockhandConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockhandEnvironment {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
connectionType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockhandContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string; // "running" | "exited" | "paused" | "restarting" | "created" | "dead"
|
||||||
|
status: string; // human string, e.g. "Up 2 hours (healthy)"
|
||||||
|
environmentId: number;
|
||||||
|
environmentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockhandAdapter {
|
||||||
|
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||||
|
listContainers(): Promise<DockhandContainer[]>;
|
||||||
|
startContainer(environmentId: number, containerId: string): Promise<void>;
|
||||||
|
stopContainer(environmentId: number, containerId: string): Promise<void>;
|
||||||
|
restartContainer(environmentId: number, containerId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
|
||||||
|
function base() {
|
||||||
|
return config.url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${config.token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method: string, path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${base()}${path}`, { method, headers: headers() });
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// non-JSON error page
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.message || data?.error || `Dockhand API error: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listEnvironments(): Promise<DockhandEnvironment[]> {
|
||||||
|
const data = await api("GET", "/api/environments");
|
||||||
|
return (Array.isArray(data) ? data : []).map((e: any) => ({
|
||||||
|
id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
connectionType: e.connectionType,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listContainers(): Promise<DockhandContainer[]> {
|
||||||
|
const environments = await listEnvironments();
|
||||||
|
const perEnv = await Promise.all(
|
||||||
|
environments.map(async (env) => {
|
||||||
|
try {
|
||||||
|
const data = await api("GET", `/api/containers?env=${env.id}&all=true`);
|
||||||
|
return (Array.isArray(data) ? data : []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
image: c.image,
|
||||||
|
state: c.state,
|
||||||
|
status: c.status,
|
||||||
|
environmentId: env.id,
|
||||||
|
environmentName: env.name,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// one unreachable host shouldn't take down the whole dashboard view
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return perEnv.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startContainer(environmentId: number, containerId: string): Promise<void> {
|
||||||
|
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopContainer(environmentId: number, containerId: string): Promise<void> {
|
||||||
|
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/stop?env=${environmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartContainer(environmentId: number, containerId: string): Promise<void> {
|
||||||
|
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/restart?env=${environmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await api("GET", "/api/environments");
|
||||||
|
return { ok: true, latencyMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ping, listContainers, startContainer, stopContainer, restartContainer };
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
|||||||
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
|
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
|
||||||
{ key: "token", label: "API token", secret: true, type: "password" },
|
{ key: "token", label: "API token", secret: true, type: "password" },
|
||||||
],
|
],
|
||||||
|
dockhand: [
|
||||||
|
{ key: "url", label: "Dockhand URL", secret: false, placeholder: "https://dockhand.example.lan" },
|
||||||
|
{ key: "token", label: "API token", secret: true, type: "password", placeholder: "dh_..." },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { IntegrationType } from "../db/schema.js";
|
|||||||
import type { IntegrationConfig } from "./types.js";
|
import type { IntegrationConfig } from "./types.js";
|
||||||
import { createTailscaleAdapter } from "./tailscale/adapter.js";
|
import { createTailscaleAdapter } from "./tailscale/adapter.js";
|
||||||
import { createGiteaAdapter } from "./gitea/adapter.js";
|
import { createGiteaAdapter } from "./gitea/adapter.js";
|
||||||
|
import { createDockhandAdapter } from "./dockhand/adapter.js";
|
||||||
|
|
||||||
export interface PingableAdapter {
|
export interface PingableAdapter {
|
||||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||||
@@ -20,6 +21,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
|||||||
return createTailscaleAdapter(config as any);
|
return createTailscaleAdapter(config as any);
|
||||||
case "gitea":
|
case "gitea":
|
||||||
return createGiteaAdapter(config as any);
|
return createGiteaAdapter(config as any);
|
||||||
|
case "dockhand":
|
||||||
|
return createDockhandAdapter(config as any);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { createIntegrationAdapter } from "../integrations/registry.js";
|
|||||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||||
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
||||||
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||||
|
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
export const integrationsRouter = Router();
|
export const integrationsRouter = Router();
|
||||||
@@ -397,3 +398,74 @@ integrationsRouter.post(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Dockhand ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function requireDockhandAdapter(req: Request, res: Response) {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const loaded = await loadIntegrationConfig(id);
|
||||||
|
if (!loaded) {
|
||||||
|
res.status(404).json({ error: "not_found" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (loaded.integration.type !== "dockhand") {
|
||||||
|
res.status(400).json({ error: "wrong_type" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!loaded.integration.enabled) {
|
||||||
|
res.status(400).json({ error: "integration_disabled" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { integration: loaded.integration, adapter: createDockhandAdapter(loaded.config as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
integrationsRouter.get("/:id/dockhand/containers", asyncHandler(async (req, res) => {
|
||||||
|
const found = await requireDockhandAdapter(req, res);
|
||||||
|
if (!found) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = await found.adapter.listContainers();
|
||||||
|
res.json({
|
||||||
|
containers,
|
||||||
|
summary: {
|
||||||
|
total: containers.length,
|
||||||
|
running: containers.filter((c) => c.state === "running").length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dockhandActions = ["start", "stop", "restart"] as const;
|
||||||
|
|
||||||
|
for (const action of dockhandActions) {
|
||||||
|
integrationsRouter.post(
|
||||||
|
`/:id/dockhand/environments/:envId/containers/:containerId/${action}`,
|
||||||
|
requireRole("operator"),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const found = await requireDockhandAdapter(req, res);
|
||||||
|
if (!found) return;
|
||||||
|
|
||||||
|
const envId = Number(req.params.envId);
|
||||||
|
if (!Number.isInteger(envId)) {
|
||||||
|
return res.status(400).json({ error: "invalid_environment_id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await found.adapter[`${action}Container`](envId, req.params.containerId);
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "integration",
|
||||||
|
action: `${action}_container`,
|
||||||
|
targetType: "dockhand_container",
|
||||||
|
targetId: req.params.containerId,
|
||||||
|
detail: { integrationId: found.integration.id, environmentId: envId },
|
||||||
|
});
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -222,6 +222,21 @@ export interface GiteaRepo {
|
|||||||
latestRun: GiteaWorkflowRun | null;
|
latestRun: GiteaWorkflowRun | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DockhandContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
environmentId: number;
|
||||||
|
environmentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockhandContainersResponse {
|
||||||
|
containers: DockhandContainer[];
|
||||||
|
summary: { total: number; running: number };
|
||||||
|
}
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {}
|
export class UnauthorizedError extends Error {}
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
|
||||||
@@ -400,5 +415,24 @@ export const api = {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
dockhand: {
|
||||||
|
containers: (integrationId: number) =>
|
||||||
|
request<DockhandContainersResponse>(`/api/integrations/${integrationId}/dockhand/containers`),
|
||||||
|
start: (integrationId: number, envId: number, containerId: string) =>
|
||||||
|
request<void>(
|
||||||
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/start`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
stop: (integrationId: number, envId: number, containerId: string) =>
|
||||||
|
request<void>(
|
||||||
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/stop`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
restart: (integrationId: number, envId: number, containerId: string) =>
|
||||||
|
request<void>(
|
||||||
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -16,10 +16,16 @@ interface GiteaSummary {
|
|||||||
failing: number;
|
failing: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DockhandSummary {
|
||||||
|
running: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||||
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
||||||
|
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
||||||
@@ -54,6 +60,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
.catch(() => setGiteaSummary(null));
|
.catch(() => setGiteaSummary(null));
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dockhand = integrations?.find((i) => i.type === "dockhand" && i.enabled);
|
||||||
|
if (!dockhand) {
|
||||||
|
setDockhandSummary(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.integrations.dockhand
|
||||||
|
.containers(dockhand.id)
|
||||||
|
.then((res) => setDockhandSummary({ running: res.summary.running, total: res.summary.total }))
|
||||||
|
.catch(() => setDockhandSummary(null));
|
||||||
|
}, [integrations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="row row-cards mb-3">
|
<div className="row row-cards mb-3">
|
||||||
@@ -71,7 +89,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
const integration = integrations?.find((i) => i.type === type && i.enabled);
|
const integration = integrations?.find((i) => i.type === type && i.enabled);
|
||||||
const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary;
|
const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary;
|
||||||
const isLiveGitea = type === "gitea" && integration && giteaSummary;
|
const isLiveGitea = type === "gitea" && integration && giteaSummary;
|
||||||
const isLive = isLiveTailscale || isLiveGitea;
|
const isLiveDockhand = type === "dockhand" && integration && dockhandSummary;
|
||||||
|
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-sm-6 col-lg-4" key={type}>
|
<div className="col-sm-6 col-lg-4" key={type}>
|
||||||
@@ -107,6 +126,13 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
|
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : isLiveDockhand ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h3 mb-0">
|
||||||
|
{dockhandSummary!.running}/{dockhandSummary!.total}
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary">containers running</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-secondary mt-2">
|
<div className="text-secondary mt-2">
|
||||||
{integration ? (
|
{integration ? (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
|
type DockhandContainer,
|
||||||
|
type DockhandContainersResponse,
|
||||||
type GiteaRepo,
|
type GiteaRepo,
|
||||||
type IntegrationSummary,
|
type IntegrationSummary,
|
||||||
type IntegrationType,
|
type IntegrationType,
|
||||||
@@ -10,6 +12,22 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import IntegrationForm from "../components/IntegrationForm";
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
|
||||||
|
function containerStateBadge(state: string) {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return <span className="badge bg-green-lt text-green">Running</span>;
|
||||||
|
case "exited":
|
||||||
|
case "dead":
|
||||||
|
return <span className="badge bg-red-lt text-red">{state === "dead" ? "Dead" : "Exited"}</span>;
|
||||||
|
case "paused":
|
||||||
|
return <span className="badge bg-yellow-lt text-yellow">Paused</span>;
|
||||||
|
case "restarting":
|
||||||
|
return <span className="badge bg-blue-lt text-blue">Restarting</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runStatusBadge(repo: GiteaRepo) {
|
function runStatusBadge(repo: GiteaRepo) {
|
||||||
const run = repo.latestRun;
|
const run = repo.latestRun;
|
||||||
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
|
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
|
||||||
@@ -62,6 +80,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||||
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
|
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [dockhandData, setDockhandData] = useState<DockhandContainersResponse | null>(null);
|
||||||
|
const [dockhandError, setDockhandError] = useState<string | null>(null);
|
||||||
|
const [loadingContainers, setLoadingContainers] = useState(false);
|
||||||
|
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
|
||||||
|
|
||||||
function loadIntegrations() {
|
function loadIntegrations() {
|
||||||
api.integrations
|
api.integrations
|
||||||
.list()
|
.list()
|
||||||
@@ -96,6 +119,16 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
.finally(() => setLoadingRepos(false));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
||||||
loadTailscaleDevices(selected.id);
|
loadTailscaleDevices(selected.id);
|
||||||
@@ -107,6 +140,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
} else {
|
} else {
|
||||||
setGiteaRepos(null);
|
setGiteaRepos(null);
|
||||||
}
|
}
|
||||||
|
if (selected?.type === "dockhand" && selected.enabled && !managing) {
|
||||||
|
loadDockhandContainers(selected.id);
|
||||||
|
} else {
|
||||||
|
setDockhandData(null);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedId, managing, integrations]);
|
}, [selectedId, managing, integrations]);
|
||||||
|
|
||||||
@@ -165,6 +203,21 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="d-flex align-items-center mb-3">
|
<div className="d-flex align-items-center mb-3">
|
||||||
@@ -406,6 +459,94 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : selected?.type === "dockhand" ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
Containers
|
||||||
|
{dockhandData && (
|
||||||
|
<span className="text-secondary fw-normal ms-2">
|
||||||
|
{dockhandData.summary.running}/{dockhandData.summary.total} running
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => loadDockhandContainers(selected.id)}
|
||||||
|
disabled={loadingContainers}
|
||||||
|
>
|
||||||
|
{loadingContainers ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dockhandError && <div className="alert alert-danger m-3 mb-0">{dockhandError}</div>}
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Container</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{canEdit && <th className="w-1">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dockhandData?.containers.map((c) => (
|
||||||
|
<tr key={`${c.environmentId}:${c.id}`}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td className="text-secondary">{c.environmentName}</td>
|
||||||
|
<td className="text-secondary">{c.image}</td>
|
||||||
|
<td>
|
||||||
|
{containerStateBadge(c.state)}
|
||||||
|
<div className="text-secondary small">{c.status}</div>
|
||||||
|
</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
<div className="btn-list flex-nowrap">
|
||||||
|
{c.state === "running" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => containerAction(c, "restart")}
|
||||||
|
disabled={actingOnContainer === c.id}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => containerAction(c, "stop")}
|
||||||
|
disabled={actingOnContainer === c.id}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => containerAction(c, "start")}
|
||||||
|
disabled={actingOnContainer === c.id}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{dockhandData?.containers.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
||||||
|
No containers found across any environment.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-secondary">
|
<div className="card-body text-secondary">
|
||||||
|
|||||||
Reference in new issue
Block a user