import type { ApiEnvelope, App, Container, ContainerStats, ContainerStatsSample, ContainerView, SystemStats, SystemStatsSample, TopContainerSample, DockerHealth, ProxyHealth, EventLogEntry, EventLogStats, InspectResult, LocalImage, NpmCertificate, NpmAccessList, ProxyRoute, Registry, RegistryImage, Settings, StaleContainer, VolumeScopeInfo, BrowseResult, DnsZone, DnsRecordView, BackupInfo, Workload, WorkloadKind } from './types'; // ── Helpers ───────────────────────────────────────────────────────── export 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, signal?: AbortSignal): Promise { const init: RequestInit = { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }; if (signal) init.signal = signal; return request(path, init); } 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) }); } // ── Image inspect (new-app wizard pre-fill) ──────────────────────────── // `inspectImage` lets the new-app wizard pre-fill image port/healthcheck // from a LOCAL image's metadata. It posts to the admin-gated discovery // endpoint (the legacy POST /api/deploy/inspect route was dropped in the // cutover). Local-only: it does not pull. export function inspectImage(image: string, signal?: AbortSignal): Promise { return post('/api/discovery/image/inspect', { image }, signal); } // ── Discovery (/apps/new wizard helpers) ─────────────────────────── // These endpoints back the auto-discovery + connection-test flow that // the static-site creation wizard used in the legacy /sites/new page. // They are admin-gated; the token is plaintext over HTTPS and is not // persisted server-side. // GitProviderKind is the union the *frontend* sends. The empty string // means "auto-detect server-side" (DetectProviderWithProbe runs). export type GitProviderKind = '' | 'gitea' | 'github' | 'gitlab'; // DetectedGitProvider is the narrower union the backend's detect // endpoint actually returns — `staticsite.DetectProviderWithProbe` // always resolves to one of the three concrete kinds (it falls back to // `gitea` for unknown hosts). Kept distinct from GitProviderKind so a // successful detection cannot ever set the dropdown back to "". export type DetectedGitProvider = 'gitea' | 'github' | 'gitlab'; export interface RepoInfo { owner: string; name: string; full_name: string; description: string; private: boolean; html_url: string; } export interface FolderEntry { path: string; is_dir: boolean; } export interface DiscoveryGitRequest { provider?: GitProviderKind; base_url: string; access_token?: string; repo_owner?: string; repo_name?: string; branch?: string; query?: string; } export interface ImageConflict { id: string; name: string; image: string; app_id?: string; } export function detectGitProvider( baseURL: string, signal?: AbortSignal ): Promise<{ provider: DetectedGitProvider }> { return post<{ provider: DetectedGitProvider }>( '/api/discovery/git/detect-provider', { base_url: baseURL }, signal ); } export function testGitConnection( req: DiscoveryGitRequest, signal?: AbortSignal ): Promise<{ status: string }> { return post<{ status: string }>('/api/discovery/git/test-connection', req, signal); } export function listGitRepos(req: DiscoveryGitRequest, signal?: AbortSignal): Promise { return post('/api/discovery/git/repos', req, signal); } export function listGitBranches( req: DiscoveryGitRequest, signal?: AbortSignal ): Promise { return post('/api/discovery/git/branches', req, signal); } export function listGitTree(req: DiscoveryGitRequest, signal?: AbortSignal): Promise { return post('/api/discovery/git/tree', req, signal); } export function listImageConflicts(image: string, signal?: AbortSignal): Promise { return get( `/api/discovery/image/conflicts?image=${encodeURIComponent(image)}`, signal ); } // ── Workload runtime view (runtime-state, storage, stop, start) ──── // Backed by internal/api/workload_runtime.go. The shapes mirror the // Go side exactly so the UI can render without further normalization. export interface WorkloadRuntimeState { source_kind: string; has_state: boolean; container_id?: string; state?: string; status?: string; last_commit_sha?: string; last_sync_at?: string; last_error?: string; } export interface WorkloadStorageUsage { source_kind: string; enabled: boolean; used_bytes: number; limit_mb?: number; probe_error?: string; } export interface StopStartResult { touched: number; failed: number; } export function getWorkloadRuntimeState( id: string, signal?: AbortSignal ): Promise { return get(`/api/workloads/${id}/runtime-state`, signal); } export function getWorkloadStorage( id: string, signal?: AbortSignal ): Promise { return get(`/api/workloads/${id}/storage`, signal); } export function stopWorkload(id: string): Promise { return post(`/api/workloads/${id}/stop`); } export function startWorkload(id: string): Promise { return post(`/api/workloads/${id}/start`); } // ── 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); } // ── Webhook envelopes ────────────────────────────────────────────── // These shapes are reused by the workload + trigger webhook flows. export interface WebhookUrlResponse { webhook_url: string; webhook_secret: string; has_signing_secret?: boolean; webhook_require_signature?: boolean; } export interface SigningSecretResponse { signing_secret: string; } export interface WebhookDelivery { id: number; target_type: 'project' | 'site' | 'workload' | 'trigger'; 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; } // ── Outgoing-webhook signing & test (settings tier only) ─────────── // Per-project, per-stage, per-site tiers were dropped with the legacy // endpoints. Per-workload signing is exposed via the workload webhook // flow below. export interface NotificationSecretResponse { secret: string; has_secret: boolean; } export interface NotificationTestResult { url: string; tier: 'settings' | 'project' | 'stage' | 'site' | 'workload' | 'trigger'; status_code: number; latency_ms: number; signature_sent: boolean; delivery_id: string; response_snippet: string; error?: string; } 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'); } // ── Proxy Routes ─────────────────────────────────────────────────── export function listProxyRoutes(signal?: AbortSignal): Promise { return get('/api/proxies', signal); } // ── Docker Management ────────────────────────────────────────────── 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 pruneBuildCache(): Promise<{ caches_deleted: number; space_reclaimed_mb: number }> { return post<{ caches_deleted: number; space_reclaimed_mb: number }>('/api/docker/prune-build-cache'); } 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); } // ── NPM friendly-name cache ───────────────────────────────────────── // The settings/NPM page first renders "Certificate #" / "Access List // #" then swaps to the friendly name once the list resolves — a visible // flicker on every tab re-entry and after a reload. Back the two list calls // with a short-lived sessionStorage cache so names resolve instantly within // a session (survives reloads, scoped to the tab). Pass `force` to bypass it // — the picker's "browse" action wants fresh data. const NPM_CACHE_TTL_MS = 5 * 60 * 1000; interface NpmCacheEntry { ts: number; data: T; } function readNpmCache(key: string): T | null { if (typeof sessionStorage === 'undefined') return null; try { const raw = sessionStorage.getItem(key); if (!raw) return null; const entry = JSON.parse(raw) as NpmCacheEntry; if (!entry || typeof entry.ts !== 'number') return null; if (Date.now() - entry.ts > NPM_CACHE_TTL_MS) return null; return entry.data; } catch { // Corrupt/unparseable cache — treat as a miss. return null; } } function writeNpmCache(key: string, data: T): void { if (typeof sessionStorage === 'undefined') return; try { const entry: NpmCacheEntry = { ts: Date.now(), data }; sessionStorage.setItem(key, JSON.stringify(entry)); } catch { // Quota/serialization failure is non-fatal — the call still returned // fresh data; we just don't get the cache speedup next time. } } const NPM_CERTS_CACHE_KEY = 'dw_npm_certs'; const NPM_ACCESS_LISTS_CACHE_KEY = 'dw_npm_access_lists'; /** * List NPM SSL certificates. Cached in sessionStorage for ~5 minutes so the * settings page resolves friendly names without a flicker on re-entry/reload. * Pass `force` to skip the cache and refresh (e.g. opening the picker). */ export async function listNpmCertificates(force = false): Promise { if (!force) { const cached = readNpmCache(NPM_CERTS_CACHE_KEY); if (cached) return cached; } const data = await get('/api/settings/npm-certificates'); writeNpmCache(NPM_CERTS_CACHE_KEY, data); return data; } /** * List NPM access lists. Cached in sessionStorage for ~5 minutes (see * {@link listNpmCertificates}). Pass `force` to skip the cache and refresh. */ export async function listNpmAccessLists(force = false): Promise { if (!force) { const cached = readNpmCache(NPM_ACCESS_LISTS_CACHE_KEY); if (cached) return cached; } const data = await get('/api/settings/npm-access-lists'); writeNpmCache(NPM_ACCESS_LISTS_CACHE_KEY, data); return data; } // ── Volume scopes (metadata only) ─────────────────────────────────── // Per-project volume CRUD endpoints died with the legacy routes; the // workload volume endpoints cover the new path. Scope metadata stays // because the volume editor for workloads still needs it. export function listVolumeScopes(): Promise { return get('/api/volumes/scopes'); } // ── 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 }> { // X-Confirm-Restore echoes the backup id. The backend rejects any // POST whose header doesn't match the path param — this defeats // blind CSRF (a cross-origin form/image-tag POST can't set custom // headers without a preflight). Sent alongside the bearer JWT. return request<{ status: string; message: string }>(`/api/backups/${id}/restore`, { method: 'POST', headers: { 'X-Confirm-Restore': id } }); } export function backupDownloadUrl(id: string): string { return `/api/backups/${id}/download`; } // ── Volume Snapshots ─────────────────────────────────────────────── // Per-workload archives of host-bind data volumes. Capture-only for now // (create/list/delete/download); restore is a separate later phase. export interface SnapshotInfo { id: string; workload_id: string; label: string; filename: string; size_bytes: number; manifest: string; // JSON-encoded [{ index, target, scope, source }] created_at: string; } export interface SnapshotableVolume { target: string; scope: string; source: string; } export interface SkippedVolume { target: string; scope: string; reason: string; } export interface SnapshotableInfo { volumes: SnapshotableVolume[]; skipped: SkippedVolume[]; } export function listWorkloadSnapshots(workloadId: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${workloadId}/snapshots`, signal); } export function getWorkloadSnapshotable(workloadId: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${workloadId}/snapshotable`, signal); } export function createWorkloadSnapshot(workloadId: string, label?: string): Promise { return post(`/api/workloads/${workloadId}/snapshots`, label ? { label } : {}); } export function deleteSnapshot(sid: string): Promise { return del(`/api/snapshots/${sid}`); } export function snapshotDownloadUrl(sid: string): string { return `/api/snapshots/${sid}/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 & user management ───────────────────────────────── // Previously typed as `any`, which silently disabled type checking on // the entire user/auth surface — including the password-change call. // All routes are admin-gated server-side. export interface AuthSettings { auth_mode: 'local' | 'oidc'; oidc_client_id: string; oidc_client_secret: string; oidc_issuer_url: string; oidc_redirect_url: string; } export interface AuthUser { id: string; username: string; email: string; role: 'admin' | 'viewer' | string; created_at: string; } export interface CreateUserInput { username: string; password: string; email?: string; role?: 'admin' | 'viewer' | string; } export interface UpdateUserInput { email?: string; role?: 'admin' | 'viewer' | string; } export async function getAuthSettings(): Promise { return request('/api/auth/settings'); } export async function updateAuthSettings(settings: AuthSettings): 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: CreateUserInput): Promise { return request('/api/auth/users', { method: 'POST', body: JSON.stringify(data) }); } export async function updateUser(uid: string, data: UpdateUserInput): Promise { return request(`/api/auth/users/${uid}`, { method: 'PUT', body: JSON.stringify(data) }); } export async function changeUserPassword(uid: string, password: string): Promise { await request(`/api/auth/users/${uid}/password`, { method: 'PUT', body: JSON.stringify({ password }) }); } export async function deleteUser(uid: string): Promise { await 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'; } // ── Workload volume browse / download / upload ───────────────────── // The browse/download/upload helpers now target /api/workloads/{id} // instead of the deleted project-scoped path. Source/path/scope params // retain the same query keys for compatibility with the volume editor. export function browseVolume( workloadId: string, volId: string, params?: { path?: string; reference?: string } ): Promise { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.reference) query.set('reference', params.reference); const qs = query.toString(); return get( `/api/workloads/${workloadId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}` ); } export function volumeDownloadUrl( workloadId: string, volId: string, params?: { path?: string; reference?: string } ): string { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.reference) query.set('reference', params.reference); const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; if (token) query.set('token', token); const qs = query.toString(); return `/api/workloads/${workloadId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; } export async function uploadToVolume( workloadId: string, volId: string, files: FileList, params?: { path?: string; reference?: string } ): Promise<{ uploaded: string[]; count: number }> { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); if (params?.reference) query.set('reference', params.reference); 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/workloads/${workloadId}/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(signal?: AbortSignal): Promise { return get('/api/events/log/stats', signal); } export function fetchWorkloadEvents( id: string, params?: { severity?: string; limit?: number; offset?: number }, signal?: AbortSignal ): Promise { const query = new URLSearchParams(); if (params?.severity) query.set('severity', params.severity); 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/workloads/${id}/events${qs ? `?${qs}` : ''}`, signal); } 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'); } // ── System Stats ─────────────────────────────────────────────────── 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); } // ── Per-container stats (workload-scoped) ────────────────────────── // Project / stage / instance + static-site stats endpoints died with // the legacy routes. Use the workload-scoped endpoint for any per- // container CPU/memory drill-down. export function fetchWorkloadContainerStats( workloadId: string, containerRowId: string, signal?: AbortSignal ): Promise { return get( `/api/workloads/${workloadId}/containers/${containerRowId}/stats`, signal ); } export function fetchWorkloadContainerStatsHistory( workloadId: string, containerRowId: string, window = '2h', signal?: AbortSignal ): Promise { return get( `/api/workloads/${workloadId}/containers/${containerRowId}/stats/history?window=${encodeURIComponent(window)}`, signal ); } // ── 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 ?? {}); } // ── Deploy history + rollback ─────────────────────────────────────── // Structured, version-pinned ledger of every deploy dispatch (success and // failure). `rollbackable` is computed server-side: a successful deploy of a // source kind that supports reference-pinned redeploy (image today). export interface DeployHistoryEntry { id: number; workload_id: string; source_kind: string; reference: string; reason: string; triggered_by: string; note: string; outcome: 'success' | 'failure'; error: string; started_at: string; finished_at: string; rollbackable: boolean; } export function fetchWorkloadDeploys( id: string, params?: { limit?: number; offset?: number }, signal?: AbortSignal ): Promise { const query = new URLSearchParams(); 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/workloads/${id}/deploys${qs ? `?${qs}` : ''}`, signal); } export function rollbackWorkload( id: string, deployId: number ): Promise<{ workload_id: string; reference: string; rollback_of: number; triggered_by: string }> { return post(`/api/workloads/${id}/rollback`, { deploy_id: deployId }); } // ── GitOps (config-as-code) ───────────────────────────────────────── // One rich payload per workload folds the file preview, parsed status, and // field-level drift into a single GET so the panel makes one call. The shape // mirrors the Go `gitOpsStatusResponse` (snake_case is preserved end-to-end, // matching the rest of this file). Drift entries list only the declared // fields that DIFFER from live; `managed_fields` lists every key the file // declares (the read-only gate keys on these). export interface GitOpsDriftEntry { field: string; repo_value: string; live_value: string; } export type GitOpsStatusKind = 'disabled' | 'ok' | 'no_file' | 'fetch_failed' | 'invalid'; export interface GitOpsStatus { eligible: boolean; enabled: boolean; path: string; status: GitOpsStatusKind; raw: string; message: string; commit_sha: string; last_sync_at: string; drift: GitOpsDriftEntry[]; drift_count: number; managed_fields: string[]; } export function fetchWorkloadGitOps(id: string, signal?: AbortSignal): Promise { return get(`/api/workloads/${id}/gitops`, signal); } export function setWorkloadGitOps( id: string, body: { enabled: boolean; path: string } ): Promise<{ enabled: boolean; path: string }> { return put<{ enabled: boolean; path: string }>(`/api/workloads/${id}/gitops`, body); } export function syncWorkloadGitOps( id: string ): Promise<{ status: string; commit_sha: string; applied_fields: string[]; triggered_by: string }> { return post(`/api/workloads/${id}/gitops/sync`); } // ── Per-workload metrics history ──────────────────────────────────── // CPU% and memory (bytes) summed across the workload's containers, one // point per sampled timestamp. Empty when stats collection is off / Docker // was down / the workload is new. export interface WorkloadStatsPoint { ts: number; cpu_percent: number; memory_usage: number; memory_limit: number; } export function fetchWorkloadStatsHistory( id: string, window = '2h', signal?: AbortSignal ): Promise { return get( `/api/workloads/${id}/stats/history?window=${encodeURIComponent(window)}`, signal ); } 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}`); } // Workload-level webhook URL accessors were removed in the hard legacy // cutover: inbound webhooks are now first-class Triggers. To wire a // workload to inbound deploys, create or bind a Trigger via the // /triggers UI (which mints a /api/webhook/triggers/{secret} URL). // Per-workload outbound notification routes (Slack/Discord/etc). // Multi-destination fan-out — the dispatcher sends to every enabled // row whose event_types allow-list matches the event. An empty // event_types means "match every event". Secret round-trips as // write-only: the API returns secret_set, never the ciphertext. export interface WorkloadNotification { id: string; workload_id: string; name: string; url: string; secret_set: boolean; event_types: string; enabled: boolean; sort_order: number; created_at: string; updated_at: string; } export interface WorkloadNotificationInput { name: string; url: string; secret?: string; event_types?: string; enabled?: boolean; sort_order?: number; } export function listWorkloadNotifications( id: string, signal?: AbortSignal ): Promise { return get(`/api/workloads/${id}/notifications`, signal); } export function createWorkloadNotification( id: string, body: WorkloadNotificationInput ): Promise { return post(`/api/workloads/${id}/notifications`, body); } export function updateWorkloadNotification( id: string, nid: string, body: WorkloadNotificationInput ): Promise { return put(`/api/workloads/${id}/notifications/${nid}`, body); } export function deleteWorkloadNotification( id: string, nid: string ): Promise<{ success: boolean }> { return del<{ success: boolean }>(`/api/workloads/${id}/notifications/${nid}`); } 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; /** RFC3339 timestamp the scheduler last dispatched this trigger. Empty for * never-fired or non-scheduler-driven triggers. */ last_fired_at: string; 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 interface FireNowResponse { trigger: string; fired_at: string; bindings: number; deployed: number; errored: number; } /** Fire a schedule trigger immediately without waiting for the next * natural fire window. Backend rejects with 400 for non-schedule kinds. */ export function fireTriggerNow(id: string): Promise { return post(`/api/triggers/${id}/fire`); } 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; /** True when this child was materialized as a branch preview of the chain's * `self` workload (vs. an operator-created stage child). Always false for * the parent and self nodes. */ is_preview?: boolean; /** The git branch a preview child was deployed for. Empty for non-preview * nodes (omitted by the server). */ preview_branch?: 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); } // ── Metric alert rules ────────────────────────────────────────────── // Backend: internal/api/metric_alert_rules.go. Rules compare a sampled // container metric (cpu/memory) against a threshold using a comparator. // Scope model: workload_id="" → global; workload_id set → workload-only. // Unlike log-scan rules there is no override / test / effective-rules // concept — a metric-alert rule is a flat threshold check. export interface MetricAlertRule { id: number; workload_id: string; // "" = global name: string; metric: 'cpu_percent' | 'memory_percent' | 'memory_bytes'; comparator: 'gt' | 'lt'; threshold: number; severity: 'info' | 'warn' | 'error'; cooldown_seconds: number; enabled: boolean; created_at: string; updated_at: string; } export interface MetricAlertRuleInput { workload_id?: string; name: string; metric: 'cpu_percent' | 'memory_percent' | 'memory_bytes'; comparator: 'gt' | 'lt'; threshold: number; severity?: 'info' | 'warn' | 'error'; cooldown_seconds?: number; enabled?: boolean; } export function listMetricAlertRules(opts?: { workloadID?: string; signal?: AbortSignal; }): Promise { const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : ''; return get(`/api/metric-alert-rules${params}`, opts?.signal); } export function getMetricAlertRule(id: number, signal?: AbortSignal): Promise { return get(`/api/metric-alert-rules/${id}`, signal); } export function createMetricAlertRule(data: MetricAlertRuleInput): Promise { return post('/api/metric-alert-rules', data); } export function updateMetricAlertRule( id: number, data: MetricAlertRuleInput ): Promise { return patch(`/api/metric-alert-rules/${id}`, data); } export function deleteMetricAlertRule(id: number): Promise { return del(`/api/metric-alert-rules/${id}`); } // ── Shared secrets ────────────────────────────────────────────────── // A shared secret is a named env KEY whose value the reconciler injects // into matching workloads. Scope model: scope="global" applies to every // app; scope="app" + app_id narrows it to one app grouping. // // The value is WRITE-ONLY: the API never returns it, only a `has_value` // presence flag. On update, omit `value` to keep the stored secret; // provide it to set/rotate. Flipping `encrypted` requires resubmitting // `value` (the server 400s otherwise). IDs are uuid strings. export interface SharedSecret { id: string; name: string; // the env KEY has_value: boolean; // true if a value is stored (value itself is write-only, never returned) encrypted: boolean; scope: 'global' | 'app'; app_id: string; // set when scope === 'app' description: string; enabled: boolean; created_at: string; updated_at: string; } export interface SharedSecretInput { name?: string; value?: string; // omit to keep the existing value on update; provide to set/rotate encrypted?: boolean; scope?: 'global' | 'app'; app_id?: string; description?: string; enabled?: boolean; } export function listSharedSecrets(opts?: { appID?: string; signal?: AbortSignal; }): Promise { const params = opts?.appID ? `?app_id=${encodeURIComponent(opts.appID)}` : ''; return get(`/api/shared-secrets${params}`, opts?.signal); } export function getSharedSecret(id: string, signal?: AbortSignal): Promise { return get(`/api/shared-secrets/${id}`, signal); } export function createSharedSecret(data: SharedSecretInput): Promise { return post('/api/shared-secrets', data); } export function updateSharedSecret(id: string, data: SharedSecretInput): Promise { return patch(`/api/shared-secrets/${id}`, data); } export function deleteSharedSecret(id: string): Promise { return del(`/api/shared-secrets/${id}`); }