diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index dab9e74..9a29cdf 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -23,6 +23,10 @@ export const INTEGRATION_FIELDS: Partial; @@ -23,6 +24,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createGiteaAdapter(config as any); case "dockhand": return createDockhandAdapter(config as any); + case "semaphore": + return createSemaphoreAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/integrations/semaphore/adapter.ts b/server/src/integrations/semaphore/adapter.ts new file mode 100644 index 0000000..ebe7b2e --- /dev/null +++ b/server/src/integrations/semaphore/adapter.ts @@ -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; + runTemplate(projectId: number, templateId: number): Promise; +} + +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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index f02e026..0455267 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -17,6 +17,7 @@ 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 { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -469,3 +470,71 @@ for (const action of dockhandActions) { }), ); } + +// ─── 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) }); + } + }), +); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f927122..5af7955 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -237,6 +237,40 @@ export interface DockhandContainersResponse { 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 class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -434,5 +468,14 @@ export const api = { { method: "POST" }, ), }, + semaphore: { + templates: (integrationId: number) => + request(`/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" }, + ), + }, }, }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 795bc91..f2ee61c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -21,11 +21,17 @@ interface DockhandSummary { total: number; } +interface SemaphoreSummary { + total: number; + failing: number; +} + export default function Dashboard({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); const [giteaSummary, setGiteaSummary] = useState(null); const [dockhandSummary, setDockhandSummary] = useState(null); + const [semaphoreSummary, setSemaphoreSummary] = useState(null); useEffect(() => { api.integrations.list().then((res) => setIntegrations(res.integrations)); @@ -72,6 +78,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .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]); + return ( <>
@@ -90,7 +108,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary; const isLiveGitea = type === "gitea" && integration && giteaSummary; const isLiveDockhand = type === "dockhand" && integration && dockhandSummary; - const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand; + const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary; + const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore; return (
@@ -133,6 +152,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
containers running
+ ) : isLiveSemaphore ? ( +
+
{semaphoreSummary!.total}
+
templates
+ {semaphoreSummary!.failing > 0 && ( +
{semaphoreSummary!.failing} last failed
+ )} +
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index dae8451..d93b5f9 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -7,11 +7,38 @@ import { type GiteaRepo, type IntegrationSummary, type IntegrationType, + type SemaphoreTemplate, + type SemaphoreTemplatesResponse, type TailscaleDevice, type TailscaleDevicesResponse, } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; +function semaphoreStatusBadge(template: SemaphoreTemplate) { + const status = template.lastTask?.status; + if (!status) return Never run; + switch (status) { + case "success": + return Success; + case "error": + return Failed; + case "stopped": + return Stopped; + case "rejected": + return Rejected; + case "waiting": + case "starting": + case "waiting_confirmation": + case "confirmed": + return Queued; + case "running": + case "stopping": + return Running; + default: + return {status}; + } +} + function containerStateBadge(state: string) { switch (state) { case "running": @@ -85,6 +112,11 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [loadingContainers, setLoadingContainers] = useState(false); const [actingOnContainer, setActingOnContainer] = useState(null); + const [semaphoreData, setSemaphoreData] = useState(null); + const [semaphoreError, setSemaphoreError] = useState(null); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [runningTemplate, setRunningTemplate] = useState(null); + function loadIntegrations() { api.integrations .list() @@ -145,9 +177,24 @@ export default function Integrations({ user }: { user: CurrentUser }) { } else { setDockhandData(null); } + if (selected?.type === "semaphore" && selected.enabled && !managing) { + loadSemaphoreTemplates(selected.id); + } else { + setSemaphoreData(null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, managing, integrations]); + 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)); + } + async function toggleEnabled(i: IntegrationSummary) { try { await api.integrations.update(i.id, { enabled: !i.enabled }); @@ -218,6 +265,21 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } + 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); + } + } + return ( <>
@@ -547,6 +609,72 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "semaphore" ? ( +
+
+

+ Templates + {semaphoreData && semaphoreData.summary.failing > 0 && ( + {semaphoreData.summary.failing} failing + )} +

+
+ +
+
+ {semaphoreError &&
{semaphoreError}
} +
+ + + + + + + + {canEdit && } + + + + {semaphoreData?.templates.map((t) => ( + + + + + + {canEdit && ( + + )} + + ))} + {semaphoreData?.templates.length === 0 && ( + + + + )} + +
TemplateProjectLast runStatusActions
{t.name}{t.projectName} + {t.lastTask + ? new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "").toLocaleString() + : "—"} + {semaphoreStatusBadge(t)} + +
+ No templates found across any project. +
+
+
) : (