Files
Homelab-manager/web/src/api/client.ts
T
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

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" },
),
},
},
};