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>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:14:44 +02:00
1 parent 1504c19bbd
commit abb5335a52
7 files changed
+419 -1

No files matched your search

+43
View File
@@ -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<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" },
),
},
},
};
+28 -1
View File
@@ -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<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);
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 (
<>
<div className="row row-cards mb-3">
@@ -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 (
<div className="col-sm-6 col-lg-4" key={type}>
@@ -133,6 +152,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</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>
) : (
<div className="text-secondary mt-2">
{integration ? (
+128
View File
@@ -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 <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":
@@ -85,6 +112,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
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);
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 (
<>
<div className="d-flex align-items-center mb-3">
@@ -547,6 +609,72 @@ export default function Integrations({ user }: { user: CurrentUser }) {
</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>
) : (
<div className="card">
<div className="card-body text-secondary">