diff --git a/clarity.controlplane/package-lock.json b/clarity.controlplane/package-lock.json index a9a13ef..e973ec6 100644 --- a/clarity.controlplane/package-lock.json +++ b/clarity.controlplane/package-lock.json @@ -13,7 +13,8 @@ "highlight.js": "^11.11.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-multistep": "^7.0.0" + "react-multistep": "^7.0.0", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -1710,6 +1711,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3043,6 +3057,44 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -3140,6 +3192,12 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/clarity.controlplane/package.json b/clarity.controlplane/package.json index d1d8d2d..34762ce 100644 --- a/clarity.controlplane/package.json +++ b/clarity.controlplane/package.json @@ -15,7 +15,8 @@ "highlight.js": "^11.11.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-multistep": "^7.0.0" + "react-multistep": "^7.0.0", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/clarity.controlplane/src/App.tsx b/clarity.controlplane/src/App.tsx index 90717f8..3e9cf7e 100644 --- a/clarity.controlplane/src/App.tsx +++ b/clarity.controlplane/src/App.tsx @@ -1,6 +1,6 @@ import '@blueprintjs/core/lib/css/blueprint.css'; import './App.css'; -import { useState } from 'react'; +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core'; import DashboardPage from './pages/DashboardPage'; import PipelinesPage from './pages/PipelinesPage'; @@ -11,59 +11,44 @@ import OpcPage from './opc/OpcPage'; import InfraPage from './pages/InfraPage'; import ChangesetsPage from './pages/ChangesetsPage'; -function App() { - const [activeNav, setActiveNav] = useState('opc'); +function Sidebar() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const at = (path: string) => pathname === path || pathname.startsWith(path + '/'); return ( -
- {/* ── Sidebar ── */} -
+ + ); } @@ -76,4 +61,27 @@ function PlaceholderPage({ title }: { title: string }) { ); } +function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + export default App; diff --git a/clarity.controlplane/src/api/buildApi.ts b/clarity.controlplane/src/api/buildApi.ts new file mode 100644 index 0000000..d95104c --- /dev/null +++ b/clarity.controlplane/src/api/buildApi.ts @@ -0,0 +1,49 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +export interface ProjectDefinition { + name: string; + kind: 'DotnetProject' | 'NpmProject' | 'SolutionBuild'; + relativePath: string; +} + +export interface BuildRecord { + id: string; + kind: 'DockerImage' | 'DotnetProject' | 'NpmProject' | 'SolutionBuild'; + target: string; + status: 'Running' | 'Succeeded' | 'Failed'; + startedAt: string; + finishedAt?: string; + durationMs?: number; + commitSha?: string; + 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(); +} + +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; +} diff --git a/clarity.controlplane/src/api/gitApi.ts b/clarity.controlplane/src/api/gitApi.ts new file mode 100644 index 0000000..0f5f4e5 --- /dev/null +++ b/clarity.controlplane/src/api/gitApi.ts @@ -0,0 +1,18 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +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(); +} diff --git a/clarity.controlplane/src/api/imageApi.ts b/clarity.controlplane/src/api/imageApi.ts new file mode 100644 index 0000000..68736b0 --- /dev/null +++ b/clarity.controlplane/src/api/imageApi.ts @@ -0,0 +1,51 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +export interface ImageBuildStatus { + imageName: string | null; + builtAt: string | null; + lastMessage: string; + isBuilding: boolean; +} + +export interface BuildHistoryRecord { + id: string; + status: 'Running' | 'Succeeded' | 'Failed'; + startedAt: string; + durationMs: number | null; + commitSha: string | null; + imageDigest: string | null; +} + +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(); +} + +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 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; +} + +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}`); +} diff --git a/clarity.controlplane/src/api/opcApi.ts b/clarity.controlplane/src/api/opcApi.ts index 546b6e8..edad01f 100644 --- a/clarity.controlplane/src/api/opcApi.ts +++ b/clarity.controlplane/src/api/opcApi.ts @@ -328,7 +328,7 @@ export async function listGiteaBranches(repoKey?: string): Promise { const res = await fetch(`${BASE_URL}/api/gitea/branches`, { method: 'POST', diff --git a/clarity.controlplane/src/api/promotionApi.ts b/clarity.controlplane/src/api/promotionApi.ts new file mode 100644 index 0000000..7e4fabb --- /dev/null +++ b/clarity.controlplane/src/api/promotionApi.ts @@ -0,0 +1,204 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +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 type ConformanceViolation = 'OK' | 'Missing' | 'Diverged' | 'Stale'; +export type ConformanceSeverity = 'OK' | 'Info' | 'Warning' | 'Critical'; + +export interface BranchConformanceCheck { + branch: string; + sourceBranch: string | null; + violation: ConformanceViolation; + severity: ConformanceSeverity; + detail: string; + aheadOfSource: number; + behindSource: number; + fixSha: string | null; +} + +export interface ConformanceReport { + repo: string; + isConformant: boolean; + checks: BranchConformanceCheck[]; +} + +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(); +} + +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 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(); }; +} + +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); + } +} + +export async function getConformanceReport(repo = 'Clarity'): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/conformance?repo=${encodeURIComponent(repo)}`); + if (!res.ok) throw new Error(`Failed to get conformance report: ${res.statusText}`); + return res.json(); +} + +export async function getAllConformanceReports(): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/conformance/all`); + if (!res.ok) throw new Error(`Failed to get conformance reports: ${res.statusText}`); + return res.json(); +} + +export async function createLadderBranch(branch: string, fromSha: string, repo: string): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/create-branch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, fromSha, repo }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? res.statusText); + } +} diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts index d620363..81d7d3b 100644 --- a/clarity.controlplane/src/api/provisioningApi.ts +++ b/clarity.controlplane/src/api/provisioningApi.ts @@ -1,427 +1,7 @@ -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' | 'SolutionBuild'; - relativePath: string; -} - -export interface BuildRecord { - id: string; - kind: 'DockerImage' | 'DotnetProject' | 'NpmProject' | 'SolutionBuild'; - target: string; - status: 'Running' | 'Succeeded' | 'Failed'; - startedAt: string; - finishedAt?: string; - durationMs?: number; - commitSha?: string; - 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(); }; -} - -// -- Branch Conformance API -------------------------------------------------- - -export type ConformanceViolation = 'OK' | 'Missing' | 'Diverged' | 'Stale'; -export type ConformanceSeverity = 'OK' | 'Info' | 'Warning' | 'Critical'; - -export interface BranchConformanceCheck { - branch: string; - sourceBranch: string | null; - violation: ConformanceViolation; - severity: ConformanceSeverity; - detail: string; - aheadOfSource: number; - behindSource: number; - fixSha: string | null; -} - -export interface ConformanceReport { - repo: string; - isConformant: boolean; - checks: BranchConformanceCheck[]; -} - -export async function getConformanceReport(repo = 'Clarity'): Promise { - const res = await fetch(`${BASE_URL}/api/promotions/conformance?repo=${encodeURIComponent(repo)}`); - if (!res.ok) throw new Error(`Failed to get conformance report: ${res.statusText}`); - return res.json(); -} - -export async function getAllConformanceReports(): Promise { - const res = await fetch(`${BASE_URL}/api/promotions/conformance/all`); - if (!res.ok) throw new Error(`Failed to get conformance reports: ${res.statusText}`); - return res.json(); -} - -export async function createLadderBranch(branch: string, fromSha: string, repo: string): Promise { - const res = await fetch(`${BASE_URL}/api/promotions/create-branch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branch, fromSha, repo }), - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error((body as { error?: string }).error ?? res.statusText); - } -} \ No newline at end of file +// Barrel re-export � split into domain modules. Import directly from the specific module for new code. +export * from './tenantApi'; +export * from './imageApi'; +export * from './releaseApi'; +export * from './buildApi'; +export * from './gitApi'; +export * from './promotionApi'; diff --git a/clarity.controlplane/src/api/releaseApi.ts b/clarity.controlplane/src/api/releaseApi.ts new file mode 100644 index 0000000..c52c2fb --- /dev/null +++ b/clarity.controlplane/src/api/releaseApi.ts @@ -0,0 +1,42 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +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(); +} + +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; +} diff --git a/clarity.controlplane/src/api/tenantApi.ts b/clarity.controlplane/src/api/tenantApi.ts new file mode 100644 index 0000000..0db96c6 --- /dev/null +++ b/clarity.controlplane/src/api/tenantApi.ts @@ -0,0 +1,44 @@ +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; +} diff --git a/clarity.controlplane/src/components/ImageBuildPanel.tsx b/clarity.controlplane/src/components/ImageBuildPanel.tsx index 7264e3d..7196cf1 100644 --- a/clarity.controlplane/src/components/ImageBuildPanel.tsx +++ b/clarity.controlplane/src/components/ImageBuildPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Callout, Intent, Tag } from '@blueprintjs/core'; -import { getImageStatus, type ImageBuildStatus } from '../api/provisioningApi'; +import { getImageStatus, type ImageBuildStatus } from '../api/imageApi'; const BASE_URL = import.meta.env.VITE_API_URL ?? ''; diff --git a/clarity.controlplane/src/components/wizard/DeployWizard.tsx b/clarity.controlplane/src/components/wizard/DeployWizard.tsx index 161b054..bc2d5fd 100644 --- a/clarity.controlplane/src/components/wizard/DeployWizard.tsx +++ b/clarity.controlplane/src/components/wizard/DeployWizard.tsx @@ -4,7 +4,7 @@ import ClientDetailsStep from './ClientDetailsStep'; import DeploymentConfigStep from './DeploymentConfigStep'; import ReviewStep from './ReviewStep'; import DeploymentLiveStep from './DeploymentLiveStep'; -import { submitProvisioningJob } from '../../api/provisioningApi'; +import { submitProvisioningJob } from '../../api/tenantApi'; import { defaultStackConfig } from '../../types/provisioning'; import type { ProvisioningRequest } from '../../types/provisioning'; diff --git a/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx b/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx index 3ceb03f..2753dee 100644 --- a/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx +++ b/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { AnchorButton, Callout, Intent, ProgressBar, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core'; -import { subscribeToJobStream } from '../../api/provisioningApi'; +import { subscribeToJobStream } from '../../api/tenantApi'; import { tenantUrl } from '../../config'; import type { ProvisioningProgressEvent } from '../../types/provisioning'; diff --git a/clarity.controlplane/src/main.tsx b/clarity.controlplane/src/main.tsx index bef5202..ade9d64 100644 --- a/clarity.controlplane/src/main.tsx +++ b/clarity.controlplane/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/clarity.controlplane/src/opc/OpcPage.tsx b/clarity.controlplane/src/opc/OpcPage.tsx index 67d2366..920f4f9 100644 --- a/clarity.controlplane/src/opc/OpcPage.tsx +++ b/clarity.controlplane/src/opc/OpcPage.tsx @@ -76,7 +76,7 @@ const SDLC_STAGES: { branch: string; label: string; intent: Intent }[] = [ { branch: 'develop', label: 'Dev', intent: Intent.PRIMARY }, { branch: 'staging', label: 'Staging', intent: Intent.WARNING }, { branch: 'uat', label: 'UAT', intent: Intent.DANGER }, - { branch: 'master', label: 'Production', intent: Intent.SUCCESS }, + { branch: 'main', label: 'Production', intent: Intent.SUCCESS }, ]; function deriveSdlcSummary(coverage: BranchCoverage[]): { label: string; intent: Intent } | null { diff --git a/clarity.controlplane/src/pages/BranchPage.tsx b/clarity.controlplane/src/pages/BranchPage.tsx index ec1aef1..5e54d60 100644 --- a/clarity.controlplane/src/pages/BranchPage.tsx +++ b/clarity.controlplane/src/pages/BranchPage.tsx @@ -6,10 +6,11 @@ import { } from '@blueprintjs/core'; import { getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick, - getImageBuildHistory, resetBranch, getAllConformanceReports, createLadderBranch, - type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord, + resetBranch, getAllConformanceReports, createLadderBranch, + type BranchStatus, type CommitInfo, type PromotionRecord, type ConformanceReport, type BranchConformanceCheck, -} from '../api/provisioningApi'; +} from '../api/promotionApi'; +import { getImageBuildHistory, type BuildHistoryRecord } from '../api/imageApi'; // ── Constants ───────────────────────────────────────────────────────────────── diff --git a/clarity.controlplane/src/pages/BuildMonitorPage.tsx b/clarity.controlplane/src/pages/BuildMonitorPage.tsx index a745cf1..dd9635e 100644 --- a/clarity.controlplane/src/pages/BuildMonitorPage.tsx +++ b/clarity.controlplane/src/pages/BuildMonitorPage.tsx @@ -3,10 +3,8 @@ import { Button, Callout, Intent, Tag, Spinner, NonIdealState, Collapse, HTMLTable, } from '@blueprintjs/core'; -import { - getProjects, getBuildHistory, getGitLog, - type ProjectDefinition, type BuildRecord, type GitCommit, -} from '../api/provisioningApi'; +import { getProjects, getBuildHistory, type ProjectDefinition, type BuildRecord } from '../api/buildApi'; +import { getGitLog, type GitCommit } from '../api/gitApi'; const BASE_URL = import.meta.env.VITE_API_URL ?? ''; diff --git a/clarity.controlplane/src/pages/DashboardPage.tsx b/clarity.controlplane/src/pages/DashboardPage.tsx index a152808..77587b6 100644 --- a/clarity.controlplane/src/pages/DashboardPage.tsx +++ b/clarity.controlplane/src/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { AnchorButton, Button, Callout, Intent, NonIdealState, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core'; import DeployWizard from '../components/wizard/DeployWizard'; import { tenantUrl, CLARITY_DOMAIN } from '../config'; -import { getTenants, subscribeToTenantLogs } from '../api/provisioningApi'; +import { getTenants, subscribeToTenantLogs } from '../api/tenantApi'; import type { TenantRecord } from '../types/provisioning'; const ENV_INTENT: Record = { diff --git a/clarity.controlplane/src/pages/ImageBuildPage.tsx b/clarity.controlplane/src/pages/ImageBuildPage.tsx index 3ff4a48..461dd02 100644 --- a/clarity.controlplane/src/pages/ImageBuildPage.tsx +++ b/clarity.controlplane/src/pages/ImageBuildPage.tsx @@ -4,7 +4,8 @@ import { HTMLTable, Card, Elevation, Tabs, Tab, type TabId, FormGroup, InputGroup, } from '@blueprintjs/core'; -import { getImageStatus, getBuildHistory, type ImageBuildStatus, type BuildRecord } from '../api/provisioningApi'; +import { getImageStatus, type ImageBuildStatus } from '../api/imageApi'; +import { getBuildHistory, type BuildRecord } from '../api/buildApi'; import { getInfraStatus, streamComposeUp, streamComposeForceUp, streamComposeNuke, streamComposeDown, type InfraService, diff --git a/clarity.controlplane/src/pages/PipelinesPage.tsx b/clarity.controlplane/src/pages/PipelinesPage.tsx index b270727..22fe96e 100644 --- a/clarity.controlplane/src/pages/PipelinesPage.tsx +++ b/clarity.controlplane/src/pages/PipelinesPage.tsx @@ -3,10 +3,8 @@ import { Button, Callout, Intent, Tag, Spinner, HTMLTable, NonIdealState, } from '@blueprintjs/core'; -import { - getReleaseHistory, getBuildHistory, - type ReleaseRecord, type BuildRecord, -} from '../api/provisioningApi'; +import { getReleaseHistory, type ReleaseRecord } from '../api/releaseApi'; +import { getBuildHistory, type BuildRecord } from '../api/buildApi'; const BASE_URL = import.meta.env.VITE_API_URL ?? '';