import type { ApiEnvelope, App, Container, ContainerStats, ContainerStatsSample, ContainerView, SystemStats, SystemStatsSample, TopContainerSample, Deploy, DeployLog, DockerHealth, ProxyHealth, EventLogEntry, EventLogStats, InspectResult, Instance, LocalImage, NpmCertificate, NpmAccessList, ProxyRoute, Project, ProjectDetail, Registry, RegistryImage, Settings, StaleContainer, Stage, StageEnv, Volume, VolumeScopeInfo, BrowseResult, DnsZone, DnsRecordView, BackupInfo, Workload, WorkloadKind } from './types'; // ── Helpers ───────────────────────────────────────────────────────── class ApiError extends Error { constructor( message: string, public readonly status: number ) { super(message); this.name = 'ApiError'; } } import { getAuthToken, clearAuth } from './auth'; // ── Concurrency limiter ──────────────────────────────────────────── // Chrome allows only 6 concurrent HTTP/1.1 connections per host. // Reserve slots for the persistent SSE stream and health checks by // capping regular API requests to 4 concurrent. const MAX_CONCURRENT = 4; let inflight = 0; const queue: Array<() => void> = []; function acquireSlot(signal?: AbortSignal | null): Promise { if (inflight < MAX_CONCURRENT) { inflight++; return Promise.resolve(); } return new Promise((resolve, reject) => { // A queued waiter inherits the releasing request's slot, so it // must not increment `inflight` again — `releaseSlot` skips the // decrement when it hands the slot off, keeping the count stable. const entry = () => { resolve(); }; queue.push(entry); signal?.addEventListener('abort', () => { const idx = queue.indexOf(entry); if (idx !== -1) { queue.splice(idx, 1); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); } }, { once: true }); }); } function releaseSlot(): void { const next = queue.shift(); if (next) { next(); } else { inflight--; } } async function request(path: string, init?: RequestInit): Promise { // Write operations (user-initiated) bypass the concurrency limiter // so they are never blocked behind background polling. const method = init?.method?.toUpperCase() ?? 'GET'; if (method !== 'GET') { return requestInner(path, init); } await acquireSlot(init?.signal); try { return await requestInner(path, init); } finally { releaseSlot(); } } async function requestInner(path: string, init?: RequestInit): Promise { const token = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json', ...(init?.headers as Record) }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const res = await fetch(path, { ...init, headers }); // Redirect to login on 401 (expired/missing token). if (res.status === 401 && typeof window !== 'undefined' && !path.includes('/auth/')) { clearAuth(); window.location.href = '/login'; throw new ApiError('Authentication required', 401); } let envelope: ApiEnvelope; try { envelope = await res.json(); } catch { throw new ApiError( `Server returned non-JSON response (HTTP ${res.status})`, res.status ); } if (!envelope.success) { throw new ApiError(envelope.error ?? 'Unknown API error', res.status); } return envelope.data as T; } function get(path: string, signal?: AbortSignal): Promise { return request(path, signal ? { signal } : undefined); } function post(path: string, body?: unknown): Promise { return request(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }); } function put(path: string, body: unknown): Promise { return request(path, { method: 'PUT', body: JSON.stringify(body) }); } function del(path: string): Promise { return request(path, { method: 'DELETE' }); } function patch(path: string, body: unknown): Promise { return request(path, { method: 'PATCH', body: JSON.stringify(body) }); } // ── Projects ──────────────────────────────────────────────────────── export function listProjects(signal?: AbortSignal): Promise { return get('/api/projects', signal); } export function getProject(id: string, signal?: AbortSignal): Promise { return get(`/api/projects/${id}`, signal); } export function createProject(data: Partial): Promise { return post('/api/projects', data); } export function updateProject(id: string, data: Partial): Promise { return put(`/api/projects/${id}`, data); } export function deleteProject(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/projects/${id}`); } // ── Stages ───────────────────────────────────────────────────────── export function createStage(projectId: string, data: Partial): Promise { return post(`/api/projects/${projectId}/stages`, data); } export function updateStage(projectId: string, stageId: string, data: Partial): Promise { return put(`/api/projects/${projectId}/stages/${stageId}`, data); } export function deleteStage(projectId: string, stageId: string): Promise { return del(`/api/projects/${projectId}/stages/${stageId}`); } // ── Instances ─────────────────────────────────────────────────────── export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise { return get(`/api/projects/${projectId}/stages/${stageId}/instances`, signal); } export function deployInstance( projectId: string, stageId: string, imageTag: string ): Promise<{ status: string }> { return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, { image_tag: imageTag }); } export function removeInstance( projectId: string, stageId: string, instanceId: string ): Promise<{ deleted: string }> { return del<{ deleted: string }>( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}` ); } export function stopInstance( projectId: string, stageId: string, instanceId: string ): Promise<{ status: string }> { return post<{ status: string }>( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop` ); } export function startInstance( projectId: string, stageId: string, instanceId: string ): Promise<{ status: string }> { return post<{ status: string }>( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start` ); } export function restartInstance( projectId: string, stageId: string, instanceId: string ): Promise<{ status: string }> { return post<{ status: string }>( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart` ); } // ── Deploys ───────────────────────────────────────────────────────── export function listDeploys(limit = 50, signal?: AbortSignal): Promise { return get(`/api/deploys?limit=${limit}`, signal); } export function getDeployLogs(deployId: string): Promise { return get(`/api/deploys/${deployId}/logs`); } export function inspectImage(image: string): Promise { return post('/api/deploy/inspect', { image }); } export function quickDeploy(data: { name?: string; image: string; tag?: string; registry?: string; port?: number; force?: boolean; enable_proxy?: boolean; auto_deploy?: boolean; }): Promise<{ project: Project; status: string }> { return post<{ project: Project; status: string }>('/api/deploy/quick', data); } // ── Registries ────────────────────────────────────────────────────── export function listRegistries(): Promise { return get('/api/registries'); } export function createRegistry(data: Partial): Promise { return post('/api/registries', data); } export function updateRegistry(id: string, data: Partial): Promise { return put(`/api/registries/${id}`, data); } export function deleteRegistry(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/registries/${id}`); } export function testRegistry(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/registries/${id}/test`); } export function listRegistryTags(registryId: string, image: string): Promise { // Image contains slashes (e.g. "owner/name") — don't encode them; // the backend route uses a wildcard (/tags/*) that expects path segments. return get(`/api/registries/${registryId}/tags/${image}`); } export function listRegistryImages(registryId: string): Promise { return get(`/api/registries/${registryId}/images`); } // ── Settings ──────────────────────────────────────────────────────── export function getSettings(signal?: AbortSignal): Promise { return get('/api/settings', signal); } export function updateSettings(data: Partial): Promise { return put('/api/settings', data); } // ── Webhooks ─────────────────────────────────────────────────────── export interface WebhookUrlResponse { webhook_url: string; webhook_secret: string; has_signing_secret?: boolean; webhook_require_signature?: boolean; } export interface SigningSecretResponse { signing_secret: string; } export function getProjectWebhook(projectId: string): Promise { return get(`/api/projects/${projectId}/webhook`); } export function regenerateProjectWebhook(projectId: string): Promise { return post(`/api/projects/${projectId}/webhook/regenerate`); } export function regenerateProjectSigningSecret(projectId: string): Promise { return post(`/api/projects/${projectId}/webhook/signing-secret/regenerate`); } export async function disableProjectSigningSecret(projectId: string): Promise { await del(`/api/projects/${projectId}/webhook/signing-secret`); } export async function setProjectRequireSignature(projectId: string, require: boolean): Promise { await put(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require }); } export function getStaticSiteWebhook(siteId: string): Promise { return get(`/api/sites/${siteId}/webhook`); } export function regenerateStaticSiteWebhook(siteId: string): Promise { return post(`/api/sites/${siteId}/webhook/regenerate`); } export function regenerateStaticSiteSigningSecret(siteId: string): Promise { return post(`/api/sites/${siteId}/webhook/signing-secret/regenerate`); } export async function disableStaticSiteSigningSecret(siteId: string): Promise { await del(`/api/sites/${siteId}/webhook/signing-secret`); } export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise { await put(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require }); } export interface WebhookDelivery { id: number; target_type: 'project' | 'site'; target_id: string; target_name: string; received_at: string; source_ip: string; signature_state: 'valid' | 'invalid' | 'missing' | 'unconfigured'; status_code: number; outcome: string; detail: string; body_size: number; } export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise { return get(`/api/projects/${projectId}/webhook/deliveries`, signal); } export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise { return get(`/api/sites/${siteId}/webhook/deliveries`, signal); } // ── Outgoing-webhook signing & test ──────────────────────────────── export interface NotificationSecretResponse { secret: string; has_secret: boolean; } export interface NotificationTestResult { url: string; tier: 'settings' | 'project' | 'stage' | 'site'; status_code: number; latency_ms: number; signature_sent: boolean; delivery_id: string; response_snippet: string; error?: string; } // Settings (global) tier. export function getSettingsNotificationSecret(): Promise { return get('/api/settings/notification-secret'); } export function regenerateSettingsNotificationSecret(): Promise { return post('/api/settings/notification-secret/regenerate'); } export function disableSettingsNotificationSigning(): Promise { return post('/api/settings/notification-secret/disable'); } export function testSettingsNotification(): Promise { return post('/api/settings/notification-test'); } // Project tier. export function getProjectNotificationSecret(projectId: string): Promise { return get(`/api/projects/${projectId}/notification-secret`); } export function regenerateProjectNotificationSecret(projectId: string): Promise { return post(`/api/projects/${projectId}/notification-secret/regenerate`); } export function disableProjectNotificationSigning(projectId: string): Promise { return post(`/api/projects/${projectId}/notification-secret/disable`); } export function testProjectNotification(projectId: string): Promise { return post(`/api/projects/${projectId}/notification-test`); } // Stage tier. export function getStageNotificationSecret(projectId: string, stageId: string): Promise { return get(`/api/projects/${projectId}/stages/${stageId}/notification-secret`); } export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise { return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`); } export function disableStageNotificationSigning(projectId: string, stageId: string): Promise { return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`); } export function testStageNotification(projectId: string, stageId: string): Promise { return post(`/api/projects/${projectId}/stages/${stageId}/notification-test`); } // Static-site tier. export function getStaticSiteNotificationSecret(siteId: string): Promise { return get(`/api/sites/${siteId}/notification-secret`); } export function regenerateStaticSiteNotificationSecret(siteId: string): Promise { return post(`/api/sites/${siteId}/notification-secret/regenerate`); } export function disableStaticSiteNotificationSigning(siteId: string): Promise { return post(`/api/sites/${siteId}/notification-secret/disable`); } export function testStaticSiteNotification(siteId: string): Promise { return post(`/api/sites/${siteId}/notification-test`); } // ── Proxy Routes ─────────────────────────────────────────────────── export function listProxyRoutes(): Promise { return get('/api/proxies'); } // ── Docker Management ────────────────────────────────────────────── export function fetchContainerLogs( projectId: string, stageId: string, instanceId: string, tail = 200 ): Promise { return get(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`); } export function listProjectImages(projectId: string, signal?: AbortSignal): Promise { return get(`/api/projects/${projectId}/images`, signal); } export function getUnusedImageStats(signal?: AbortSignal): Promise<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean; }> { return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images', signal); } export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> { return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images'); } export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> { return post<{ status: string }>('/api/settings/npm/test', data); } export function listNpmCertificates(): Promise { return get('/api/settings/npm-certificates'); } export function listNpmAccessLists(): Promise { return get('/api/settings/npm-access-lists'); } // ── DNS ──────────────────────────────────────────────────────────── export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> { return post<{ success: boolean; error?: string }>('/api/settings/dns/test', { provider, token, zone_id: zoneId }); } export function listDnsZones(token?: string): Promise { return post('/api/settings/dns/zones', { token: token ?? '' }); } export function getDnsRecords(): Promise { return get('/api/dns/records'); } export function syncDnsRecords(): Promise<{ created: number; deleted: number; already_synced: number }> { return post<{ created: number; deleted: number; already_synced: number }>('/api/dns/sync'); } export function deleteDnsRecord(fqdn: string): Promise { return del(`/api/dns/records/${encodeURIComponent(fqdn)}`); } // ── Backups ──────────────────────────────────────────────────────── export function listBackups(): Promise { return get('/api/backups'); } export function triggerBackup(): Promise { return post('/api/backups'); } export function deleteBackup(id: string): Promise { return del(`/api/backups/${id}`); } export function restoreBackup(id: string): Promise<{ status: string; message: string }> { return post<{ status: string; message: string }>(`/api/backups/${id}/restore`); } export function backupDownloadUrl(id: string): string { return `/api/backups/${id}/download`; } // ── Health ────────────────────────────────────────────────────────── export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> { return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health'); } // ── Auth ───────────────────────────────────────────────────────────── export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> { return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password }); } export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> { return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me'); } // Auth settings export async function getAuthSettings(): Promise { return request('/api/auth/settings'); } export async function updateAuthSettings(settings: any): Promise { return request('/api/auth/settings', { method: 'PUT', body: JSON.stringify(settings) }); } export async function listUsers(): Promise { return request('/api/auth/users'); } export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise { return request('/api/auth/users', { method: 'POST', body: JSON.stringify(data) }); } export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise { return request(`/api/auth/users/${uid}`, { method: 'PUT', body: JSON.stringify(data) }); } export async function changeUserPassword(uid: string, password: string): Promise { return request(`/api/auth/users/${uid}/password`, { method: 'PUT', body: JSON.stringify({ password }) }); } export async function deleteUser(uid: string): Promise { return request(`/api/auth/users/${uid}`, { method: 'DELETE' }); } export async function logout(): Promise { await request('/api/auth/logout', { method: 'POST' }); } // ── Config Export ──────────────────────────────────────────────────── export function exportConfigUrl(): string { return '/api/config/export'; } // ── Stage Env Overrides ────────────────────────────────────────────── export function listStageEnv(projectId: string, stageId: string): Promise { return get(`/api/projects/${projectId}/stages/${stageId}/env`); } export function createStageEnv( projectId: string, stageId: string, data: { key: string; value: string; encrypted?: boolean } ): Promise { return post(`/api/projects/${projectId}/stages/${stageId}/env`, data); } export function updateStageEnv( projectId: string, stageId: string, envId: string, data: { key?: string; value?: string; encrypted?: boolean } ): Promise { return put(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data); } export function deleteStageEnv( projectId: string, stageId: string, envId: string ): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`); } // ── Volumes ────────────────────────────────────────────────────────── export function listVolumes(projectId: string): Promise { return get(`/api/projects/${projectId}/volumes`); } export function createVolume( projectId: string, data: { source: string; target: string; scope: string; name?: string; mode?: string } ): Promise { return post(`/api/projects/${projectId}/volumes`, data); } export function updateVolume( projectId: string, volId: string, data: { source?: string; target?: string; scope?: string; name?: string; mode?: string } ): Promise { return put(`/api/projects/${projectId}/volumes/${volId}`, data); } export function listVolumeScopes(): Promise { return get('/api/volumes/scopes'); } export function deleteVolume( projectId: string, volId: string ): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); } export function browseVolume( projectId: string, volId: string, params?: { path?: string; stage?: string; tag?: string } ): Promise { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.stage) query.set('stage', params.stage); if (params?.tag) query.set('tag', params.tag); const qs = query.toString(); return get(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`); } export function volumeDownloadUrl( projectId: string, volId: string, params?: { path?: string; stage?: string; tag?: string } ): string { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.stage) query.set('stage', params.stage); if (params?.tag) query.set('tag', params.tag); const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; if (token) query.set('token', token); const qs = query.toString(); return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; } export async function uploadToVolume( projectId: string, volId: string, files: FileList, params?: { path?: string; stage?: string; tag?: string } ): Promise<{ uploaded: string[]; count: number }> { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.stage) query.set('stage', params.stage); if (params?.tag) query.set('tag', params.tag); const qs = query.toString(); const formData = new FormData(); for (let i = 0; i < files.length; i++) { formData.append('files', files[i]); } const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, { method: 'POST', headers, body: formData, }); const envelope = await res.json(); if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed'); return envelope.data; } // ── Event Log ─────────────────────────────────────────────────────── export function fetchEventLog(params?: { severity?: string; source?: string; since?: string; until?: string; limit?: number; offset?: number; }): Promise { const query = new URLSearchParams(); if (params?.severity) query.set('severity', params.severity); if (params?.source) query.set('source', params.source); if (params?.since) query.set('since', params.since); if (params?.until) query.set('until', params.until); if (params?.limit) query.set('limit', String(params.limit)); if (params?.offset) query.set('offset', String(params.offset)); const qs = query.toString(); return get(`/api/events/log${qs ? `?${qs}` : ''}`); } export function fetchEventLogStats(): Promise { return get('/api/events/log/stats'); } export function deleteEvent(id: number): Promise<{ status: string }> { return del<{ status: string }>(`/api/events/log/${id}`); } export function clearAllEvents(): Promise<{ status: string; count: number }> { return del<{ status: string; count: number }>('/api/events/log'); } // ── Stale Containers ──────────────────────────────────────────────── export function fetchStaleContainers(signal?: AbortSignal): Promise { return get('/api/containers/stale', signal); } export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> { return post<{ deleted: string }>(`/api/containers/stale/${id}/cleanup`); } export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> { return post<{ deleted: number }>('/api/containers/stale/cleanup'); } // ── Container Stats ──────────────────────────────────────────────── export function fetchContainerStats( projectId: string, stageId: string, instanceId: string, signal?: AbortSignal ): Promise { return get( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`, signal ); } export function fetchInstanceStatsHistory( projectId: string, stageId: string, instanceId: string, window = '2h', signal?: AbortSignal ): Promise { return get( `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`, signal ); } export function fetchSystemStats(signal?: AbortSignal): Promise { return get('/api/system/stats', signal); } export function fetchSystemStatsHistory( window = '2h', signal?: AbortSignal ): Promise { return get(`/api/system/stats/history?window=${encodeURIComponent(window)}`, signal); } export function fetchTopContainers( by: 'cpu' | 'memory' = 'cpu', limit = 5, signal?: AbortSignal ): Promise { return get(`/api/system/stats/top?by=${by}&limit=${limit}`, signal); } export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise { return get(`/api/sites/${id}/stats`, signal); } export function fetchStaticSiteStatsHistory( id: string, window = '2h', signal?: AbortSignal ): Promise { return get( `/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`, signal ); } export async function fetchStaticSiteLogs(id: string, tail = 200): Promise { const result = await get(`/api/sites/${id}/logs?tail=${tail}`); return result ?? []; } // ── Static Sites ────────────────────────────────────────────────────── import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types'; export function listStaticSites(signal?: AbortSignal): Promise { return get('/api/sites', signal); } export function getStaticSite(id: string): Promise { return get(`/api/sites/${id}`); } export function createStaticSite(data: Partial): Promise { return post('/api/sites', data); } export function updateStaticSite(id: string, data: Partial): Promise { return put(`/api/sites/${id}`, data); } export function deleteStaticSite(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/sites/${id}`); } export function deployStaticSite(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/sites/${id}/deploy`); } export function stopStaticSite(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/sites/${id}/stop`); } export function startStaticSite(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/sites/${id}/start`); } export function listStaticSiteRepos(data: { provider?: string; gitea_url: string; access_token?: string; query?: string; }): Promise { return post('/api/sites/repos', data); } export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> { return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url }); } export function testStaticSiteConnection(data: { provider?: string; gitea_url: string; access_token?: string; repo_owner: string; repo_name: string; }): Promise<{ status: string }> { return post<{ status: string }>('/api/sites/test-connection', data); } export function listStaticSiteBranches(data: { provider?: string; gitea_url: string; access_token?: string; repo_owner: string; repo_name: string; }): Promise { return post('/api/sites/branches', data); } export function listStaticSiteTree(data: { provider?: string; gitea_url: string; access_token?: string; repo_owner: string; repo_name: string; branch: string; }): Promise { return post('/api/sites/tree', data); } export function listStaticSiteSecrets(siteId: string): Promise { return get(`/api/sites/${siteId}/secrets`); } export function createStaticSiteSecret( siteId: string, data: { key: string; value: string; encrypted?: boolean } ): Promise { return post(`/api/sites/${siteId}/secrets`, data); } export function updateStaticSiteSecret( siteId: string, secretId: string, data: { key?: string; value?: string; encrypted?: boolean } ): Promise { return put(`/api/sites/${siteId}/secrets/${secretId}`, data); } export function deleteStaticSiteSecret( siteId: string, secretId: string ): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`); } export function getStaticSiteStorage( siteId: string ): Promise { return get(`/api/sites/${siteId}/storage`); } // ── Stacks (docker-compose) ───────────────────────────────────────── import type { Stack, StackRevision, StackService } from './types'; export function listStacks(signal?: AbortSignal): Promise { return get('/api/stacks', signal); } export function getStack(id: string, signal?: AbortSignal): Promise { return get(`/api/stacks/${id}`, signal); } export function createStack(data: { name: string; description?: string; yaml: string; deploy?: boolean; }): Promise<{ stack: Stack; revision: StackRevision }> { return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data); } export function updateStack(id: string, data: { name?: string; description?: string }): Promise { return put(`/api/stacks/${id}`, data); } export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> { const qs = removeVolumes ? '?remove_volumes=true' : ''; return del<{ deleted: string }>(`/api/stacks/${id}${qs}`); } export function listStackRevisions(id: string, signal?: AbortSignal): Promise { return get(`/api/stacks/${id}/revisions`, signal); } export function getStackRevision(id: string, revId: string): Promise { return get(`/api/stacks/${id}/revisions/${revId}`); } export function createStackRevision(id: string, yaml: string): Promise { return post(`/api/stacks/${id}/revisions`, { yaml }); } export function rollbackStack(id: string, revId: string): Promise { return post(`/api/stacks/${id}/rollback/${revId}`); } export function stopStack(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/stacks/${id}/stop`); } export function startStack(id: string): Promise<{ status: string }> { return post<{ status: string }>(`/api/stacks/${id}/start`); } export function getStackServices(id: string, signal?: AbortSignal): Promise { return get(`/api/stacks/${id}/services`, signal); } export async function getStackLogs( id: string, service?: string, tail = 200 ): Promise { const params = new URLSearchParams(); if (service) params.set('service', service); params.set('tail', String(tail)); const token = getAuthToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers }); if (!res.ok) { throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status); } return res.text(); } // ── Workloads ─────────────────────────────────────────────────────── export function listWorkloads(kind?: WorkloadKind, signal?: AbortSignal): Promise { const path = kind ? `/api/workloads?kind=${encodeURIComponent(kind)}` : '/api/workloads'; return get(path, signal); } export function getWorkload(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}`, signal); } export function listWorkloadContainers(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/containers`, signal); } export function setWorkloadAppID(id: string, appID: string): Promise { return patch(`/api/workloads/${id}/app`, { app_id: appID }); } export function createPluginWorkload(body: import('./types').PluginWorkloadInput): Promise { return post('/api/workloads', body); } export function updatePluginWorkload(id: string, body: import('./types').PluginWorkloadInput): Promise { return put(`/api/workloads/${id}/plugin`, body); } export function deployPluginWorkload( id: string, body?: { reference?: string; note?: string } ): Promise<{ workload_id: string; reference: string; triggered_by: string }> { return post(`/api/workloads/${id}/deploy`, body ?? {}); } export function listHookKinds(signal?: AbortSignal): Promise { return get('/api/hooks/kinds', signal); } export function deletePluginWorkload(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/workloads/${id}`); } export interface WorkloadEnv { id: string; workload_id: string; key: string; value: string; encrypted: boolean; created_at: string; updated_at: string; } export function listWorkloadEnv(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/env`, signal); } export function setWorkloadEnv( id: string, body: { key: string; value: string; encrypted: boolean } ): Promise { return put(`/api/workloads/${id}/env`, body); } export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`); } export interface WorkloadWebhook { webhook_url: string; webhook_secret: string; has_signing_secret: boolean; webhook_require_signature: boolean; } export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/webhook`, signal); } export function regenerateWorkloadWebhook(id: string): Promise { return post(`/api/workloads/${id}/webhook/regenerate`); } export function fetchWorkloadContainerLogs( workloadId: string, containerRowId: string, tail: number ): Promise { return get( `/api/workloads/${workloadId}/containers/${containerRowId}/logs?tail=${tail}` ); } export interface WorkloadVolume { id: string; workload_id: string; source: string; target: string; scope: string; name: string; created_at: string; updated_at: string; } export function listWorkloadVolumes(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/volumes`, signal); } export function setWorkloadVolume( id: string, body: { source: string; target: string; scope: string; name?: string } ): Promise { return put(`/api/workloads/${id}/volumes`, body); } export function deleteWorkloadVolume(id: string, volID: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/workloads/${id}/volumes/${volID}`); } export interface HookKindSchema { kind: string; sample: unknown; } export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise { return get(`/api/hooks/kinds/${kind}/schema`, signal); } // ── Triggers (first-class redeploy signal sources) ────────────────── export interface RedeployTrigger { id: string; kind: string; name: string; config: unknown; webhook_enabled: boolean; webhook_require_signature: boolean; binding_count: number; created_at: string; updated_at: string; } export interface TriggerWebhook { url: string; secret: string; webhook_require_signature: boolean; } export interface TriggerBinding { id: string; workload_id: string; workload_name: string; trigger_id: string; binding_config: unknown; enabled: boolean; sort_order: number; created_at: string; updated_at: string; } export interface WorkloadTriggerBinding extends TriggerBinding { trigger_kind: string; trigger_name: string; } export interface TriggerInput { kind: string; name: string; config: unknown; webhook_enabled: boolean; webhook_require_signature: boolean; } export interface BindingInput { workload_id: string; binding_config?: unknown; enabled?: boolean; sort_order?: number; } export interface WorkloadBindInput { trigger_id?: string; binding_config?: unknown; enabled?: boolean; sort_order?: number; inline?: TriggerInput; } export function listTriggers(kind?: string, signal?: AbortSignal): Promise { const path = kind ? `/api/triggers?kind=${encodeURIComponent(kind)}` : '/api/triggers'; return get(path, signal); } export function getTrigger(id: string, signal?: AbortSignal): Promise { return get(`/api/triggers/${id}`, signal); } export function createTrigger(body: TriggerInput): Promise { return post('/api/triggers', body); } export function updateTrigger(id: string, body: TriggerInput): Promise { return put(`/api/triggers/${id}`, body); } export function deleteTrigger(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/triggers/${id}`); } export function getTriggerWebhook(id: string, signal?: AbortSignal): Promise { return get(`/api/triggers/${id}/webhook`, signal); } export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; url: string }> { return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`); } export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise { return get(`/api/triggers/${id}/bindings`, signal); } export function bindWorkloadToTrigger(triggerId: string, body: BindingInput): Promise { return post(`/api/triggers/${triggerId}/bindings`, body); } export function listBindingsForWorkload( workloadId: string, signal?: AbortSignal ): Promise { return get(`/api/workloads/${workloadId}/triggers`, signal); } export function bindTriggerToWorkload( workloadId: string, body: WorkloadBindInput ): Promise { return post(`/api/workloads/${workloadId}/triggers`, body); } export function updateBinding( id: string, body: { binding_config?: unknown; enabled?: boolean; sort_order?: number } ): Promise { return put(`/api/bindings/${id}`, body); } export function deleteBinding(id: string): Promise<{ deleted: string }> { return del<{ deleted: string }>(`/api/bindings/${id}`); } export interface WorkloadChainNode { id: string; name: string; source_kind: string; trigger_kind: string; created_at: string; updated_at: string; } export interface WorkloadChain { parent: WorkloadChainNode | null; self: WorkloadChainNode; children: WorkloadChainNode[]; } export function getWorkloadChain(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/chain`, signal); } export function promoteFromWorkload( targetID: string, sourceID: string, body?: { image_tag?: string; deploy?: boolean } ): Promise<{ workload_id: string; source_id: string; promoted_tag: string; deploy_queued: boolean }> { return post(`/api/workloads/${targetID}/promote-from/${sourceID}`, body ?? {}); } // ── Containers (global index) ─────────────────────────────────────── export interface ListContainersFilter { workload_id?: string; kind?: WorkloadKind; state?: string; app_id?: string; } export function listContainers(filter: ListContainersFilter = {}, signal?: AbortSignal): Promise { const params = new URLSearchParams(); for (const [k, v] of Object.entries(filter)) { // Skip unset / empty filters; explicitly check undefined and '' instead // of truthy so future filter shapes (numbers, booleans) aren't dropped. if (v !== undefined && v !== '') params.set(k, String(v)); } const qs = params.toString(); const path = qs ? `/api/containers?${qs}` : '/api/containers'; return get(path, signal); } // ── Apps ──────────────────────────────────────────────────────────── export function listApps(signal?: AbortSignal): Promise { return get('/api/apps', signal); } export function getApp(id: string, signal?: AbortSignal): Promise { return get(`/api/apps/${id}`, signal); } export function createApp(data: { name: string; description?: string }): Promise { return post('/api/apps', data); } export function updateApp(id: string, data: { name: string; description?: string }): Promise { return put(`/api/apps/${id}`, data); } export function deleteApp(id: string): Promise { return del(`/api/apps/${id}`); } // ── Event Triggers ────────────────────────────────────────────────── // Backend: internal/api/event_triggers.go. AND-composed filter shape; // empty filter fields mean "match any value." The dispatcher fans // matching event_log entries out to action_target via signed webhook. export interface EventTrigger { id: number; name: string; filter_severity: string; // CSV; "" = any filter_source: string; // CSV; "" = any filter_message_regex: string; // "" = any action_type: string; // 'webhook' today action_target: string; // URL action_secret: string; // optional HMAC secret enabled: boolean; created_at: string; updated_at: string; } export interface EventTriggerInput { name: string; filter_severity?: string; filter_source?: string; filter_message_regex?: string; action_type?: string; action_target: string; action_secret?: string; enabled?: boolean; } export function listEventTriggers(signal?: AbortSignal): Promise { return get('/api/event-triggers', signal); } export function getEventTrigger(id: number, signal?: AbortSignal): Promise { return get(`/api/event-triggers/${id}`, signal); } export function createEventTrigger(data: EventTriggerInput): Promise { return post('/api/event-triggers', data); } export function updateEventTrigger(id: number, data: EventTriggerInput): Promise { return patch(`/api/event-triggers/${id}`, data); } export function deleteEventTrigger(id: number): Promise { return del(`/api/event-triggers/${id}`); } export function testEventTrigger(id: number): Promise { return post(`/api/event-triggers/${id}/test`); } // ── Log scan rules ────────────────────────────────────────────────── // Backend: internal/api/log_scan_rules.go. Rules are regex patterns // the scanner manager evaluates against container log lines. Scope // model: workload_id="" + overrides_id=0 → global; workload_id set → // workload-only (or per-workload override of a global via // overrides_id). export interface LogScanRule { id: number; workload_id: string; // "" = global overrides_id: number; // 0 = not an override name: string; pattern: string; severity: 'info' | 'warn' | 'error'; streams: 'all' | 'stdout' | 'stderr'; cooldown_seconds: number; enabled: boolean; created_at: string; updated_at: string; } export interface LogScanRuleInput { workload_id?: string; overrides_id?: number; name: string; pattern: string; severity?: 'info' | 'warn' | 'error'; streams?: 'all' | 'stdout' | 'stderr'; cooldown_seconds?: number; enabled?: boolean; } export interface LogScanTestResult { matched: boolean; captures?: Record; error?: string; } export function listLogScanRules(opts?: { workloadID?: string; signal?: AbortSignal; }): Promise { const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : ''; return get(`/api/log-scan-rules${params}`, opts?.signal); } export function getLogScanRule(id: number, signal?: AbortSignal): Promise { return get(`/api/log-scan-rules/${id}`, signal); } export function createLogScanRule(data: LogScanRuleInput): Promise { return post('/api/log-scan-rules', data); } export function updateLogScanRule(id: number, data: LogScanRuleInput): Promise { return patch(`/api/log-scan-rules/${id}`, data); } export function deleteLogScanRule(id: number): Promise { return del(`/api/log-scan-rules/${id}`); } export function testLogScanRule(id: number, sampleLine: string): Promise { return post(`/api/log-scan-rules/${id}/test`, { sample_line: sampleLine }); } export function getEffectiveLogScanRules(workloadID: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${workloadID}/effective-rules`, signal); } export interface LogScanStats { engine: { dropped_by_bucket: number; dropped_by_cooldown: number; }; active_tails: number; last_compile_errors: string[]; } export function getLogScanStats(signal?: AbortSignal): Promise { return get('/api/log-scan-rules/stats', signal); } export { ApiError };