OPC # 0001: Extract OPC into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:42 -04:00
commit 42383bdc03
170 changed files with 21365 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
export type ServiceStatus = 'running' | 'stopped' | 'unhealthy' | 'unknown';
export interface InfraService {
name: string;
container: string;
status: ServiceStatus;
ports: string[];
uptime?: string;
}
export interface InfraStatusResponse {
services: InfraService[];
checkedAt: string;
}
export async function getInfraStatus(): Promise<InfraStatusResponse> {
const res = await fetch(`${BASE_URL}/api/infra/status`);
if (!res.ok) throw new Error('Failed to fetch infra status');
return res.json();
}
export async function infraServiceAction(
service: string,
action: 'start' | 'stop' | 'restart'
): Promise<void> {
const res = await fetch(`${BASE_URL}/api/infra/${service}/${action}`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed to ${action} ${service}`);
}
export function streamComposeUp(onLine: (line: string) => void, onDone: () => void): EventSource {
const src = new EventSource(`${BASE_URL}/api/infra/compose/up/stream`);
src.onmessage = (e) => onLine(e.data);
src.onerror = () => { onDone(); src.close(); };
return src;
}
export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource {
const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`);
src.onmessage = (e) => onLine(e.data);
src.onerror = () => { onDone(); src.close(); };
return src;
}
+313
View File
@@ -0,0 +1,313 @@
import type { Opc, OpcNote, OpcArtifact, OpcType, OpcStatus, OpcPriority } from '../types/opc';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
// ── OPC CRUD ──────────────────────────────────────────────────────────────────
export async function getNextNumber(): Promise<string> {
const res = await fetch(`${BASE_URL}/api/opc/next-number`);
if (!res.ok) throw new Error('Failed to fetch next OPC number');
const data = await res.json();
return data.number as string;
}
export async function listOpcs(type?: string, status?: string): Promise<Opc[]> {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (status && status !== 'all') params.set('status', status);
const res = await fetch(`${BASE_URL}/api/opc?${params}`);
if (!res.ok) throw new Error(`Failed to load OPCs: ${res.statusText}`);
// API returns OpcRecord (camelCase from .NET JsonSerializerDefaults.Web)
return (await res.json()).map(mapRecord);
}
export async function getOpc(id: string): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc/${id}`);
if (!res.ok) throw new Error(`Failed to load OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
export async function createOpc(req: {
title: string; type: OpcType; priority: OpcPriority;
assignee: string; description: string;
}): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to create OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
export async function updateOpc(id: string, req: {
title?: string; description?: string; type?: OpcType;
status?: OpcStatus; priority?: OpcPriority; assignee?: string;
}): Promise<Opc> {
const res = await fetch(`${BASE_URL}/api/opc/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to update OPC: ${res.statusText}`);
return mapRecord(await res.json());
}
// ── Notes ─────────────────────────────────────────────────────────────────────
export async function listNotes(opcId: string): Promise<OpcNote[]> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`);
if (!res.ok) throw new Error(`Failed to load notes: ${res.statusText}`);
return (await res.json()).map(mapNote);
}
export async function addNote(opcId: string, author: string, content: string): Promise<OpcNote> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, content }),
});
if (!res.ok) throw new Error(`Failed to add note: ${res.statusText}`);
return mapNote(await res.json());
}
// ── Artifacts ─────────────────────────────────────────────────────────────────
export async function listArtifacts(opcId: string, type?: string): Promise<OpcArtifact[]> {
const params = type ? `?type=${type}` : '';
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts${params}`);
if (!res.ok) throw new Error(`Failed to load artifacts: ${res.statusText}`);
return (await res.json()).map(mapArtifact);
}
export async function createArtifact(opcId: string, req: {
artifactType: string; title: string; content: string;
}): Promise<OpcArtifact> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to create artifact: ${res.statusText}`);
return mapArtifact(await res.json());
}
export async function updateArtifact(artifactId: string, req: {
artifactType: string; title: string; content: string;
}): Promise<OpcArtifact> {
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to update artifact: ${res.statusText}`);
return mapArtifact(await res.json());
}
export async function deleteArtifact(artifactId: string): Promise<void> {
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`Failed to delete artifact: ${res.statusText}`);
}
// ── Git commit linkage ────────────────────────────────────────────────────────
// Commits are linked by convention: developers include "OPC # XXXX" in their commit message.
// The git log endpoint supports ?grep=OPC+%230001 to filter.
export interface LinkedCommit {
hash: string;
shortHash: string;
author: string;
date: string;
subject: string;
files: string[];
}
export async function getLinkedCommits(opcNumber: string): Promise<LinkedCommit[]> {
const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50`);
if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`);
return res.json();
}
// ── Pinned commits ────────────────────────────────────────────────────────────
export interface PinnedCommit {
opcId: string;
hash: string;
shortHash: string;
subject: string;
author: string;
pinnedAt: string;
pinnedBy: string;
}
function mapPinnedCommit(d: Record<string, unknown>): PinnedCommit {
return {
opcId: d.opcId as string,
hash: d.hash as string,
shortHash: d.shortHash as string,
subject: d.subject as string,
author: d.author as string,
pinnedAt: d.pinnedAt as string,
pinnedBy: d.pinnedBy as string,
};
}
export async function getPinnedCommits(opcId: string): Promise<PinnedCommit[]> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`);
if (!res.ok) throw new Error(`Failed to load pinned commits: ${res.statusText}`);
return (await res.json()).map(mapPinnedCommit);
}
export async function pinCommit(opcId: string, hash: string, pinnedBy: string): Promise<PinnedCommit> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, pinnedBy }),
});
if (!res.ok) {
const body = await res.text().catch(() => res.statusText);
throw new Error(body || res.statusText);
}
return mapPinnedCommit(await res.json());
}
export async function unpinCommit(opcId: string, hash: string): Promise<void> {
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits/${hash}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`Failed to unpin commit: ${res.statusText}`);
}
// ── Branch coverage ───────────────────────────────────────────────────────────
export interface BranchCoverage {
branch: string;
contains: boolean;
tipHash: string;
isHead: boolean;
}
export async function getBranchCoverage(hashes: string[]): Promise<BranchCoverage[]> {
if (hashes.length === 0) return [];
const res = await fetch(`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}`);
if (!res.ok) throw new Error(`Failed to get branch coverage: ${res.statusText}`);
return res.json();
}
// ── Commit detail (full diff) ─────────────────────────────────────────────────
export interface CommitFile {
path: string;
oldPath: string;
status: string;
additions: number;
deletions: number;
patch: string;
}
export interface CommitDetail {
hash: string;
shortHash: string;
author: string;
email: string;
date: string;
subject: string;
body: string;
files: CommitFile[];
}
export async function getCommitDetail(hash: string): Promise<CommitDetail> {
const res = await fetch(`${BASE_URL}/api/git/commits/${hash}`);
if (!res.ok) throw new Error(`Failed to load commit ${hash}: ${res.statusText}`);
return res.json();
}
// ── AI assist ─────────────────────────────────────────────────────────────────
export async function aiAssist(prompt: string, context?: string): Promise<string> {
const res = await fetch(`${BASE_URL}/api/opc/ai-assist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, context }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`AI assist failed: ${err}`);
}
const data = await res.json();
return data.text as string;
}
// ── Field mappers (snake_case/PascalCase → camelCase) ─────────────────────────
// .NET JsonSerializerDefaults.Web produces camelCase already, so these are
// lightweight guards in case of null/missing fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapRecord(r: any): Opc {
return {
id: r.id,
number: r.number,
title: r.title,
description: r.description ?? '',
type: r.type as OpcType,
status: r.status as OpcStatus,
priority: r.priority as OpcPriority,
assignee: r.assignee ?? '',
createdAt: r.createdAt,
updatedAt: r.updatedAt,
notes: [], // loaded separately on drawer open
commits: [], // loaded separately on drawer open
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapNote(r: any): OpcNote {
return {
id: r.id,
author: r.author,
timestamp: r.createdAt,
content: r.content,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapArtifact(r: any): OpcArtifact {
return {
id: r.id,
opcId: r.opcId,
artifactType: r.artifactType,
title: r.title,
content: r.content,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
}
// ── Gitea branch integration ───────────────────────────────────────────────────
export interface GiteaBranch {
name: string;
commitSha: string;
protected: boolean;
}
export async function listGiteaBranches(): Promise<GiteaBranch[]> {
const res = await fetch(`${BASE_URL}/api/gitea/branches`);
if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`);
return res.json();
}
export async function createGiteaBranch(
opcNumber: string,
opcTitle: string,
from = 'master',
): Promise<GiteaBranch> {
const res = await fetch(`${BASE_URL}/api/gitea/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opcNumber, opcTitle, from }),
});
if (!res.ok) {
const body = await res.text().catch(() => res.statusText);
throw new Error(body || res.statusText);
}
return res.json();
}
@@ -0,0 +1,290 @@
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 BranchStatus {
branch: string;
exists: boolean;
shortHash: string | null;
lastCommitSummary: string | null;
aheadOfNext: number;
behindNext: number;
unreleasedLines: string[];
}
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(): Promise<BranchStatus[]> {
const res = await fetch(`${BASE_URL}/api/promotions/ladder`);
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,
): () => 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 }),
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(); };
}