Files
Homelab-manager/server/src/integrations/dockhand/adapter.ts
T
bobbanandClaude Sonnet 5 e35da87886 Add a Diagnostic Log, ported and generalized from Sloth Manager
Sloth Manager tracked every API call made to DNS providers for
connectivity troubleshooting. Port that here, generalized to cover
every outbound integration this app makes, not just DNS -- Tailscale,
Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up
too, since a broken API token or unreachable host on any of them is
just as worth diagnosing.

New services/diagLog.ts: a generic withDiagLogging(source, adapter)
wraps every async method of any adapter object with timing +
success/failure recording, without touching a single adapter's
request/error-handling internals -- every DNS and integration adapter
interface here is already just a flat set of async methods, so this
one wrapper works for all twelve of them. Applied it at each adapter
factory's own return statement (one line each) rather than at the
route layer, so background jobs that construct adapters directly
(the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox
lookups) get logged too, not just requests through routes/integrations.ts.

New diag_log table (ring-buffered to the last 500 rows, mirroring
Sloth Manager's approach -- this is for live troubleshooting, not a
durable record) and admin-only GET/DELETE /api/diag-log routes, source/
result filters, pagination.

New admin-only Diagnostic Log page: filterable, paginated table with a
Clear button. Also introduces the shared useSortable hook + SortableTh
component used here for the first time -- a follow-up commit applies
the same sorting (and CSV export) to the rest of the app's tables, per
the same request.

Verified end-to-end against a temp SQLite DB with real migrations: a
fake wrapped adapter's successful and failing calls both land correctly
in the log with the right source/operation/latency/error, and the
source/ok filters and clear-log operation all behave correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:41:06 +02:00

128 lines
4.2 KiB
TypeScript

/**
* 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).
*/
import { withDiagLogging } from "../../services/diagLog.js";
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 withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer });
}