import type { ProvisioningProgressEvent, ProvisioningRequest, TenantRecord } from '../types/provisioning'; const BASE_URL = import.meta.env.VITE_API_URL ?? ''; export async function submitProvisioningJob(request: ProvisioningRequest): Promise { const res = await fetch(`${BASE_URL}/api/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!res.ok) throw new Error(`Failed to queue job: ${res.statusText}`); const data = await res.json(); return data.id as string; } export async function getTenants(): Promise { const res = await fetch(`${BASE_URL}/api/tenants`); if (!res.ok) throw new Error(`Failed to load tenants: ${res.statusText}`); return res.json(); } export function subscribeToTenantLogs( subdomain: string, onLine: (line: string) => void, onError: (err: Event) => void ): EventSource { const source = new EventSource(`${BASE_URL}/api/tenants/${subdomain}/logs`); source.onmessage = (e) => { if (e.data) onLine(e.data); }; source.onerror = onError; return source; } export function subscribeToJobStream( jobId: string, onEvent: (event: ProvisioningProgressEvent) => void, onError: (err: Event) => void ): EventSource { const source = new EventSource(`${BASE_URL}/api/provision/${jobId}/stream`); source.onmessage = (e) => { try { onEvent(JSON.parse(e.data)); } catch { /* ignore */ } }; source.onerror = onError; return source; } export interface ImageBuildStatus { imageName: string | null; builtAt: string | null; lastMessage: string; isBuilding: boolean; } export async function getImageStatus(): Promise { const res = await fetch(`${BASE_URL}/api/image/status`); if (!res.ok) throw new Error(`Failed to get image status: ${res.statusText}`); return res.json(); } /** Triggers a build and streams log lines. Calls onLine for each log chunk, onDone when finished. */ export function triggerImageBuild( onLine: (line: string) => void, onDone: (success: boolean) => void, onError: (err: Event) => void ): EventSource { const source = new EventSource(`${BASE_URL}/api/image/build-stream`); source.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.done) { onDone(true); source.close(); } else if (msg.line) onLine(msg.line); } catch { /* ignore */ } }; source.onerror = (e) => { onDone(false); onError(e); }; return source; } /** POST to kick off the build — returns immediately; use subscribeToJobStream for progress */ export async function startImageBuild(): Promise { const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' }); if (!res.ok) throw new Error(`Build trigger failed: ${res.statusText}`); } // ── Release API ────────────────────────────────────────────────────────────── export interface TenantReleaseResult { subdomain: string; containerName: string; success: boolean; error?: string; } export interface ReleaseRecord { id: string; environment: string; imageName: string; status: 'Running' | 'Succeeded' | 'PartialFailure' | 'Failed'; startedAt: string; finishedAt?: string; tenants: TenantReleaseResult[]; } export async function getReleaseHistory(): Promise { const res = await fetch(`${BASE_URL}/api/release/history`); if (!res.ok) throw new Error(`Failed to get release history: ${res.statusText}`); return res.json(); } /** Triggers a release to the given environment and streams log lines as SSE. */ export function triggerRelease( env: string, onLine: (line: string) => void, onDone: (record: ReleaseRecord) => void, onError: (err: Event) => void ): EventSource { const source = new EventSource(`${BASE_URL}/api/release/${env}`); source.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.done && msg.release) { onDone(msg.release as ReleaseRecord); source.close(); } else if (typeof msg.line === 'string') onLine(msg.line); } catch { /* ignore */ } }; source.onerror = (e) => { onError(e); }; return source; } // ── Project Build API ──────────────────────────────────────────────────────── export interface ProjectDefinition { name: string; kind: 'DotnetProject' | 'NpmProject'; relativePath: string; } export interface BuildRecord { id: string; kind: 'DockerImage' | 'DotnetProject' | 'NpmProject'; target: string; status: 'Running' | 'Succeeded' | 'Failed'; startedAt: string; finishedAt?: string; durationMs?: number; log: string[]; } export async function getProjects(): Promise { const res = await fetch(`${BASE_URL}/api/builds/projects`); if (!res.ok) throw new Error(`Failed to get projects: ${res.statusText}`); return res.json(); } export async function getBuildHistory(): Promise { const res = await fetch(`${BASE_URL}/api/builds/history`); if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`); return res.json(); } /** Triggers a project build and streams log lines. */ export function triggerProjectBuild( projectName: string, onLine: (line: string) => void, onDone: (record: BuildRecord) => void, onError: (err: Event) => void ): EventSource { const source = new EventSource(`${BASE_URL}/api/builds/${encodeURIComponent(projectName)}`); source.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.done && msg.build) { onDone(msg.build as BuildRecord); source.close(); } else if (typeof msg.line === 'string') onLine(msg.line); } catch { /* ignore */ } }; source.onerror = (e) => { onError(e); }; return source; } // ── Git History API ────────────────────────────────────────────────────────── export interface GitCommit { hash: string; shortHash: string; author: string; date: string; subject: string; files: string[]; } export async function getGitLog(path?: string, limit = 20): Promise { const params = new URLSearchParams({ limit: String(limit) }); if (path) params.set('path', path); const res = await fetch(`${BASE_URL}/api/git/log?${params}`); if (!res.ok) throw new Error(`Failed to get git log: ${res.statusText}`); return res.json(); } // ── Promotion / Branch Ladder API ──────────────────────────────────────────── export interface BuildHistoryRecord { id: string; status: 'Running' | 'Succeeded' | 'Failed'; startedAt: string; durationMs: number | null; commitSha: string | null; imageDigest: string | null; } export async function getImageBuildHistory(limit = 30): Promise { const res = await fetch(`${BASE_URL}/api/image/history?limit=${limit}`); if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`); return res.json(); } export interface CommitInfo { sha: string; shortSha: string; message: string; author: string; date: string; } export interface BranchStatus { branch: string; exists: boolean; shortHash: string | null; lastCommitSummary: string | null; aheadOfNext: number; behindNext: number; unreleasedCommits: CommitInfo[]; } export interface PromotionRecord { id: string; fromBranch: string; toBranch: string; requestedBy: string; note: string | null; status: 'Pending' | 'Running' | 'Succeeded' | 'Failed'; createdAt: string; completedAt: string | null; commitCount: number; commitLines: string[]; log: string[]; } export async function getLadderStatus(repo = 'Clarity'): Promise { const res = await fetch(`${BASE_URL}/api/promotions/ladder?repo=${encodeURIComponent(repo)}`); if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`); return res.json(); } export async function getPromotionHistory(): Promise { const res = await fetch(`${BASE_URL}/api/promotions/history`); if (!res.ok) throw new Error(`Failed to get promotion history: ${res.statusText}`); return res.json(); } /** Triggers a promotion and streams SSE lines. Calls onDone with the final record. */ export function triggerPromotion( from: string, to: string, requestedBy: string, note: string | undefined, onLine: (line: string) => void, onDone: (record: PromotionRecord) => void, onError: (err: string) => void, repo = 'Clarity', ): () => void { let cancelled = false; const controller = new AbortController(); (async () => { try { const res = await fetch(`${BASE_URL}/api/promotions/promote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to, requestedBy, note, repo }), signal: controller.signal, }); if (!res.ok || !res.body) { onError(res.statusText); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (!cancelled) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const chunk of parts) { const dataLine = chunk.replace(/^data:\s*/m, '').trim(); if (!dataLine) continue; try { const msg = JSON.parse(dataLine); if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord); else if (typeof msg.line === 'string') onLine(msg.line); } catch { /* skip */ } } } } catch (e) { if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error'); } })(); return () => { cancelled = true; controller.abort(); }; } export async function resetBranch(branch: string, toSha: string, repo: string): Promise { const res = await fetch(`${BASE_URL}/api/promotions/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ branch, toSha, repo }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error((body as { error?: string }).error ?? res.statusText); } } /** Cherry-picks the specified commits (by full SHA) from `from` to `to` and streams SSE progress. */ export function triggerCherryPick( shas: string[], from: string, to: string, requestedBy: string, note: string | undefined, onLine: (line: string) => void, onDone: (record: PromotionRecord) => void, onError: (err: string) => void, repo = 'Clarity', ): () => void { let cancelled = false; const controller = new AbortController(); (async () => { try { const res = await fetch(`${BASE_URL}/api/promotions/cherry-pick`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shas, from, to, requestedBy, note, repo }), signal: controller.signal, }); if (!res.ok || !res.body) { onError(res.statusText); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (!cancelled) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const chunk of parts) { const dataLine = chunk.replace(/^data:\s*/m, '').trim(); if (!dataLine) continue; try { const msg = JSON.parse(dataLine); if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord); else if (typeof msg.line === 'string') onLine(msg.line); } catch { /* skip */ } } } } catch (e) { if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error'); } })(); return () => { cancelled = true; controller.abort(); }; }