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>
482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
export type UserRole = "admin" | "operator" | "viewer";
|
|
|
|
export interface CurrentUser {
|
|
id: number;
|
|
sub: string;
|
|
email?: string;
|
|
name?: string;
|
|
role: UserRole;
|
|
}
|
|
|
|
export interface UserRecord {
|
|
id: number;
|
|
oidcSub: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
role: UserRole;
|
|
createdAt: string;
|
|
lastLoginAt: string | null;
|
|
}
|
|
|
|
export interface AuditLogEntry {
|
|
id: number;
|
|
actorUserId: number | null;
|
|
actorLabel: string | null;
|
|
category: string;
|
|
action: string;
|
|
targetType: string | null;
|
|
targetId: string | null;
|
|
detail: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export type SecretType = "api_token" | "ssl_certificate" | "password" | "generic";
|
|
export type SecretStatus = "ok" | "expiring" | "expired";
|
|
|
|
export interface SecretRecord {
|
|
id: number;
|
|
name: string;
|
|
type: SecretType;
|
|
description: string | null;
|
|
expiryDate: string;
|
|
warnDays: number;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
status: SecretStatus;
|
|
daysLeft: number;
|
|
}
|
|
|
|
export interface SecretInput {
|
|
name: string;
|
|
type: SecretType;
|
|
description?: string;
|
|
expiryDate: string;
|
|
warnDays: number;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface IpamEntry {
|
|
id: number;
|
|
ipAddress: string;
|
|
label: string | null;
|
|
vendor: string | null;
|
|
location: string | null;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
matchingDnsRecords: string[];
|
|
}
|
|
|
|
export interface IpamInput {
|
|
ipAddress: string;
|
|
label?: string;
|
|
vendor?: string;
|
|
location?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
|
|
|
export interface DnsProviderField {
|
|
key: string;
|
|
label: string;
|
|
secret: boolean;
|
|
type?: "text" | "password" | "checkbox";
|
|
placeholder?: string;
|
|
}
|
|
|
|
export interface DnsProviderSummary {
|
|
id: number;
|
|
providerType: DnsProviderType;
|
|
name: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface DnsZone {
|
|
id: string;
|
|
name: string;
|
|
syncedAt: string | null;
|
|
recordCount: number;
|
|
}
|
|
|
|
export interface DnsRecord {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
content: string;
|
|
ttl: number | null;
|
|
priority: number | null;
|
|
proxied?: boolean | null;
|
|
}
|
|
|
|
export interface DnsRecordInput {
|
|
type: string;
|
|
name: string;
|
|
content: string;
|
|
ttl?: number;
|
|
priority?: number;
|
|
proxied?: boolean;
|
|
}
|
|
|
|
export interface ServerRecord {
|
|
id: number;
|
|
name: string;
|
|
hostname: string | null;
|
|
osType: string;
|
|
description: string | null;
|
|
apiTokenPrefix: string;
|
|
createdAt: string;
|
|
lastSeenAt: string | null;
|
|
}
|
|
|
|
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
|
|
|
|
export interface TaskRecord {
|
|
id: number;
|
|
serverId: number;
|
|
serverName: string;
|
|
scheduleType: ScheduleType;
|
|
origin: "agent" | "manual";
|
|
name: string;
|
|
command: string | null;
|
|
scheduleExpression: string | null;
|
|
source: string | null;
|
|
enabled: boolean;
|
|
nextRunAt: string | null;
|
|
isStale: boolean;
|
|
firstSeenAt: string;
|
|
lastSeenAt: string;
|
|
}
|
|
|
|
export interface ManualTaskInput {
|
|
serverId: number;
|
|
scheduleType: ScheduleType;
|
|
name: string;
|
|
command?: string;
|
|
scheduleExpression?: string;
|
|
nextRunAt?: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand";
|
|
|
|
export interface IntegrationField {
|
|
key: string;
|
|
label: string;
|
|
secret: boolean;
|
|
type?: "text" | "password" | "checkbox";
|
|
placeholder?: string;
|
|
}
|
|
|
|
export interface IntegrationSummary {
|
|
id: number;
|
|
type: IntegrationType;
|
|
name: string;
|
|
baseUrl: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface TailscaleDevice {
|
|
id: string;
|
|
nodeId: string;
|
|
hostname: string;
|
|
label: string;
|
|
addresses: string[];
|
|
primaryAddress: string;
|
|
os: string;
|
|
lastSeen: string | null;
|
|
isExitNode: boolean;
|
|
authorized: boolean;
|
|
online: boolean | null;
|
|
}
|
|
|
|
export interface TailscaleDevicesResponse {
|
|
devices: TailscaleDevice[];
|
|
summary: { total: number; online: number; unauthorized: number };
|
|
}
|
|
|
|
export interface GiteaWorkflowRun {
|
|
id: number;
|
|
displayTitle: string;
|
|
status: string;
|
|
conclusion: string | null;
|
|
headBranch: string;
|
|
event: string;
|
|
runNumber: number;
|
|
htmlUrl: string;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
export interface GiteaRepo {
|
|
owner: string;
|
|
name: string;
|
|
fullName: string;
|
|
htmlUrl: string;
|
|
private: boolean;
|
|
hasActions: boolean;
|
|
updatedAt: string;
|
|
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 class UnauthorizedError extends Error {}
|
|
export class ForbiddenError extends Error {}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(path, {
|
|
...init,
|
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
credentials: "same-origin",
|
|
});
|
|
if (res.status === 401) {
|
|
throw new UnauthorizedError("unauthorized");
|
|
}
|
|
if (res.status === 403) {
|
|
throw new ForbiddenError("forbidden");
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`Request failed (${res.status}): ${body}`);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
me: () => request<{ user: CurrentUser }>("/api/me"),
|
|
users: {
|
|
list: () => request<{ users: UserRecord[] }>("/api/users"),
|
|
updateRole: (id: number, role: UserRole) =>
|
|
request<{ user: UserRecord }>(`/api/users/${id}/role`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ role }),
|
|
}),
|
|
},
|
|
auditLog: {
|
|
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
|
|
},
|
|
secrets: {
|
|
list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"),
|
|
create: (data: SecretInput) =>
|
|
request<{ secret: SecretRecord }>("/api/secrets", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<SecretInput>) =>
|
|
request<{ secret: SecretRecord }>(`/api/secrets/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }),
|
|
},
|
|
ipam: {
|
|
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
|
|
create: (data: IpamInput) =>
|
|
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
|
|
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
|
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
|
},
|
|
dns: {
|
|
providerFields: () =>
|
|
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
|
|
providers: {
|
|
list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"),
|
|
create: (data: { providerType: DnsProviderType; name: string; config: Record<string, string | boolean> }) =>
|
|
request<{ provider: DnsProviderSummary }>("/api/dns/providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: number, data: { name?: string; enabled?: boolean; config?: Record<string, string | boolean> }) =>
|
|
request<{ provider: DnsProviderSummary }>(`/api/dns/providers/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/dns/providers/${id}`, { method: "DELETE" }),
|
|
test: (data: { providerType: DnsProviderType; config: Record<string, string | boolean> }) =>
|
|
request<{ ok: boolean; zoneCount?: number; error?: string }>("/api/dns/providers/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
},
|
|
zones: {
|
|
list: (providerId: number) => request<{ zones: DnsZone[] }>(`/api/dns/providers/${providerId}/zones`),
|
|
},
|
|
records: {
|
|
list: (providerId: number, zoneId: string) =>
|
|
request<{ records: DnsRecord[]; syncedAt: string | null }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
|
),
|
|
sync: (providerId: number, zoneId: string, zoneName?: string) =>
|
|
request<{ records: DnsRecord[]; syncedAt: string }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/sync`,
|
|
{ method: "POST", body: JSON.stringify({ zoneName }) },
|
|
),
|
|
create: (providerId: number, zoneId: string, data: DnsRecordInput) =>
|
|
request<{ record: DnsRecord }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
|
{ method: "POST", body: JSON.stringify(data) },
|
|
),
|
|
update: (providerId: number, zoneId: string, recordId: string, data: DnsRecordInput) =>
|
|
request<{ record: DnsRecord }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
|
{ method: "PUT", body: JSON.stringify(data) },
|
|
),
|
|
remove: (providerId: number, zoneId: string, recordId: string) =>
|
|
request<void>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
},
|
|
},
|
|
servers: {
|
|
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
|
create: (data: { name: string; hostname?: string; description?: string }) =>
|
|
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
rotateToken: (id: number) =>
|
|
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
|
method: "POST",
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
|
},
|
|
tasks: {
|
|
list: (
|
|
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (params.serverId) qs.set("serverId", String(params.serverId));
|
|
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
|
|
if (params.search) qs.set("search", params.search);
|
|
if (params.includeStale) qs.set("includeStale", "true");
|
|
const query = qs.toString();
|
|
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
|
},
|
|
create: (data: ManualTaskInput) =>
|
|
request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<Omit<ManualTaskInput, "serverId">>) =>
|
|
request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
|
remove: (id: number) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
|
|
},
|
|
integrations: {
|
|
fields: () => request<{ fields: Partial<Record<IntegrationType, IntegrationField[]>> }>("/api/integrations/fields"),
|
|
list: () => request<{ integrations: IntegrationSummary[] }>("/api/integrations"),
|
|
create: (data: { type: IntegrationType; name: string; config: Record<string, string | boolean> }) =>
|
|
request<{ integration: IntegrationSummary }>("/api/integrations", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: number, data: { name?: string; enabled?: boolean; config?: Record<string, string | boolean> }) =>
|
|
request<{ integration: IntegrationSummary }>(`/api/integrations/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/integrations/${id}`, { method: "DELETE" }),
|
|
test: (data: { type: IntegrationType; config: Record<string, string | boolean> }) =>
|
|
request<{ ok: boolean; latencyMs?: number; error?: string }>("/api/integrations/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
tailscale: {
|
|
devices: (integrationId: number) =>
|
|
request<TailscaleDevicesResponse>(`/api/integrations/${integrationId}/tailscale/devices`),
|
|
setAuthorized: (integrationId: number, deviceId: string, authorized: boolean) =>
|
|
request<void>(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}/authorize`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ authorized }),
|
|
}),
|
|
remove: (integrationId: number, deviceId: string) =>
|
|
request<void>(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
},
|
|
gitea: {
|
|
repos: (integrationId: number) => request<{ repos: GiteaRepo[] }>(`/api/integrations/${integrationId}/gitea/repos`),
|
|
rerunFailed: (integrationId: number, owner: string, repo: string, runId: number) =>
|
|
request<void>(
|
|
`/api/integrations/${integrationId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/runs/${runId}/rerun-failed`,
|
|
{ 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" },
|
|
),
|
|
},
|
|
},
|
|
};
|