import type { ApiEnvelope, ContainerStats, ContainerStatsSample, 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 } 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' }); } // ── 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; } 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 getStaticSiteWebhook(siteId: string): Promise { return get(`/api/sites/${siteId}/webhook`); } export function regenerateStaticSiteWebhook(siteId: string): Promise { return post(`/api/sites/${siteId}/webhook/regenerate`); } // ── 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(); } export { ApiError };