Compare commits

...
10 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5 da34be08d5 Fix Tailscale online/exit-node detection against real API data
Found while verifying against the user's real tailnet (25 devices): the
Tailscale device object has no "online" or "isExitNode" field at all — this
was inherited from Sloth Manager's original adapter, which apparently never
had this checked against live data either. With the old mapping, every
device showed online:null and isExitNode was always false regardless of
reality.

Fixed by deriving both from fields that actually exist:
- online <- connectedToControl (whether the device currently has an active
  session with Tailscale's control plane)
- isExitNode <- enabledRoutes containing both 0.0.0.0/0 and ::/0 (a device
  can *advertise* exit-node routes without them being approved; checking
  enabledRoutes instead of advertisedRoutes reflects whether it's actually
  acting as one right now)

Both fields come back from the list endpoint via `?fields=all`, so this
adds no extra requests.

Verified against the real tailnet: 25 devices, 24 online / 1 offline (a
phone last seen 9 days ago — correct), and the one device actually
configured as an exit node identified correctly. This is the last of the
five previously-unverified integrations now confirmed against real
infrastructure; combined with the earlier Synology (HTTP vs HTTPS) and
Proxmox (async task timing) findings, every integration has now had at
least one real bug shaken out by testing against the user's actual homelab
rather than mocks alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 01:09:27 +02:00
bobbanandClaude Sonnet 5 4ed1ac8cad Fix Synology adapter to support plain HTTP, not just HTTPS
Found while verifying against the user's real DSM: the adapter hardcoded
node:https for every request, but the user's NAS is reached over plain
HTTP on port 5000 inside the LAN (HTTPS on 5001 also works, but nothing
about the integration should assume one or the other). Now picks http vs
https based on the configured URL's own protocol, with the right default
port per protocol (5000/5001) and TLS options only applied for https.

Verified against the user's real Synology (10.200.5.35): ping, login, and
SYNO.Storage.CGI.Storage load_info all confirmed working end-to-end through
the actual HTTP route layer — 1 volume (normal, SHR, ~11.5TB/~3.9TB used),
4 disks (all normal, 34-39°C). This is the first of the five previously-
unverified integrations confirmed against real hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:48:42 +02:00
bobbanandClaude Sonnet 5 afc920e96e Update README: all planned modules and integrations are now built
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:35:50 +02:00
bobbanandClaude Sonnet 5 a83a4b3b11 Add Synology integration — completes all six planned live integrations
Sixth and final live integration: volume and disk health across the NAS,
read-only per the delivery plan (DSM write actions are riskier and stayed
out of scope). Adapter built against the same discover-then-call pattern
used by hacf-fr/synologydsm-api (the library behind Home Assistant's
Synology integration) since DSM's API paths/versions vary by release and
the user's NAS isn't reachable from here to check directly:
- GET query.cgi?api=SYNO.API.Info&method=query&query=all once per adapter
  instance, to learn the real path/version for SYNO.API.Auth and
  SYNO.Storage.CGI.Storage rather than hardcoding them
- SYNO.API.Auth login (account/passwd/format=sid) for a session id, re-used
  across calls and refreshed on session-related error codes (105/106/119)
- SYNO.Storage.CGI.Storage's `load_info` method, which returns disks,
  volumes, and pools in one call — verified against that library's
  storage.py field mapping (size.total/used, status, smart_status, temp,
  exceed_bad_sector_thr, below_remain_life_thr)

Uses node:https directly (like Proxmox and the cPanel DNS adapter) for an
"allow self-signed certificate" option, since DSM ships one by default.
No 2FA support — login surfaces a clear error if the account requires it
rather than failing silently.

Verified: full build passes. Unreachable from here, so ran an 11-check HTTP
test against the live server: confirms all six integration types are now
registered, role gating, credential (password) non-leakage, disabled-
integration blocking, and the wrong_type crash-safety check — server stays
up throughout. Real volume/disk data still needs verification once this app
can reach the user's NAS.

This completes the integrations phase from the original plan: Tailscale,
Gitea, Dockhand, Semaphore, Proxmox, Synology are all built, each following
the same config-in-UI + encrypted-credentials pattern. Combined with the
foundation, Secrets, IPAM, DNS, and Servers & Tasks modules from earlier,
Homelab Manager now covers every feature area from the user's original
request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:35:21 +02:00
bobbanandClaude Sonnet 5 ae39f18755 Update README: Proxmox integration is now built
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:24:59 +02:00
bobbanandClaude Sonnet 5 bc72b9e152 Add Proxmox integration
Fifth live integration: VM/LXC status across every online node in the
cluster, with start/stop/restart actions — matching the "dashboard + basic
actions" depth from the plan. Adapter built against Proxmox VE's own
published API tree (pve.proxmox.com/pve-docs/api-viewer/apidoc.js — parsed
its ~4MB ExtJS tree structure directly since it's not plain JSON) plus
community-verified docs for the token auth header format, since the user's
instance isn't reachable from here.

server/src/integrations/proxmox/adapter.ts: GET /nodes for online nodes,
then GET /nodes/{node}/{qemu,lxc} per node in parallel (Promise.all) and
flattened, tagging each guest with its node and type; POST
/nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} for actions (all
confirmed token-auth-eligible via the spec's "allowtoken" flag). Uses
node:https directly (like the cPanel DNS adapter) rather than fetch, since
Proxmox commonly runs a self-signed certificate in homelab setups — added
an "Allow self-signed certificate" checkbox field for that. Auth is
`Authorization: PVEAPIToken=<tokenId>=<tokenSecret>`, a different shape
from the other three integrations' single bearer token, so the field
schema splits it into two fields (a non-secret token ID like
"root@pam!homelab-manager" and a secret token value).

Verified: full build passes. Unreachable from here, so ran a 16-check HTTP
test against the live server: role gating, credential non-leakage,
disabled-integration blocking, invalid-guest-type and non-numeric-vmid
rejection, checkbox-only config correctly still failing required-field
validation, and the wrong_type crash-safety check for this fifth adapter
type — server stays up throughout. Real node/VM data and the actual
start/stop/restart actions still need verification once this app can reach
the user's Proxmox cluster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:24:37 +02:00
bobbanandClaude Sonnet 5 257ec3ec0a Update README: Semaphore integration is now built
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:15:01 +02:00
bobbanandClaude Sonnet 5 abb5335a52 Add Semaphore integration
Fourth live integration: Ansible run history per template with a
trigger-a-run action — matching the "dashboard + basic actions" depth from
the plan. Adapter built against Semaphore's official published OpenAPI spec
(github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) plus its Go
source directly for the task-status enum, which the spec itself leaves
untyped (db/Task.go, pkg/task_logger/task_logger.go) — instance wasn't
reachable from here (semaphore.int.toolabs.se doesn't resolve outside the
user's LAN), so verified against the real spec/source rather than guessing.

server/src/integrations/semaphore/adapter.ts: GET /api/projects, then GET
/api/project/{id}/templates?sort=name&order=asc per project — each template
in that response already embeds its last_task, so listing every template's
current status across every project needs only one call per project (no
N+1 per-template lookup, unlike Gitea where the run history isn't embedded
in the repo list). POST /api/project/{id}/tasks {template_id} triggers a
run. Follows the same config-in-UI + encrypted-credential pattern as the
other three integrations.

Verified: full build passes. Same as Dockhand — unreachable, so ran a
14-check HTTP test against the live server (real target URL, bogus token):
role gating, credential non-leakage, disabled-integration blocking, and the
wrong_type crash-safety check for this fourth adapter type, confirming the
server stays up throughout. Real template/run data and the trigger-a-run
action still need verification once this app can reach the user's LAN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:14:44 +02:00
bobbanandClaude Sonnet 5 1504c19bbd Update README: Dockhand integration is now built
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:04:50 +02:00
bobbanandClaude Sonnet 5 9c45a806c8 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>
2026-09-15 00:04:34 +02:00
12 changed files with 1807 additions and 27 deletions

No files matched your search

+26 -10
View File
@@ -11,7 +11,7 @@ Authentik (OIDC), with local admin/operator/viewer roles.
## Status
Built so far:
All modules from the original plan are built:
- Monorepo scaffold, Tabler-themed app shell/navigation
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
@@ -24,19 +24,35 @@ Built so far:
via a lightweight push agent (`agent/linux/`), plus manual entries for
things an agent can't see (Docker jobs, backups)
- **Integrations → Tailscale** — device list with online/authorized status,
and authorize/deauthorize/remove actions; a live device-count widget on
the Dashboard.
and authorize/deauthorize/remove actions; a live device-count widget.
- **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) on the Dashboard.
(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.
- **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).
- **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.
Both integrations follow the same config-in-UI + encrypted-credentials
pattern as DNS providers, so the remaining ones slot into the same
"Add integration" form as they're built.
All six integrations follow the same config-in-UI + encrypted-credentials
pattern, added through **Integrations → Manage integrations**.
Not yet built (see `.claude/plans` for the full delivery plan):
- Remaining live integrations: Proxmox, Synology, Semaphore, Dockhand
**What's been verified for real** vs. **what still needs your network**:
Authentik login and Gitea were both tested against the user's actual live
services. Tailscale, Dockhand, Semaphore, Proxmox, and Synology were built
against each service's real published API spec/source (not guesswork) and
verified with scripted HTTP tests against a bogus/unreachable config, since
those instances are LAN-only and not reachable from where this was built —
their route wiring, validation, and role gating are confirmed correct, but
real data and the write actions (start/stop/restart, trigger-a-run) haven't
been exercised against the user's actual homelab yet. Worth going through
each one after deploying, per integration.
## Requirements
+126
View File
@@ -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 };
}
+20
View File
@@ -19,6 +19,26 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
{ 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_..." },
],
semaphore: [
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
{ key: "token", label: "API token", secret: true, type: "password" },
],
proxmox: [
{ key: "url", label: "Proxmox URL", secret: false, placeholder: "https://pve.example.lan:8006" },
{ key: "tokenId", label: "API token ID", secret: false, placeholder: "root@pam!homelab-manager" },
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
synology: [
{ key: "url", label: "Synology DSM URL", secret: false, placeholder: "https://nas.example.lan:5001" },
{ key: "username", label: "Username", secret: false },
{ key: "password", label: "Password", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+169
View File
@@ -0,0 +1,169 @@
/**
* Proxmox VE adapter — uses the Proxmox VE REST API (api2/json).
* Requires config: url, tokenId, tokenSecret; optional: insecure
*
* Auth: `Authorization: PVEAPIToken=<tokenId>=<tokenSecret>` — see
* https://pve.proxmox.com/wiki/Proxmox_VE_API#API_Tokens
*
* Endpoints verified against Proxmox's own published API tree
* (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET
* /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST
* /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all
* token-auth-eligible per that spec's "allowtoken" flag).
*
* Proxmox commonly runs with a self-signed certificate in homelab setups, so
* (like the cPanel DNS adapter) this uses node:https directly rather than
* fetch, to support an "insecure" opt-out of certificate verification.
*/
import * as https from "node:https";
export interface ProxmoxConfig {
url: string;
tokenId: string;
tokenSecret: string;
insecure?: boolean;
}
export type ProxmoxGuestType = "qemu" | "lxc";
export interface ProxmoxGuest {
vmid: number;
name: string;
node: string;
type: ProxmoxGuestType;
status: string; // "running" | "stopped"
cpu: number | null;
maxmem: number | null;
mem: number | null;
uptime: number | null;
}
export interface ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>;
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean, options: { method?: string; headers?: Record<string, string> } = {}): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 8006,
path: parsed.pathname + parsed.search,
method: options.method || "GET",
headers: options.headers || {},
rejectUnauthorized: !insecure,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
const insecure = config.insecure === true;
function base() {
return config.url.replace(/\/$/, "");
}
function headers() {
return {
Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}`,
Accept: "application/json",
};
}
async function api(method: string, path: string): Promise<any> {
const res = await request(`${base()}/api2/json${path}`, insecure, { method, headers: headers() });
let data: any = null;
try {
data = res.text() ? JSON.parse(res.text()) : null;
} catch {
// non-JSON error page
}
if (res.status < 200 || res.status >= 300) {
const message = data?.errors ? JSON.stringify(data.errors) : data?.message;
throw new Error(message || `Proxmox API error: HTTP ${res.status}`);
}
return data?.data;
}
async function listNodes(): Promise<string[]> {
const data = await api("GET", "/nodes");
return (Array.isArray(data) ? data : [])
.filter((n: any) => n.status === "online")
.map((n: any) => n.node);
}
async function listGuestsForNode(node: string, type: ProxmoxGuestType): Promise<ProxmoxGuest[]> {
const data = await api("GET", `/nodes/${node}/${type}`);
return (Array.isArray(data) ? data : []).map((g: any) => ({
vmid: g.vmid,
name: g.name,
node,
type,
status: g.status,
cpu: g.cpu ?? null,
maxmem: g.maxmem ?? null,
mem: g.mem ?? null,
uptime: g.uptime ?? null,
}));
}
async function listGuests(): Promise<ProxmoxGuest[]> {
const nodes = await listNodes();
const perNode = await Promise.all(
nodes.map(async (node) => {
try {
const [vms, containers] = await Promise.all([
listGuestsForNode(node, "qemu"),
listGuestsForNode(node, "lxc"),
]);
return [...vms, ...containers];
} catch {
// one unreachable/offline node shouldn't take down the whole dashboard view
return [];
}
}),
);
return perNode.flat();
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
}
const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start");
const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop");
const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot");
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("GET", "/nodes");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, listGuests, startGuest, stopGuest, restartGuest };
}
+12
View File
@@ -2,6 +2,10 @@ import type { IntegrationType } from "../db/schema.js";
import type { IntegrationConfig } from "./types.js";
import { createTailscaleAdapter } from "./tailscale/adapter.js";
import { createGiteaAdapter } from "./gitea/adapter.js";
import { createDockhandAdapter } from "./dockhand/adapter.js";
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
import { createProxmoxAdapter } from "./proxmox/adapter.js";
import { createSynologyAdapter } from "./synology/adapter.js";
export interface PingableAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
@@ -20,6 +24,14 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
return createTailscaleAdapter(config as any);
case "gitea":
return createGiteaAdapter(config as any);
case "dockhand":
return createDockhandAdapter(config as any);
case "semaphore":
return createSemaphoreAdapter(config as any);
case "proxmox":
return createProxmoxAdapter(config as any);
case "synology":
return createSynologyAdapter(config as any);
default:
throw new Error(`Integration type "${type}" is not implemented yet`);
}
@@ -0,0 +1,144 @@
/**
* Semaphore (Ansible Semaphore / Semaphore UI) adapter.
* Requires config: url, token
*
* API reference verified against the official spec
* (https://github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) and
* source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task
* status enum, since the swagger doc itself doesn't enumerate it.
*/
export interface SemaphoreConfig {
url: string;
token: string;
}
// waiting -> starting -> running -> (success | error | stopped)
// waiting_confirmation/confirmed/rejected apply only to templates requiring approval.
export type SemaphoreTaskStatus =
| "waiting"
| "starting"
| "waiting_confirmation"
| "confirmed"
| "rejected"
| "running"
| "stopping"
| "stopped"
| "success"
| "error";
export interface SemaphoreTask {
id: number;
status: SemaphoreTaskStatus;
created: string | null;
start: string | null;
end: string | null;
}
export interface SemaphoreTemplate {
id: number;
projectId: number;
projectName: string;
name: string;
playbook: string;
lastTask: SemaphoreTask | null;
}
export interface SemaphoreAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listTemplatesWithStatus(): Promise<SemaphoreTemplate[]>;
runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask>;
}
export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapter {
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, body?: unknown): Promise<any> {
const res = await fetch(`${base()}/api${path}`, {
method,
headers: headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
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((typeof data === "string" ? data : data?.error) || `Semaphore API error: HTTP ${res.status}`);
}
return data;
}
function mapTask(t: any): SemaphoreTask | null {
if (!t) return null;
return {
id: t.id,
status: t.status,
created: t.created ?? null,
start: t.start ?? null,
end: t.end ?? null,
};
}
async function listProjects(): Promise<{ id: number; name: string }[]> {
const data = await api("GET", "/projects");
return (Array.isArray(data) ? data : []).map((p: any) => ({ id: p.id, name: p.name }));
}
async function listTemplatesForProject(projectId: number, projectName: string): Promise<SemaphoreTemplate[]> {
const data = await api("GET", `/project/${projectId}/templates?sort=name&order=asc`);
return (Array.isArray(data) ? data : []).map((t: any) => ({
id: t.id,
projectId,
projectName,
name: t.name,
playbook: t.playbook,
lastTask: mapTask(t.last_task),
}));
}
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> {
const projects = await listProjects();
const perProject = await Promise.all(
projects.map(async (p) => {
try {
return await listTemplatesForProject(p.id, p.name);
} catch {
return [];
}
}),
);
return perProject.flat();
}
async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> {
const task = await api("POST", `/project/${projectId}/tasks`, { template_id: templateId });
return mapTask(task)!;
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("GET", "/projects");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, listTemplatesWithStatus, runTemplate };
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Synology DSM adapter — uses the DSM Web API (webapi/*.cgi).
* Requires config: url, username, password; optional: insecure
*
* Read-only by design (per the delivery plan — DSM write actions are riskier
* and out of scope for v1): reports volume/pool usage and disk health only.
*
* DSM's API paths and versions vary by DSM release, so — like every
* well-behaved DSM client (this follows the same discover-then-call pattern
* as hacf-fr/synologydsm-api, the library behind Home Assistant's Synology
* integration) — this first queries SYNO.API.Info to learn the real path and
* version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than
* hardcoding them. SYNO.Storage.CGI.Storage's `load_info` method returns
* disks, volumes, and storage pools in one call (verified against that
* library's storage.py wrapper).
*
* DSM ships with a self-signed certificate unless the admin configured a
* real one, so (like the cPanel/Proxmox adapters) this uses node:https
* directly to support an "insecure" opt-out of certificate verification —
* but DSM is just as commonly reached over plain HTTP (default port 5000)
* inside a trusted LAN, so the request helper picks http vs https from the
* configured URL's own protocol rather than assuming HTTPS.
*/
import * as https from "node:https";
import * as http from "node:http";
export interface SynologyConfig {
url: string;
username: string;
password: string;
insecure?: boolean;
}
export interface SynologyVolume {
id: string;
status: string; // "normal" | "degraded" | "crashed" | ...
deviceType: string;
sizeTotal: number | null;
sizeUsed: number | null;
}
export interface SynologyDisk {
id: string;
name: string;
device: string;
status: string; // "normal" | "system_partition_failed" | "crashed" | ...
smartStatus: string;
temp: number | null;
exceedBadSectorThreshold: boolean;
belowRemainLifeThreshold: boolean;
}
export interface SynologyStorageInfo {
volumes: SynologyVolume[];
disks: SynologyDisk[];
}
export interface SynologyAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
getStorageInfo(): Promise<SynologyStorageInfo>;
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const defaultPort = isHttps ? 5001 : 5000;
const req = lib.request(
{
hostname: parsed.hostname,
port: parsed.port || defaultPort,
path: parsed.pathname + parsed.search,
method: "GET",
...(isHttps ? { rejectUnauthorized: !insecure } : {}),
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
interface ApiInfo {
path: string;
maxVersion: number;
}
export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
const insecure = config.insecure === true;
let apiMap: Record<string, ApiInfo> | null = null;
let sid: string | null = null;
function base() {
return config.url.replace(/\/$/, "");
}
async function rawGet(path: string, params: Record<string, string | number>): Promise<any> {
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
const res = await request(`${base()}/webapi/${path}?${qs}`, insecure);
let data: any;
try {
data = JSON.parse(res.text());
} catch {
throw new Error(`Synology API returned non-JSON response (HTTP ${res.status})`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Synology API error: HTTP ${res.status}`);
}
return data;
}
async function discoverApis(): Promise<Record<string, ApiInfo>> {
if (apiMap) return apiMap;
const data = await rawGet("query.cgi", { api: "SYNO.API.Info", version: 1, method: "query", query: "all" });
if (!data.success) throw new Error(data.error?.code ? `Synology API.Info error ${data.error.code}` : "Synology API.Info query failed");
apiMap = data.data;
return apiMap!;
}
async function login(): Promise<string> {
const apis = await discoverApis();
const authInfo = apis["SYNO.API.Auth"];
if (!authInfo) throw new Error("SYNO.API.Auth not available on this DSM instance");
const data = await rawGet(authInfo.path, {
api: "SYNO.API.Auth",
version: authInfo.maxVersion,
method: "login",
account: config.username,
passwd: config.password,
format: "sid",
});
if (!data.success) {
const code = data.error?.code;
const messages: Record<number, string> = {
400: "Invalid username or password",
401: "Account disabled",
402: "Permission denied",
403: "2-factor authentication is required — not supported by this integration",
404: "2-factor authentication code was rejected",
};
throw new Error((code && messages[code]) || `Synology login failed (error ${code ?? "unknown"})`);
}
sid = data.data.sid;
return sid!;
}
async function callApi(apiName: string, method: string, extraParams: Record<string, string | number> = {}): Promise<any> {
const apis = await discoverApis();
const info = apis[apiName];
if (!info) throw new Error(`${apiName} is not available on this DSM instance`);
if (!sid) await login();
const doCall = async () =>
rawGet(info.path, { api: apiName, version: info.maxVersion, method, _sid: sid!, ...extraParams });
let data = await doCall();
// Session-related error codes (105 permission/session, 106 session timeout,
// 119 invalid session) -> re-login once and retry.
if (!data.success && [105, 106, 119].includes(data.error?.code)) {
sid = null;
await login();
data = await doCall();
}
if (!data.success) {
throw new Error(`${apiName} error ${data.error?.code ?? "unknown"}`);
}
return data.data;
}
async function getStorageInfo(): Promise<SynologyStorageInfo> {
const data = await callApi("SYNO.Storage.CGI.Storage", "load_info");
const volumes: SynologyVolume[] = (data.volumes ?? []).map((v: any) => ({
id: v.id,
status: v.status,
deviceType: v.device_type,
sizeTotal: v.size?.total !== undefined ? Number(v.size.total) : null,
sizeUsed: v.size?.used !== undefined ? Number(v.size.used) : null,
}));
const disks: SynologyDisk[] = (data.disks ?? []).map((d: any) => ({
id: d.id,
name: d.name,
device: d.device,
status: d.status,
smartStatus: d.smart_status,
temp: d.temp ?? null,
exceedBadSectorThreshold: !!d.exceed_bad_sector_thr,
belowRemainLifeThreshold: !!d.below_remain_life_thr,
}));
return { volumes, disks };
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await login();
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, getStorageInfo };
}
+30 -15
View File
@@ -3,6 +3,18 @@
* Requires config: tailnet, apiKey
*
* API docs: https://tailscale.com/api
*
* Note: the real device object has no "online" or "isExitNode" field at all
* (verified against a live tailnet — this diverges from what Sloth Manager's
* original adapter assumed). "Online" is derived from `connectedToControl`
* (whether the device currently has an active session with Tailscale's
* control plane); "is an exit node" is derived from `enabledRoutes`
* containing both default routes (0.0.0.0/0 and ::/0) — a device can
* *advertise* those routes without them being approved, so `enabledRoutes`
* (not `advertisedRoutes`) is the correct "is actually acting as an exit
* node right now" signal. `?fields=all` on the list endpoint returns both
* in the same call as the basic fields, so no per-device follow-up is
* needed.
*/
const BASE = "https://api.tailscale.com";
@@ -23,7 +35,7 @@ export interface TailscaleDevice {
lastSeen: string | null;
isExitNode: boolean;
authorized: boolean;
online: boolean | null;
online: boolean;
}
export interface TailscaleAdapter {
@@ -61,20 +73,23 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
}
async function listDevices(): Promise<TailscaleDevice[]> {
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
return (data.devices || []).map((d: any) => ({
id: d.id,
nodeId: d.nodeId || d.id,
hostname: d.hostname || "",
label: d.displayName || d.hostname || "",
addresses: d.addresses || [],
primaryAddress: d.addresses?.[0] || "",
os: d.os || "",
lastSeen: d.lastSeen || null,
isExitNode: !!d.isExitNode,
authorized: !!d.authorized,
online: d.online ?? null,
}));
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
return (data.devices || []).map((d: any) => {
const enabledRoutes: string[] = d.enabledRoutes || [];
return {
id: d.id,
nodeId: d.nodeId || d.id,
hostname: d.hostname || "",
label: d.displayName || d.hostname || "",
addresses: d.addresses || [],
primaryAddress: d.addresses?.[0] || "",
os: d.os || "",
lastSeen: d.lastSeen || null,
isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"),
authorized: !!d.authorized,
online: !!d.connectedToControl,
};
});
}
async function deleteDevice(deviceId: string): Promise<void> {
+259
View File
@@ -16,6 +16,10 @@ import { createIntegrationAdapter } from "../integrations/registry.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const integrationsRouter = Router();
@@ -397,3 +401,258 @@ 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) });
}
}),
);
}
// ─── Semaphore ───────────────────────────────────────────────────────────────
async function requireSemaphoreAdapter(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 !== "semaphore") {
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: createSemaphoreAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/semaphore/templates", asyncHandler(async (req, res) => {
const found = await requireSemaphoreAdapter(req, res);
if (!found) return;
try {
const templates = await found.adapter.listTemplatesWithStatus();
res.json({
templates,
summary: {
total: templates.length,
failing: templates.filter((t) => t.lastTask?.status === "error").length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
integrationsRouter.post(
"/:id/semaphore/projects/:projectId/templates/:templateId/run",
requireRole("operator"),
asyncHandler(async (req, res) => {
const found = await requireSemaphoreAdapter(req, res);
if (!found) return;
const projectId = Number(req.params.projectId);
const templateId = Number(req.params.templateId);
if (!Number.isInteger(projectId) || !Number.isInteger(templateId)) {
return res.status(400).json({ error: "invalid_id" });
}
try {
const task = await found.adapter.runTemplate(projectId, templateId);
await recordAudit({
actor: req.currentUser!,
category: "integration",
action: "run_template",
targetType: "semaphore_template",
targetId: templateId,
detail: { integrationId: found.integration.id, projectId, taskId: task.id },
});
res.status(201).json({ task });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}),
);
// ─── Proxmox ─────────────────────────────────────────────────────────────────
async function requireProxmoxAdapter(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 !== "proxmox") {
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: createProxmoxAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
try {
const guests = await found.adapter.listGuests();
res.json({
guests,
summary: {
total: guests.length,
running: guests.filter((g) => g.status === "running").length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
const proxmoxActions = ["start", "stop", "restart"] as const;
for (const action of proxmoxActions) {
integrationsRouter.post(
`/:id/proxmox/nodes/:node/:type/:vmid/${action}`,
requireRole("operator"),
asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
const vmid = Number(req.params.vmid);
if (!Number.isInteger(vmid)) {
return res.status(400).json({ error: "invalid_vmid" });
}
const type = req.params.type;
if (type !== "qemu" && type !== "lxc") {
return res.status(400).json({ error: "invalid_type" });
}
try {
await found.adapter[`${action}Guest`](req.params.node, type, vmid);
await recordAudit({
actor: req.currentUser!,
category: "integration",
action: `${action}_guest`,
targetType: "proxmox_guest",
targetId: vmid,
detail: { integrationId: found.integration.id, node: req.params.node, guestType: type },
});
res.status(204).end();
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}),
);
}
// ─── Synology ────────────────────────────────────────────────────────────────
// Read-only per the delivery plan — no start/stop/etc actions.
async function requireSynologyAdapter(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 !== "synology") {
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: createSynologyAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => {
const found = await requireSynologyAdapter(req, res);
if (!found) return;
try {
const info = await found.adapter.getStorageInfo();
res.json({
...info,
summary: {
volumeCount: info.volumes.length,
volumesNotNormal: info.volumes.filter((v) => v.status !== "normal").length,
diskCount: info.disks.length,
disksNotNormal: info.disks.filter((d) => d.status !== "normal").length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
+134 -1
View File
@@ -190,7 +190,7 @@ export interface TailscaleDevice {
lastSeen: string | null;
isExitNode: boolean;
authorized: boolean;
online: boolean | null;
online: boolean;
}
export interface TailscaleDevicesResponse {
@@ -222,6 +222,99 @@ export interface GiteaRepo {
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 type SemaphoreTaskStatus =
| "waiting"
| "starting"
| "waiting_confirmation"
| "confirmed"
| "rejected"
| "running"
| "stopping"
| "stopped"
| "success"
| "error";
export interface SemaphoreTask {
id: number;
status: SemaphoreTaskStatus;
created: string | null;
start: string | null;
end: string | null;
}
export interface SemaphoreTemplate {
id: number;
projectId: number;
projectName: string;
name: string;
playbook: string;
lastTask: SemaphoreTask | null;
}
export interface SemaphoreTemplatesResponse {
templates: SemaphoreTemplate[];
summary: { total: number; failing: number };
}
export type ProxmoxGuestType = "qemu" | "lxc";
export interface ProxmoxGuest {
vmid: number;
name: string;
node: string;
type: ProxmoxGuestType;
status: string;
cpu: number | null;
maxmem: number | null;
mem: number | null;
uptime: number | null;
}
export interface ProxmoxGuestsResponse {
guests: ProxmoxGuest[];
summary: { total: number; running: number };
}
export interface SynologyVolume {
id: string;
status: string;
deviceType: string;
sizeTotal: number | null;
sizeUsed: number | null;
}
export interface SynologyDisk {
id: string;
name: string;
device: string;
status: string;
smartStatus: string;
temp: number | null;
exceedBadSectorThreshold: boolean;
belowRemainLifeThreshold: boolean;
}
export interface SynologyStorageResponse {
volumes: SynologyVolume[];
disks: SynologyDisk[];
summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number };
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
@@ -400,5 +493,45 @@ export const api = {
{ 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" },
),
},
semaphore: {
templates: (integrationId: number) =>
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
run: (integrationId: number, projectId: number, templateId: number) =>
request<{ task: SemaphoreTask }>(
`/api/integrations/${integrationId}/semaphore/projects/${projectId}/templates/${templateId}/run`,
{ method: "POST" },
),
},
proxmox: {
guests: (integrationId: number) => request<ProxmoxGuestsResponse>(`/api/integrations/${integrationId}/proxmox/guests`),
start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }),
stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }),
restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }),
},
synology: {
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
},
},
};
+118 -1
View File
@@ -16,10 +16,35 @@ interface GiteaSummary {
failing: number;
}
interface DockhandSummary {
running: number;
total: number;
}
interface SemaphoreSummary {
total: number;
failing: number;
}
interface ProxmoxSummary {
running: number;
total: number;
}
interface SynologySummary {
volumeCount: number;
volumesNotNormal: number;
disksNotNormal: number;
}
export default function Dashboard({ user }: { user: CurrentUser }) {
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
useEffect(() => {
api.integrations.list().then((res) => setIntegrations(res.integrations));
@@ -54,6 +79,60 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.catch(() => setGiteaSummary(null));
}, [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]);
useEffect(() => {
const semaphore = integrations?.find((i) => i.type === "semaphore" && i.enabled);
if (!semaphore) {
setSemaphoreSummary(null);
return;
}
api.integrations.semaphore
.templates(semaphore.id)
.then((res) => setSemaphoreSummary(res.summary))
.catch(() => setSemaphoreSummary(null));
}, [integrations]);
useEffect(() => {
const proxmox = integrations?.find((i) => i.type === "proxmox" && i.enabled);
if (!proxmox) {
setProxmoxSummary(null);
return;
}
api.integrations.proxmox
.guests(proxmox.id)
.then((res) => setProxmoxSummary({ running: res.summary.running, total: res.summary.total }))
.catch(() => setProxmoxSummary(null));
}, [integrations]);
useEffect(() => {
const synology = integrations?.find((i) => i.type === "synology" && i.enabled);
if (!synology) {
setSynologySummary(null);
return;
}
api.integrations.synology
.storage(synology.id)
.then((res) =>
setSynologySummary({
volumeCount: res.summary.volumeCount,
volumesNotNormal: res.summary.volumesNotNormal,
disksNotNormal: res.summary.disksNotNormal,
}),
)
.catch(() => setSynologySummary(null));
}, [integrations]);
return (
<>
<div className="row row-cards mb-3">
@@ -71,7 +150,11 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const integration = integrations?.find((i) => i.type === type && i.enabled);
const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary;
const isLiveGitea = type === "gitea" && integration && giteaSummary;
const isLive = isLiveTailscale || isLiveGitea;
const isLiveDockhand = type === "dockhand" && integration && dockhandSummary;
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
const isLiveSynology = type === "synology" && integration && synologySummary;
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
return (
<div className="col-sm-6 col-lg-4" key={type}>
@@ -107,6 +190,40 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</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>
) : isLiveSemaphore ? (
<div className="mt-2">
<div className="h3 mb-0">{semaphoreSummary!.total}</div>
<div className="text-secondary">templates</div>
{semaphoreSummary!.failing > 0 && (
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
)}
</div>
) : isLiveProxmox ? (
<div className="mt-2">
<div className="h3 mb-0">
{proxmoxSummary!.running}/{proxmoxSummary!.total}
</div>
<div className="text-secondary">VMs/containers running</div>
</div>
) : isLiveSynology ? (
<div className="mt-2">
<div className="h3 mb-0">{synologySummary!.volumeCount}</div>
<div className="text-secondary">volumes</div>
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && (
<div className="text-red small mt-1">
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`}
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "}
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`}
</div>
)}
</div>
) : (
<div className="text-secondary mt-2">
{integration ? (
+547
View File
@@ -2,14 +2,99 @@ import { useEffect, useState } from "react";
import {
api,
type CurrentUser,
type DockhandContainer,
type DockhandContainersResponse,
type GiteaRepo,
type IntegrationSummary,
type IntegrationType,
type ProxmoxGuest,
type ProxmoxGuestsResponse,
type SemaphoreTemplate,
type SemaphoreTemplatesResponse,
type SynologyStorageResponse,
type TailscaleDevice,
type TailscaleDevicesResponse,
} from "../api/client";
import IntegrationForm from "../components/IntegrationForm";
function proxmoxStatusBadge(status: string) {
return status === "running" ? (
<span className="badge bg-green-lt text-green">Running</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Stopped</span>
);
}
function healthBadge(status: string) {
return status === "normal" ? (
<span className="badge bg-green-lt text-green">Normal</span>
) : (
<span className="badge bg-red-lt text-red">{status}</span>
);
}
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`;
}
function semaphoreStatusBadge(template: SemaphoreTemplate) {
const status = template.lastTask?.status;
if (!status) return <span className="badge bg-secondary-lt text-secondary">Never run</span>;
switch (status) {
case "success":
return <span className="badge bg-green-lt text-green">Success</span>;
case "error":
return <span className="badge bg-red-lt text-red">Failed</span>;
case "stopped":
return <span className="badge bg-secondary-lt text-secondary">Stopped</span>;
case "rejected":
return <span className="badge bg-secondary-lt text-secondary">Rejected</span>;
case "waiting":
case "starting":
case "waiting_confirmation":
case "confirmed":
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
case "running":
case "stopping":
return <span className="badge bg-blue-lt text-blue">Running</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{status}</span>;
}
}
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) {
const run = repo.latestRun;
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
@@ -62,6 +147,25 @@ export default function Integrations({ user }: { user: CurrentUser }) {
const [loadingRepos, setLoadingRepos] = useState(false);
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);
const [semaphoreData, setSemaphoreData] = useState<SemaphoreTemplatesResponse | null>(null);
const [semaphoreError, setSemaphoreError] = useState<string | null>(null);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [runningTemplate, setRunningTemplate] = useState<number | null>(null);
const [proxmoxData, setProxmoxData] = useState<ProxmoxGuestsResponse | null>(null);
const [proxmoxError, setProxmoxError] = useState<string | null>(null);
const [loadingGuests, setLoadingGuests] = useState(false);
const [actingOnGuest, setActingOnGuest] = useState<number | null>(null);
const [synologyData, setSynologyData] = useState<SynologyStorageResponse | null>(null);
const [synologyError, setSynologyError] = useState<string | null>(null);
const [loadingStorage, setLoadingStorage] = useState(false);
function loadIntegrations() {
api.integrations
.list()
@@ -96,6 +200,16 @@ 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);
@@ -107,9 +221,59 @@ 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 {
setSemaphoreData(null);
}
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 loadSemaphoreTemplates(id: number) {
setLoadingTemplates(true);
setSemaphoreError(null);
api.integrations.semaphore
.templates(id)
.then((res) => setSemaphoreData(res))
.catch((err) => setSemaphoreError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingTemplates(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 });
@@ -165,6 +329,51 @@ 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;
setRunningTemplate(t.id);
setSemaphoreError(null);
try {
await api.integrations.semaphore.run(selectedId, t.projectId, t.id);
loadSemaphoreTemplates(selectedId);
} catch (err) {
setSemaphoreError(err instanceof Error ? err.message : String(err));
} finally {
setRunningTemplate(null);
}
}
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart") {
if (!selectedId) return;
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}"?`)) 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 (
<>
<div className="d-flex align-items-center mb-3">
@@ -406,6 +615,344 @@ export default function Integrations({ user }: { user: CurrentUser }) {
</table>
</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>
) : selected?.type === "semaphore" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Templates
{semaphoreData && semaphoreData.summary.failing > 0 && (
<span className="text-red fw-normal ms-2">{semaphoreData.summary.failing} failing</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadSemaphoreTemplates(selected.id)}
disabled={loadingTemplates}
>
{loadingTemplates ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{semaphoreError && <div className="alert alert-danger m-3 mb-0">{semaphoreError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Template</th>
<th>Project</th>
<th>Last run</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{semaphoreData?.templates.map((t) => (
<tr key={`${t.projectId}:${t.id}`}>
<td>{t.name}</td>
<td className="text-secondary">{t.projectName}</td>
<td className="text-secondary">
{t.lastTask
? new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "").toLocaleString()
: "—"}
</td>
<td>{semaphoreStatusBadge(t)}</td>
{canEdit && (
<td>
<button
className="btn btn-sm btn-primary"
onClick={() => runTemplate(t)}
disabled={runningTemplate === t.id}
>
{runningTemplate === t.id ? "Starting…" : "Run"}
</button>
</td>
)}
</tr>
))}
{semaphoreData?.templates.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No templates found across any project.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "proxmox" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
VMs &amp; Containers
{proxmoxData && (
<span className="text-secondary fw-normal ms-2">
{proxmoxData.summary.running}/{proxmoxData.summary.total} running
</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadProxmoxGuests(selected.id)}
disabled={loadingGuests}
>
{loadingGuests ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{proxmoxError && <div className="alert alert-danger m-3 mb-0">{proxmoxError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Node</th>
<th>Type</th>
<th>Uptime</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{proxmoxData?.guests.map((g) => (
<tr key={`${g.node}:${g.type}:${g.vmid}`}>
<td>
{g.name} <span className="text-secondary">#{g.vmid}</span>
</td>
<td className="text-secondary">{g.node}</td>
<td>
<span className="badge bg-blue-lt">{g.type === "qemu" ? "VM" : "LXC"}</span>
</td>
<td className="text-secondary">{formatUptime(g.uptime)}</td>
<td>{proxmoxStatusBadge(g.status)}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{g.status === "running" ? (
<>
<button
className="btn btn-sm"
onClick={() => guestAction(g, "restart")}
disabled={actingOnGuest === g.vmid}
>
Restart
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => guestAction(g, "stop")}
disabled={actingOnGuest === g.vmid}
>
Stop
</button>
</>
) : (
<button
className="btn btn-sm btn-primary"
onClick={() => guestAction(g, "start")}
disabled={actingOnGuest === g.vmid}
>
Start
</button>
)}
</div>
</td>
)}
</tr>
))}
{proxmoxData?.guests.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No VMs or containers found across any node.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "synology" ? (
<div className="row g-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Volumes</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadSynologyStorage(selected.id)}
disabled={loadingStorage}
>
{loadingStorage ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{synologyError && <div className="alert alert-danger m-3 mb-0">{synologyError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Volume</th>
<th>Type</th>
<th>Used</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{synologyData?.volumes.map((v) => (
<tr key={v.id}>
<td>{v.id}</td>
<td className="text-secondary">{v.deviceType}</td>
<td className="text-secondary">
{formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)}
</td>
<td>{healthBadge(v.status)}</td>
</tr>
))}
{synologyData?.volumes.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No volumes found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Disks</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Disk</th>
<th>Device</th>
<th>Temp</th>
<th>S.M.A.R.T.</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{synologyData?.disks.map((d) => (
<tr key={d.id}>
<td>{d.name}</td>
<td className="text-secondary">{d.device}</td>
<td className="text-secondary">{d.temp !== null ? `${d.temp}°C` : "—"}</td>
<td>{healthBadge(d.smartStatus)}</td>
<td>
{healthBadge(d.status)}
{d.exceedBadSectorThreshold && <span className="badge bg-yellow-lt text-yellow ms-1">Bad sectors</span>}
{d.belowRemainLifeThreshold && <span className="badge bg-yellow-lt text-yellow ms-1">Low life</span>}
</td>
</tr>
))}
{synologyData?.disks.length === 0 && (
<tr>
<td colSpan={5} className="text-secondary text-center">
No disks found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
) : (
<div className="card">
<div className="card-body text-secondary">