db025cce01
Co-authored-by: Copilot <copilot@github.com>
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import type { ProvisioningProgressEvent, ProvisioningRequest, TenantRecord } from '../types/provisioning';
|
|
|
|
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
|
|
|
export async function submitProvisioningJob(request: ProvisioningRequest): Promise<string> {
|
|
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<TenantRecord[]> {
|
|
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<ImageBuildStatus> {
|
|
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<void> {
|
|
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<ReleaseRecord[]> {
|
|
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<ProjectDefinition[]> {
|
|
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<BuildRecord[]> {
|
|
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<GitCommit[]> {
|
|
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<BuildHistoryRecord[]> {
|
|
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<BranchStatus[]> {
|
|
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<PromotionRecord[]> {
|
|
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<void> {
|
|
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(); };
|
|
}
|