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
+1
View File
@@ -0,0 +1 @@
/* App-level overrides — component styles live in index.css */
+76
View File
@@ -0,0 +1,76 @@
import '@blueprintjs/core/lib/css/blueprint.css';
import './App.css';
import { useState } from 'react';
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import DashboardPage from './pages/DashboardPage';
import PipelinesPage from './pages/PipelinesPage';
import BuildMonitorPage from './pages/BuildMonitorPage';
import ImageBuildPage from './pages/ImageBuildPage';
import BranchPage from './pages/BranchPage';
import OpcPage from './opc/OpcPage';
import InfraPage from './pages/InfraPage';
function App() {
const [activeNav, setActiveNav] = useState('opc');
return (
<div className="cp-shell">
{/* ── Sidebar ── */}
<aside className="cp-sidebar">
<div className="cp-sidebar-brand">
<span className="brand-mark">CP</span>
<span className="brand-name">Control Plane</span>
</div>
<div className="cp-sidebar-nav">
<Menu className="cp-sidebar-menu">
<MenuItem icon="cloud-upload" text="Deployments" active={activeNav === 'deployments'} onClick={() => setActiveNav('deployments')} />
<MenuItem icon="git-branch" text="Pipelines" active={activeNav === 'pipelines'} onClick={() => setActiveNav('pipelines')} />
<MenuItem icon="git-merge" text="Branch Ladder" active={activeNav === 'branches'} onClick={() => setActiveNav('branches')} />
<MenuItem icon="build" text="Image Build" active={activeNav === 'image-build'} onClick={() => setActiveNav('image-build')} />
<MenuItem icon="pulse" text="Build Monitor" active={activeNav === 'build-monitor'} onClick={() => setActiveNav('build-monitor')} />
<MenuDivider />
<MenuItem icon="heat-grid" text="Infrastructure" active={activeNav === 'infra'} onClick={() => setActiveNav('infra')} />
<MenuItem icon="clipboard" text="OPC" active={activeNav === 'opc'} onClick={() => setActiveNav('opc')} />
<MenuItem icon="people" text="Clients" active={activeNav === 'clients'} onClick={() => setActiveNav('clients')} />
<MenuItem icon="cog" text="Settings" active={activeNav === 'settings'} onClick={() => setActiveNav('settings')} />
</Menu>
</div>
<div className="cp-sidebar-footer">
<div className="cp-sidebar-user">
<div className="user-avatar">A</div>
<div className="user-info">
<span className="user-name">Platform Admin</span>
<span className="user-role">Clarity Internal</span>
</div>
</div>
</div>
</aside>
{/* ── Main content ── */}
<main className="cp-main">
{activeNav === 'deployments' && <DashboardPage />}
{activeNav === 'pipelines' && <PipelinesPage />}
{activeNav === 'branches' && <BranchPage />}
{activeNav === 'image-build' && <ImageBuildPage />}
{activeNav === 'build-monitor' && <BuildMonitorPage />}
{activeNav === 'infra' && <InfraPage />}
{activeNav === 'opc' && <OpcPage />}
{activeNav === 'clients' && <PlaceholderPage title="Clients" />}
{activeNav === 'settings' && <PlaceholderPage title="Settings" />}
</main>
</div>
);
}
function PlaceholderPage({ title }: { title: string }) {
return (
<div className="page-header">
<h1>{title}</h1>
<p>Coming soon.</p>
</div>
);
}
export default App;
+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(); };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,117 @@
import { useEffect, useState, useRef } from 'react';
import { Button, Drawer, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core';
import { html as diff2htmlHtml } from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { getCommitDetail, type CommitDetail } from '../api/opcApi';
interface Props {
hash: string | null;
onClose: () => void;
}
export function GitCommitDrawer({ hash, onClose }: Props) {
const [detail, setDetail] = useState<CommitDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const diffRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!hash) { setDetail(null); setError(null); return; }
setLoading(true); setDetail(null); setError(null);
getCommitDetail(hash)
.then(setDetail)
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [hash]);
// After diff HTML is injected, run highlight.js over code blocks
useEffect(() => {
if (detail && diffRef.current) {
diffRef.current.querySelectorAll<HTMLElement>('code[class]').forEach(el => {
hljs.highlightElement(el);
});
}
}, [detail]);
const combinedPatch = detail?.files.map(f => f.patch).join('\n') ?? '';
const diffHtml = combinedPatch
? diff2htmlHtml(combinedPatch, {
drawFileList: true,
matching: 'lines',
outputFormat: 'line-by-line',
renderNothingWhenEmpty: false,
})
: '';
return (
<Drawer
isOpen={!!hash}
onClose={onClose}
title={detail ? (
<span className="git-drawer-title">
<code className="git-drawer-hash">{detail.shortHash}</code>
<span className="git-drawer-subject">{detail.subject}</span>
</span>
) : 'Commit Diff'}
size="70%"
position="right"
className="git-commit-drawer"
>
<div className="git-drawer-body">
{loading && <NonIdealState icon={<Spinner size={24} />} title="Loading diff…" />}
{error && <NonIdealState icon="error" intent={Intent.DANGER} title="Failed to load commit" description={error} />}
{detail && (
<>
{/* Metadata bar */}
<div className="git-commit-meta-bar">
<div className="git-commit-meta-left">
<Tooltip content="Copy full hash">
<code
className="git-commit-hash-chip"
onClick={() => navigator.clipboard.writeText(detail.hash)}
style={{ cursor: 'pointer' }}
>
{detail.shortHash}
</code>
</Tooltip>
<span className="git-commit-author">{detail.author}</span>
<span className="git-commit-date">{detail.date}</span>
</div>
<div className="git-commit-meta-right">
<Tag intent={Intent.SUCCESS} minimal round icon="add">
+{detail.files.reduce((a, f) => a + f.additions, 0)}
</Tag>
<Tag intent={Intent.DANGER} minimal round icon="remove">
-{detail.files.reduce((a, f) => a + f.deletions, 0)}
</Tag>
<Tag minimal round>{detail.files.length} file{detail.files.length !== 1 ? 's' : ''}</Tag>
</div>
</div>
{/* Commit body if multiline */}
{detail.body.trim() !== detail.subject.trim() && (
<pre className="git-commit-body">{detail.body.trim()}</pre>
)}
{/* Diff */}
{diffHtml
? <div ref={diffRef} className="git-diff-container" dangerouslySetInnerHTML={{ __html: diffHtml }} />
: <NonIdealState icon="git-commit" title="No diff" description="This commit has no file changes." />
}
</>
)}
{!loading && !error && !detail && hash && (
<NonIdealState icon={<Spinner size={20} />} title="Loading…" />
)}
</div>
<div className="git-drawer-footer">
<Button text="Close" onClick={onClose} />
</div>
</Drawer>
);
}
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Callout, Intent, Tag } from '@blueprintjs/core';
import { getImageStatus, type ImageBuildStatus } from '../api/provisioningApi';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
export default function ImageBuildPanel() {
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
const [building, setBuilding] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getImageStatus().then(setStatus).catch(() => {});
}, []);
// Auto-scroll log panel
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const handleBuild = async () => {
if (building) return;
setBuilding(true);
setOpen(true);
setLogs([]);
setError(null);
try {
// POST /api/image/build — the response body IS the SSE stream
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
if (!res.ok || !res.body) {
setError(`Build failed to start: ${res.statusText}`);
setBuilding(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
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) {
// Build finished — refresh status
getImageStatus().then(setStatus).catch(() => {});
} else if (typeof msg.line === 'string') {
setLogs((prev) => [...prev.slice(-1000), msg.line]);
}
} catch { /* ignore non-JSON */ }
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error during build');
} finally {
setBuilding(false);
}
};
const lastBuilt = status?.builtAt
? new Date(status.builtAt).toLocaleString()
: 'Never';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Button
icon="build"
intent={Intent.WARNING}
loading={building}
onClick={handleBuild}
text="Build Image"
/>
{!building && (
<Tag minimal intent={Intent.NONE} style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}>
{status?.imageName ?? 'clarity-server:latest'} · last built {lastBuilt}
</Tag>
)}
{building && (
<Tag minimal intent={Intent.WARNING}>Building</Tag>
)}
{logs.length > 0 && !building && (
<Button
icon={open ? 'chevron-up' : 'chevron-down'}
minimal
small
onClick={() => setOpen((o) => !o)}
text={open ? 'Hide log' : 'Show log'}
/>
)}
</div>
{error && (
<Callout intent={Intent.DANGER} compact>{error}</Callout>
)}
{open && logs.length > 0 && (
<div
ref={logRef}
style={{
fontFamily: 'monospace',
fontSize: '0.72rem',
background: '#111',
color: '#d4d4d4',
padding: '0.6rem 0.8rem',
borderRadius: '4px',
height: '220px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{logs.map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import { useEffect } from 'react';
import { Callout, FormGroup, InputGroup, Intent } from '@blueprintjs/core';
import { CLARITY_DOMAIN } from '../../config';
import type { ProvisioningRequest } from '../../types/provisioning';
interface Props {
signalParent: (state: { isValid: boolean }) => void;
data: ProvisioningRequest;
onChange: (updated: Partial<ProvisioningRequest>) => void;
}
function deriveSubdomain(environment: string, siteCode: string): string {
const env = environment.toLowerCase().replace(/[^a-z]/g, '');
const site = siteCode.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!env || !site) return '';
return `${env}-app-clarity-${site}`;
}
export default function ClientDetailsStep({ signalParent, data, onChange }: Props) {
// Keep a flag so we can show the user the derived name immediately
const derivedSubdomain = deriveSubdomain(data.environment, data.siteCode);
useEffect(() => {
if (derivedSubdomain !== data.subdomain) {
onChange({ subdomain: derivedSubdomain });
}
}, [derivedSubdomain]);
const isValid =
data.clientName.trim().length > 0 &&
data.stateCode.trim().length === 2 &&
data.siteCode.trim().length > 0 &&
data.adminEmail.includes('@') &&
data.subdomain.length > 0;
useEffect(() => {
signalParent({ isValid });
}, [isValid, signalParent]);
return (
<div className="wizard-step">
<p className="step-description">Enter the details for the new client tenant.</p>
<FormGroup label="Client Name" labelFor="clientName" labelInfo="(required)">
<InputGroup
id="clientName"
placeholder="e.g. Florida Commerce"
value={data.clientName}
onValueChange={(v) => onChange({ clientName: v })}
large
/>
</FormGroup>
<FormGroup label="State Code" labelFor="stateCode" labelInfo="(required, 2 letters)">
<InputGroup
id="stateCode"
placeholder="FL"
maxLength={2}
value={data.stateCode}
onValueChange={(v) => onChange({ stateCode: v.toUpperCase() })}
large
style={{ maxWidth: 120 }}
/>
</FormGroup>
<FormGroup
label="Site Code"
labelFor="siteCode"
labelInfo="(required)"
helperText="Unique numeric identifier, e.g. 01000014"
>
<InputGroup
id="siteCode"
placeholder="01000014"
value={data.siteCode}
onValueChange={(v) => onChange({ siteCode: v.replace(/[^0-9]/g, '') })}
large
style={{ maxWidth: 200 }}
/>
</FormGroup>
<FormGroup label="Day-Zero Admin Email" labelFor="adminEmail" labelInfo="(required)">
<InputGroup
id="adminEmail"
type="email"
placeholder="director@commerce.fl.gov"
value={data.adminEmail}
onValueChange={(v) => onChange({ adminEmail: v })}
large
/>
</FormGroup>
{data.subdomain && (
<Callout intent={Intent.PRIMARY} style={{ marginTop: '0.5rem' }}>
<strong>Container name:</strong> <code>{data.subdomain}</code>
<br />
<strong>Client URL:</strong> <code>{data.subdomain}.{CLARITY_DOMAIN}</code>
</Callout>
)}
</div>
);
}
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { Button, Intent } from '@blueprintjs/core';
import ClientDetailsStep from './ClientDetailsStep';
import DeploymentConfigStep from './DeploymentConfigStep';
import ReviewStep from './ReviewStep';
import DeploymentLiveStep from './DeploymentLiveStep';
import { submitProvisioningJob } from '../../api/provisioningApi';
import type { ProvisioningRequest } from '../../types/provisioning';
const EMPTY: ProvisioningRequest = {
clientName: '', stateCode: '', subdomain: '', adminEmail: '',
siteCode: '', environment: 'fdev', tier: 'Shared',
};
const STEP_LABELS = ['Client Details', 'Deployment Config', 'Review', 'Deploying'];
interface Props {
onClose: () => void;
}
export default function DeployWizard({ onClose }: Props) {
const [activeStep, setActiveStep] = useState(0);
const [formData, setFormData] = useState<ProvisioningRequest>(EMPTY);
const [step0Valid, setStep0Valid] = useState(false);
const [step1Valid, setStep1Valid] = useState(true); // tier has a default
const [jobId, setJobId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const handleChange = (u: Partial<ProvisioningRequest>) =>
setFormData((p) => ({ ...p, ...u }));
const handleDeploy = async () => {
setSubmitting(true);
setSubmitError(null);
try {
const id = await submitProvisioningJob(formData);
setJobId(id);
setActiveStep(3);
} catch (e: unknown) {
setSubmitError(e instanceof Error ? e.message : 'Deployment failed. Please try again.');
} finally {
setSubmitting(false);
}
};
const canGoBack = activeStep > 0 && !jobId;
const isLastFormStep = activeStep === 2;
return (
<div className="wizard-page">
{/* Header */}
<div className="wizard-page-header">
<div className="wizard-page-title">
<h2>Deploy New Client</h2>
<p>Provision a new Clarity tenant from scratch.</p>
</div>
{!jobId && (
<Button minimal icon="cross" onClick={onClose} />
)}
</div>
{/* Step progress */}
<div className="wizard-progress">
{STEP_LABELS.map((label, i) => (
<div
key={i}
className={`wizard-progress-step${i === activeStep ? ' active' : i < activeStep ? ' done' : ''}`}
>
<div className="wizard-progress-dot">{i < activeStep ? '✓' : i + 1}</div>
<span>{label}</span>
</div>
))}
</div>
{/* Step content */}
<div className="wizard-page-body">
{activeStep === 0 && (
<ClientDetailsStep
data={formData}
onChange={handleChange}
signalParent={({ isValid }) => setStep0Valid(isValid)}
/>
)}
{activeStep === 1 && (
<DeploymentConfigStep
data={formData}
onChange={handleChange}
signalParent={({ isValid }) => setStep1Valid(isValid)}
/>
)}
{activeStep === 2 && (
<ReviewStep data={formData} signalParent={() => {}} />
)}
{activeStep === 3 && jobId && (
<DeploymentLiveStep jobId={jobId} subdomain={formData.subdomain} />
)}
{submitError && (
<p className="wizard-error" style={{ marginTop: 12 }}>{submitError}</p>
)}
</div>
{/* Footer nav */}
{activeStep < 3 && (
<div className="wizard-page-footer">
{canGoBack && (
<Button text="Back" minimal icon="arrow-left" onClick={() => setActiveStep((s) => s - 1)} disabled={submitting} />
)}
<div style={{ flex: 1 }} />
{activeStep === 0 && (
<Button intent={Intent.PRIMARY} text="Next" rightIcon="arrow-right" disabled={!step0Valid} onClick={() => setActiveStep(1)} />
)}
{activeStep === 1 && (
<Button intent={Intent.PRIMARY} text="Next" rightIcon="arrow-right" disabled={!step1Valid} onClick={() => setActiveStep(2)} />
)}
{isLastFormStep && (
<Button intent={Intent.DANGER} text="Deploy Client" icon="cloud-upload" loading={submitting} onClick={handleDeploy} />
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import type { ProvisioningRequest, TenantEnvironment, TenantTier } from '../../types/provisioning';
interface Props {
signalParent: (state: { isValid: boolean }) => void;
data: ProvisioningRequest;
onChange: (updated: Partial<ProvisioningRequest>) => void;
}
const ENVIRONMENTS: { value: TenantEnvironment; label: string; description: string }[] = [
{ value: 'fdev', label: 'Dev (fdev)', description: 'Feature development - fast provisioning, no production data.' },
{ value: 'uat', label: 'UAT', description: 'User acceptance testing - mirrors production configuration.' },
{ value: 'prod', label: 'Production', description: 'Live production environment. Full isolation enforced.' },
];
const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [
{
value: 'Shared',
label: 'Shared',
badge: 'Standard',
description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.',
},
{
value: 'Isolated',
label: 'Isolated',
badge: 'Professional',
description: 'Shared Keycloak and Vault, but a dedicated Postgres container and MinIO bucket per tenant.',
},
{
value: 'Dedicated',
label: 'Dedicated',
badge: 'Enterprise',
description: 'Fully dedicated Keycloak, Vault, Postgres and MinIO containers for complete hard isolation.',
},
];
export default function DeploymentConfigStep({ signalParent, data, onChange }: Props) {
useEffect(() => {
signalParent({ isValid: !!data.tier && !!data.environment });
}, [data.tier, data.environment, signalParent]);
return (
<div className="wizard-step">
<p className="step-description">Choose the deployment environment and infrastructure isolation tier.</p>
<h4 style={{ marginBottom: '0.5rem' }}>Environment</h4>
<div className="tier-cards" style={{ marginBottom: '1.5rem' }}>
{ENVIRONMENTS.map((env) => (
<button
key={env.value}
type="button"
className={`tier-card${data.environment === env.value ? ' selected' : ''}`}
onClick={() => onChange({ environment: env.value })}
>
<div className="tier-card-header">
<span className="tier-card-label">{env.label}</span>
</div>
<p className="tier-card-description">{env.description}</p>
</button>
))}
</div>
<h4 style={{ marginBottom: '0.5rem' }}>Isolation Tier</h4>
<div className="tier-cards">
{TIERS.map((tier) => (
<button
key={tier.value}
type="button"
className={`tier-card${data.tier === tier.value ? ' selected' : ''}`}
onClick={() => onChange({ tier: tier.value })}
>
<div className="tier-card-header">
<span className="tier-card-label">{tier.label}</span>
<span className={`tier-card-badge tier-badge-${tier.value.toLowerCase()}`}>{tier.badge}</span>
</div>
<p className="tier-card-description">{tier.description}</p>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from 'react';
import { AnchorButton, Callout, Intent, ProgressBar, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core';
import { subscribeToJobStream } from '../../api/provisioningApi';
import { tenantUrl } from '../../config';
import type { ProvisioningProgressEvent } from '../../types/provisioning';
const SAGA_STEPS = [
'Infrastructure Provisioning',
'Identity Bootstrapping (Keycloak)',
'Cryptographic Pre-Flight (Vault)',
'Database Migration & Seeding (EF Core)',
'Handoff (Email Magic Link)',
];
type StepStatus = 'pending' | 'running' | 'complete' | 'failed';
interface Props {
jobId: string;
subdomain: string;
}
export default function DeploymentLiveStep({ jobId, subdomain }: Props) {
const [stepStatuses, setStepStatuses] = useState<Record<string, StepStatus>>(
Object.fromEntries(SAGA_STEPS.map((s) => [s, 'pending' as StepStatus]))
);
const [logs, setLogs] = useState<ProvisioningProgressEvent[]>([]);
const [diagnostics, setDiagnostics] = useState<ProvisioningProgressEvent[]>([]);
const [finalStatus, setFinalStatus] = useState<'running' | 'complete' | 'failed'>('running');
const logEndRef = useRef<HTMLDivElement>(null);
const diagEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let terminal = false;
const source = subscribeToJobStream(jobId, (evt) => {
if (evt.type === 'diagnostic') {
setDiagnostics((prev) => [...prev, evt]);
return;
}
setLogs((prev) => [...prev, evt]);
if (evt.type === 'step_started' && evt.step)
setStepStatuses((p) => ({ ...p, [evt.step!]: 'running' }));
else if (evt.type === 'step_complete' && evt.step)
setStepStatuses((p) => ({ ...p, [evt.step!]: 'complete' }));
else if (evt.type === 'step_failed' && evt.step) {
setStepStatuses((p) => ({ ...p, [evt.step!]: 'failed' }));
setFinalStatus('failed');
} else if (evt.type === 'job_complete') {
terminal = true;
setFinalStatus('complete');
} else if (evt.type === 'job_failed') {
terminal = true;
setFinalStatus('failed');
}
}, () => { if (!terminal) setFinalStatus('failed'); });
return () => source.close();
}, [jobId]);
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]);
useEffect(() => { diagEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [diagnostics]);
const completedCount = Object.values(stepStatuses).filter((s) => s === 'complete').length;
const clientUrl = tenantUrl(subdomain);
const progressPanel = (
<>
<div className="step-tracker">
{SAGA_STEPS.map((step) => {
const status = stepStatuses[step];
return (
<div key={step} className="step-tracker-row">
{status === 'running' && <Spinner size={14} />}
{status === 'complete' && <Tag intent={Intent.SUCCESS} minimal round>&#10003;</Tag>}
{status === 'failed' && <Tag intent={Intent.DANGER} minimal round>&#10007;</Tag>}
{status === 'pending' && <span className="step-dot" />}
<span className={`step-label step-${status}`}>{step}</span>
</div>
);
})}
</div>
<div className="log-feed">
{logs.map((log, i) => (
<div key={i} className={`log-line log-${log.type}`}>
<span className="log-ts">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="log-msg">{log.message ?? log.type}</span>
</div>
))}
<div ref={logEndRef} />
</div>
</>
);
const diagnosticsPanel = (
<div className="log-feed log-feed--diagnostics">
{diagnostics.length === 0
? <span className="log-msg" style={{ opacity: 0.5 }}>No diagnostics captured.</span>
: diagnostics.map((d, i) => (
<div key={i} className="log-line log-diagnostic">
<span className="log-ts">{new Date(d.timestamp).toLocaleTimeString()}</span>
<span className="log-step">{d.step}</span>
<pre className="log-detail">{d.detail ?? d.message}</pre>
</div>
))
}
<div ref={diagEndRef} />
</div>
);
return (
<div className="wizard-step">
<p className="step-description">
{finalStatus === 'running' ? 'Provisioning in progress - do not close this window.' :
finalStatus === 'complete' ? 'Deployment complete.' : 'Deployment failed. Rollback triggered.'}
</p>
<ProgressBar
value={completedCount / SAGA_STEPS.length}
intent={finalStatus === 'failed' ? Intent.DANGER : finalStatus === 'complete' ? Intent.SUCCESS : Intent.PRIMARY}
animate={finalStatus === 'running'}
stripes={finalStatus === 'running'}
style={{ marginBottom: '1.5rem' }}
/>
<Tabs id="deploy-tabs" renderActiveTabPanelOnly={false}>
<Tab id="progress" title="Progress" panel={progressPanel} />
<Tab
id="diagnostics"
title={
<span>
Diagnostics
{diagnostics.length > 0 && (
<Tag intent={Intent.DANGER} minimal round style={{ marginLeft: 6 }}>
{diagnostics.length}
</Tag>
)}
</span>
}
panel={diagnosticsPanel}
/>
</Tabs>
{finalStatus === 'complete' && (
<Callout intent={Intent.SUCCESS} title="Client Provisioned" style={{ marginTop: '1rem' }}>
<p style={{ marginBottom: '0.75rem' }}>
Tenant <strong>{subdomain}</strong> is live. The day-zero admin has been set up in Keycloak.
</p>
<AnchorButton
intent={Intent.SUCCESS}
icon="share"
text={`Open ${subdomain}.clarity.test`}
href={clientUrl}
target="_blank"
rel="noopener noreferrer"
/>
</Callout>
)}
{finalStatus === 'failed' && (
<Callout intent={Intent.DANGER} title="Deployment Failed" style={{ marginTop: '1rem' }}>
A compensating rollback has been triggered. Check the Diagnostics tab for the full stack trace.
</Callout>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { Callout, HTMLTable, Intent, Tag } from '@blueprintjs/core';
import { tenantUrl } from '../../config';
import type { ProvisioningRequest } from '../../types/provisioning';
interface Props {
signalParent: (state: { isValid: boolean }) => void;
data: ProvisioningRequest;
}
export default function ReviewStep({ data }: Props) {
const clientUrl = tenantUrl(data.subdomain);
const containerName = data.subdomain;
return (
<div className="wizard-step">
<p className="step-description">Confirm the details below before deploying.</p>
<HTMLTable striped className="review-table">
<tbody>
<tr><td>Client Name</td><td><strong>{data.clientName}</strong></td></tr>
<tr><td>State Code</td><td><Tag intent={Intent.PRIMARY} round>{data.stateCode}</Tag></td></tr>
<tr><td>Site Code</td><td><code>{data.siteCode}</code></td></tr>
<tr><td>Environment</td><td><Tag intent={data.environment === 'prod' ? Intent.DANGER : data.environment === 'uat' ? Intent.WARNING : Intent.PRIMARY} round>{data.environment}</Tag></td></tr>
<tr><td>Container Name</td><td><code>{containerName}</code></td></tr>
<tr><td>Client URL</td><td><code style={{ fontSize: '0.9em' }}>{clientUrl}</code></td></tr>
<tr><td>Admin Email</td><td>{data.adminEmail}</td></tr>
<tr><td>Tier</td><td><Tag intent={data.tier === 'Dedicated' ? Intent.DANGER : data.tier === 'Isolated' ? Intent.WARNING : Intent.NONE} round>{data.tier}</Tag></td></tr>
</tbody>
</HTMLTable>
<Callout intent={Intent.WARNING} title="This provisions real infrastructure" style={{ marginTop: '1.5rem' }}>
Clicking Deploy will start a <code>{containerName}</code> Docker container running Clarity.Server,
create a Keycloak realm, unseal Vault, and register the subdomain route in the Gateway.
A compensating rollback will trigger automatically on failure.
</Callout>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Central runtime configuration for the Control Plane UI.
* Override defaults via Vite env vars in .env.local:
* VITE_CLARITY_DOMAIN=clarity.test
*/
export const CLARITY_DOMAIN: string =
import.meta.env.VITE_CLARITY_DOMAIN ?? 'clarity.test';
/** Builds the public HTTPS URL for a provisioned tenant subdomain. */
export const tenantUrl = (subdomain: string): string =>
`https://${subdomain}.${CLARITY_DOMAIN}`;
+928
View File
@@ -0,0 +1,928 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
background: #f4f6f9;
color: #1c2127;
-webkit-font-smoothing: antialiased;
}
/* ═══════════════════════════════════════════
Shell — sidebar + main
═══════════════════════════════════════════ */
.cp-shell {
display: flex;
min-height: 100vh;
}
/* ── Sidebar ── */
.cp-sidebar {
width: 240px;
flex-shrink: 0;
background: #1a2332;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.cp-sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 1.4rem 1.25rem 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.07);
}
.brand-mark {
width: 32px;
height: 32px;
background: #215db0;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.brand-name {
font-size: 0.875rem;
font-weight: 600;
color: #e5e8eb;
letter-spacing: -0.01em;
}
.cp-sidebar-nav {
flex: 1;
padding: 0.5rem 0.5rem;
overflow-y: auto;
}
/* Make Blueprint Menu transparent on dark sidebar */
.cp-sidebar-menu.bp5-menu {
background: transparent;
padding: 0;
}
/* Nav items */
.cp-sidebar-menu .bp5-menu-item {
color: #8f99a8;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
.cp-sidebar-menu .bp5-menu-item:hover {
background: rgba(255,255,255,0.06);
color: #d3d8de;
}
.cp-sidebar-menu .bp5-menu-item.bp5-active,
.cp-sidebar-menu .bp5-menu-item.bp5-intent-primary {
background: rgba(33, 93, 176, 0.35) !important;
color: #fff !important;
}
.cp-sidebar-menu .bp5-menu-item .bp5-icon {
color: inherit;
opacity: 0.8;
}
.cp-sidebar-menu .bp5-menu-item.bp5-active .bp5-icon {
opacity: 1;
}
/* Divider */
.cp-sidebar-menu .bp5-menu-divider {
border-color: rgba(255,255,255,0.07);
margin: 0.25rem 0;
}
.cp-sidebar-footer {
padding: 0.85rem 1rem;
border-top: 1px solid rgba(255,255,255,0.07);
}
.cp-sidebar-user {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: #215db0;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
display: grid;
place-items: center;
flex-shrink: 0;
}
.user-info { display: flex; flex-direction: column; }
.user-name { font-size: 0.8rem; font-weight: 600; color: #d3d8de; }
.user-role { font-size: 0.7rem; color: #5f6b7c; }
/* ── Main content area ── */
.cp-main {
flex: 1;
padding: 2rem 2.5rem;
min-width: 0;
}
/* ── Page header ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
gap: 1rem;
}
.page-header h1 {
font-size: 1.4rem;
font-weight: 700;
color: #1c2127;
letter-spacing: -0.02em;
margin-bottom: 0.2rem;
}
.page-header p {
font-size: 0.875rem;
color: #738091;
}
/* ═══════════════════════════════════════════
Job cards
═══════════════════════════════════════════ */
.job-list { display: flex; flex-direction: column; gap: 0.75rem; }
.job-card {
background: #fff;
border: 1px solid #e5e8eb;
border-radius: 10px;
padding: 1.1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: box-shadow 0.15s, border-color 0.15s;
}
.job-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
border-color: #c5cbd3;
}
.job-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.job-card-header strong { font-size: 0.95rem; display: block; }
.job-card-subdomain {
font-size: 0.78rem;
color: #738091;
display: block;
margin-top: 1px;
}
.job-card-meta {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: #8f99a8;
}
/* ── Empty state ── */
.empty-state {
background: #fff;
border: 1px dashed #d3d8de;
border-radius: 10px;
padding: 4rem 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.empty-state-icon { font-size: 2.5rem; }
.empty-state h3 { font-size: 1rem; font-weight: 600; color: #1c2127; }
.empty-state p { font-size: 0.875rem; color: #738091; margin-bottom: 0.5rem; }
/* ═══════════════════════════════════════════
Wizard
═══════════════════════════════════════════ */
.wizard-progress {
display: flex;
align-items: center;
gap: 0;
padding: 1rem 1.5rem 0;
border-bottom: 1px solid #e5e8eb;
margin-bottom: 0;
}
.wizard-progress-step {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding-bottom: 0.85rem;
font-size: 0.8rem;
font-weight: 500;
color: #8f99a8;
border-bottom: 2px solid transparent;
}
.wizard-progress-step.active {
color: #215db0;
border-bottom-color: #215db0;
}
.wizard-progress-step.done { color: #1c6e42; }
.wizard-progress-dot {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e5e8eb;
color: #738091;
font-size: 0.7rem;
font-weight: 700;
display: grid;
place-items: center;
flex-shrink: 0;
}
.wizard-progress-step.active .wizard-progress-dot {
background: #215db0;
color: #fff;
}
.wizard-progress-step.done .wizard-progress-dot {
background: #1c6e42;
color: #fff;
}
.wizard-step { padding: 0.25rem 0; }
.step-description {
font-size: 0.875rem;
color: #738091;
margin-bottom: 1.5rem;
}
.wizard-step .bp5-form-group { margin-bottom: 1rem; }
.review-table { width: 100%; font-size: 0.875rem; }
.review-table td:first-child { width: 150px; color: #738091; padding-right: 1rem; padding-bottom: 0.6rem; }
.review-table td:last-child { font-weight: 500; }
.wizard-footer-actions { display: flex; gap: 8px; align-items: center; }
.wizard-error { color: #c23030; font-size: 0.85rem; margin-top: 0.5rem; }
/* ── Wizard as inline page ── */
.wizard-page {
display: flex;
flex-direction: column;
height: 100%;
}
.wizard-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.wizard-page-title h2 {
font-size: 1.4rem;
font-weight: 700;
color: #1c2127;
letter-spacing: -0.02em;
margin-bottom: 0.2rem;
}
.wizard-page-title p {
font-size: 0.875rem;
color: #738091;
}
.wizard-page-body {
flex: 1;
max-width: 640px;
padding-top: 1.5rem;
}
.wizard-page-footer {
display: flex;
align-items: center;
gap: 8px;
padding-top: 1.5rem;
margin-top: 1.5rem;
border-top: 1px solid #e5e8eb;
max-width: 640px;
}
/* ── Tier cards ── */
.tier-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tier-card {
width: 100%;
text-align: left;
background: #fff;
border: 2px solid #e5e8eb;
border-radius: 10px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.tier-card:hover {
border-color: #215db0;
box-shadow: 0 2px 8px rgba(33,93,176,0.1);
}
.tier-card.selected {
border-color: #215db0;
background: #f0f4ff;
box-shadow: 0 2px 8px rgba(33,93,176,0.12);
}
.tier-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.35rem;
}
.tier-card-label {
font-size: 0.95rem;
font-weight: 600;
color: #1c2127;
}
.tier-card-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.tier-badge-shared { background: #e5e8eb; color: #5f6b7c; }
.tier-badge-isolated { background: #fef3c7; color: #92400e; }
.tier-badge-dedicated { background: #fee2e2; color: #991b1b; }
.tier-card-description {
font-size: 0.8rem;
color: #738091;
line-height: 1.5;
margin: 0;
}
/* ═══════════════════════════════════════════
OPC — Online Project Communication
═══════════════════════════════════════════ */
/* Filter bar */
.opc-filter-bar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.opc-count-badge {
margin-left: auto;
font-size: 0.78rem;
color: #738091;
font-weight: 500;
}
/* Table */
.opc-table.bp5-html-table {
width: 100%;
background: #fff;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e5e8eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.opc-table.bp5-html-table thead tr th {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #738091;
padding: 0.6rem 0.85rem;
background: #f8f9fb;
border-bottom: 1px solid #e5e8eb;
}
.opc-table.bp5-html-table tbody tr td {
padding: 0.65rem 0.85rem;
vertical-align: middle;
}
.opc-table.bp5-html-table.bp5-interactive tbody tr:hover td {
cursor: pointer;
background: #f0f4ff;
}
.opc-row-selected td {
background: #e8f0fb !important;
}
.opc-number-chip {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.78rem;
color: #215db0;
background: #e8f0fb;
padding: 2px 7px;
border-radius: 4px;
white-space: nowrap;
}
.opc-title-cell {
font-weight: 500;
max-width: 340px;
}
.opc-date-cell {
font-size: 0.78rem;
color: #738091;
white-space: nowrap;
}
/* Drawer body */
.opc-drawer-body {
padding: 1.25rem 1.5rem;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Meta strip */
.opc-meta-strip {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.opc-meta-label {
font-size: 0.75rem;
font-weight: 600;
color: #738091;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Tab panel */
.opc-tab-panel {
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Field display */
.opc-field-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #738091;
margin-bottom: 0.2rem;
}
.opc-field-value {
font-size: 0.875rem;
color: #1c2127;
}
.opc-field-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 0.5rem;
}
.opc-description {
font-size: 0.875rem;
color: #1c2127;
line-height: 1.65;
white-space: pre-wrap;
margin: 0;
}
/* Notes */
.opc-notes-feed {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 60px;
}
.opc-note-card {
background: #f6f7f9;
border: 1px solid #e5e8eb;
border-radius: 8px;
padding: 0.8rem 1rem;
}
.opc-note-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.opc-note-author {
font-size: 0.8rem;
font-weight: 600;
color: #1c2127;
}
.opc-note-time {
font-size: 0.73rem;
color: #738091;
}
.opc-note-content {
font-size: 0.875rem;
color: #1c2127;
line-height: 1.6;
white-space: pre-wrap;
}
.opc-note-compose {
border-top: 1px solid #e5e8eb;
padding-top: 1rem;
display: flex;
flex-direction: column;
}
/* Commits */
.opc-commits-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.opc-commit-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
background: #f6f7f9;
border: 1px solid #e5e8eb;
border-radius: 6px;
}
.opc-commit-hash {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.75rem;
color: #215db0;
background: #e8f0fb;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
align-self: center;
}
.opc-commit-info { flex: 1; }
.opc-commit-msg { font-size: 0.82rem; font-weight: 500; color: #1c2127; }
.opc-commit-meta { font-size: 0.72rem; color: #738091; margin-top: 2px; }
.opc-commit-files {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.35rem;
}
.opc-commit-files span {
font-size: 0.68rem;
font-family: 'Consolas', monospace;
background: #e5e8eb;
color: #5f6b7c;
padding: 1px 5px;
border-radius: 3px;
}
/* Drawer form actions */
.opc-drawer-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e8eb;
margin-top: auto;
}
/* Inline editable description */
.opc-editable-desc {
font-size: 0.875rem !important;
color: #1c2127;
line-height: 1.65;
white-space: pre-wrap;
min-height: 60px;
width: 100%;
border-radius: 4px;
padding: 4px 6px;
}
.opc-editable-desc.bp5-editable-text-editing {
box-shadow: 0 0 0 1px #215db0, 0 0 0 3px rgba(33,93,176,0.2);
}
/* AI assist box */
.opc-ai-box {
background: #faf7ff;
border: 1px dashed #c4b5fd;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-top: 0.25rem;
}
.opc-ai-label { margin-bottom: 0.4rem; }
.opc-ai-input-row { display: flex; gap: 0.5rem; align-items: center; }
.opc-ai-result {
margin-top: 0.75rem;
background: #fff;
border: 1px solid #e5e8eb;
border-radius: 6px;
overflow: hidden;
}
.opc-ai-result-text {
font-size: 0.8rem;
color: #1c2127;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.75rem 1rem;
max-height: 220px;
overflow-y: auto;
line-height: 1.65;
}
.opc-ai-result-actions {
display: flex;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid #e5e8eb;
background: #f8f9fb;
}
/* Artifact panel */
.opc-artifact-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.opc-artifact-card {
background: #fff;
border: 1px solid #e5e8eb;
border-radius: 8px;
padding: 0.9rem 1rem;
}
.opc-artifact-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.opc-artifact-body {
font-size: 0.82rem;
color: #1c2127;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
max-height: 300px;
overflow-y: auto;
line-height: 1.6;
}
.opc-artifact-meta {
font-size: 0.7rem;
color: #738091;
margin-top: 0.5rem;
}
.opc-artifact-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Branch coverage */
.opc-branch-coverage {
background: #f8f9fb;
border: 1px solid #e5e8eb;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.opc-branch-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
/* Commits section labels */
.opc-commits-section-label {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.opc-section-hint {
font-size: 0.8rem;
color: #738091;
}
.opc-empty-hint {
font-size: 0.82rem;
color: #738091;
margin: 0 0 0.5rem;
}
/* ── Git Commit Drawer ──────────────────────────────────────────────────────── */
.git-commit-drawer .bp5-drawer-header {
padding: 0.75rem 1rem;
}
.git-drawer-title {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.git-drawer-hash {
font-size: 0.78rem;
background: #e8edf2;
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
color: #215db0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.git-drawer-subject {
font-size: 0.92rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1c2127;
}
.git-drawer-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.git-drawer-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #d3d8de;
display: flex;
justify-content: flex-end;
}
.git-commit-meta-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
}
.git-commit-meta-left {
display: flex;
align-items: center;
gap: 0.6rem;
}
.git-commit-meta-right {
display: flex;
align-items: center;
gap: 0.4rem;
}
.git-commit-hash-chip {
font-size: 0.78rem;
background: #dbe7ff;
color: #215db0;
border-radius: 4px;
padding: 2px 7px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
transition: background 0.15s;
}
.git-commit-hash-chip:hover {
background: #c3d6ff;
}
.git-commit-author {
font-size: 0.83rem;
font-weight: 600;
color: #1c2127;
}
.git-commit-date {
font-size: 0.8rem;
color: #738091;
}
.git-commit-body {
font-size: 0.82rem;
color: #404854;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Inter', system-ui, sans-serif;
}
.git-diff-container {
font-size: 0.78rem;
line-height: 1.45;
border-radius: 6px;
overflow: hidden;
border: 1px solid #d0d7de;
}
/* Tune diff2html table to fit drawer width */
.git-diff-container .d2h-wrapper {
overflow-x: auto;
}
.git-diff-container .d2h-file-header {
background: #f0f3f6;
font-size: 0.78rem;
}
.git-diff-container .d2h-code-line {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.75rem;
}
.git-diff-container .d2h-ins {
background: #e6ffec;
}
.git-diff-container .d2h-del {
background: #ffebe9;
}
.git-diff-container .d2h-ins > td.d2h-code-linenumber {
background: #ccffd8;
color: #196c2e;
}
.git-diff-container .d2h-del > td.d2h-code-linenumber {
background: #ffd7d5;
color: #82071e;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+813
View File
@@ -0,0 +1,813 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { GitCommitDrawer } from '../components/GitCommitDrawer';
import {
Button, Callout, Divider, Drawer, FormGroup,
HTMLSelect, HTMLTable, InputGroup, Intent,
NonIdealState, Spinner, Tab, Tabs, Tag, TextArea, Tooltip,
EditableText,
} from '@blueprintjs/core';
import type { Opc, OpcArtifact, OpcNote, OpcPriority, OpcStatus, OpcType, ArtifactType } from '../types/opc';
import {
listOpcs, createOpc, updateOpc, getNextNumber,
listNotes, addNote,
listArtifacts, createArtifact, updateArtifact, deleteArtifact,
getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverage,
listGiteaBranches, createGiteaBranch,
aiAssist,
type LinkedCommit, type PinnedCommit, type BranchCoverage, type GiteaBranch,
} from '../api/opcApi';
// -- Label / intent maps -------------------------------------------------------
const TYPE_LABELS: Record<OpcType, string> = {
ChangeOrder: 'Change Order',
NonDevTask: 'Non-Dev Task',
QaTask: 'QA Task',
BusinessRequirement: 'Business Req.',
Feature: 'Feature',
General: 'General',
};
const TYPE_INTENT: Record<OpcType, Intent> = {
ChangeOrder: Intent.PRIMARY,
NonDevTask: Intent.NONE,
QaTask: Intent.WARNING,
BusinessRequirement: Intent.SUCCESS,
Feature: Intent.SUCCESS,
General: Intent.NONE,
};
const STATUS_INTENT: Record<OpcStatus, Intent> = {
New: Intent.PRIMARY,
InProgress: Intent.WARNING,
InReview: Intent.PRIMARY,
Blocked: Intent.DANGER,
Closed: Intent.SUCCESS,
Cancelled: Intent.NONE,
};
const STATUS_LABELS: Record<OpcStatus, string> = {
New: 'New',
InProgress: 'In Progress',
InReview: 'In Review',
Blocked: 'Blocked',
Closed: 'Closed',
Cancelled: 'Cancelled',
};
const PRIORITY_INTENT: Record<OpcPriority, Intent> = {
Low: Intent.NONE,
Medium: Intent.WARNING,
High: Intent.DANGER,
Critical: Intent.DANGER,
};
const ARTIFACT_TABS: { type: ArtifactType; label: string; placeholder: string }[] = [
{ type: 'BusinessRequirement', label: 'Business Req.', placeholder: 'Document business requirements, JAD/JAR outputs, acceptance criteria...' },
{ type: 'Rule', label: 'Rules', placeholder: 'Define rule engine rules, validation logic, business rules...' },
{ type: 'Spec', label: 'Spec', placeholder: 'Technical specification — data contracts, API shapes, architecture decisions...' },
{ type: 'Documentation', label: 'Docs', placeholder: 'End-user or developer documentation for this change...' },
{ type: 'QaTestPath', label: 'QA Test Paths', placeholder: 'Step-by-step QA test scenarios, edge cases, regression checks...' },
];
// -- Helpers -------------------------------------------------------------------
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// -- AI Assist Box -------------------------------------------------------------
function AiAssistBox({ context, onApply }: { context?: string; onApply: (text: string) => void }) {
const [prompt, setPrompt] = useState('');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async () => {
if (!prompt.trim()) return;
setLoading(true); setError(null); setResult('');
try {
const text = await aiAssist(prompt.trim(), context);
setResult(text);
} catch (e) { setError(String(e)); }
finally { setLoading(false); }
};
return (
<div className="opc-ai-box">
<div className="opc-ai-label">
<span className="opc-field-label" style={{ color: '#7c3aed' }}>AI Assist</span>
</div>
<div className="opc-ai-input-row">
<InputGroup
placeholder="Ask AI to draft content (e.g. 'Write acceptance criteria for...')"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); run(); } }}
rightElement={
<Tooltip content="Send prompt" placement="top">
<Button icon="arrow-up" minimal intent={Intent.PRIMARY} loading={loading}
onClick={run} disabled={!prompt.trim() || loading} />
</Tooltip>
}
/>
</div>
{error && <Callout intent={Intent.DANGER} icon="error" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>{error}</Callout>}
{result && (
<div className="opc-ai-result">
<pre className="opc-ai-result-text">{result}</pre>
<div className="opc-ai-result-actions">
<Button icon="arrow-up" intent={Intent.PRIMARY} text="Copy to field above" small
onClick={() => { onApply(result); setResult(''); setPrompt(''); }} />
<Button icon="cross" text="Dismiss" small minimal onClick={() => setResult('')} />
</div>
</div>
)}
</div>
);
}
// -- Artifact panel ------------------------------------------------------------
function ArtifactPanel({ opcId, opcNumber, artifactType, placeholder }: {
opcId: string; opcNumber: string; artifactType: ArtifactType; placeholder: string;
}) {
const [artifacts, setArtifacts] = useState<OpcArtifact[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editBody, setEditBody] = useState('');
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try { setArtifacts(await listArtifacts(opcId, artifactType)); }
catch { /* API may not be up */ }
finally { setLoading(false); }
}, [opcId, artifactType]);
useEffect(() => { load(); }, [load]);
const startAdd = () => { setEditId(null); setEditTitle(''); setEditBody(''); setAdding(true); };
const startEdit = (a: OpcArtifact) => { setAdding(false); setEditId(a.id); setEditTitle(a.title); setEditBody(a.content); };
const cancel = () => { setAdding(false); setEditId(null); };
const save = async () => {
setSaving(true);
try {
if (editId) {
const u = await updateArtifact(editId, { artifactType, title: editTitle, content: editBody });
setArtifacts(prev => prev.map(a => a.id === editId ? u : a));
} else {
const c = await createArtifact(opcId, { artifactType, title: editTitle, content: editBody });
setArtifacts(prev => [...prev, c]);
}
cancel();
} catch { /* no-op */ }
finally { setSaving(false); }
};
const remove = async (id: string) => {
await deleteArtifact(id);
setArtifacts(prev => prev.filter(a => a.id !== id));
};
const isEditing = adding || editId !== null;
if (loading) return <div style={{ padding: '2rem', textAlign: 'center' }}><Spinner size={20} /></div>;
return (
<div className="opc-artifact-panel">
{!isEditing && (
<>
{artifacts.length === 0 ? (
<NonIdealState icon="document" title="Nothing here yet"
description={`Add the first entry for ${opcNumber}.`}
action={<Button icon="plus" intent={Intent.PRIMARY} text="Add Entry" onClick={startAdd} />} />
) : (
<>
{artifacts.map(a => (
<div key={a.id} className="opc-artifact-card">
<div className="opc-artifact-card-header">
<strong>{a.title || '(untitled)'}</strong>
<div style={{ display: 'flex', gap: '0.3rem' }}>
<Button icon="edit" minimal small onClick={() => startEdit(a)} />
<Button icon="trash" minimal small intent={Intent.DANGER} onClick={() => remove(a.id)} />
</div>
</div>
<pre className="opc-artifact-body">{a.content}</pre>
<div className="opc-artifact-meta">{fmtDate(a.updatedAt)}</div>
</div>
))}
<Button icon="plus" text="Add Entry" minimal intent={Intent.PRIMARY} onClick={startAdd} style={{ marginTop: '0.5rem' }} />
</>
)}
</>
)}
{isEditing && (
<div className="opc-artifact-form">
<FormGroup label="Title">
<InputGroup placeholder="Brief title" value={editTitle}
onChange={e => setEditTitle(e.target.value)} autoFocus />
</FormGroup>
<FormGroup label="Content">
<TextArea fill rows={10} placeholder={placeholder} value={editBody}
onChange={e => setEditBody(e.target.value)} />
</FormGroup>
<AiAssistBox context={editBody || undefined} onApply={t => setEditBody(prev => prev ? `${prev}\n\n${t}` : t)} />
<div className="opc-drawer-actions" style={{ paddingTop: '0.75rem' }}>
<Button text="Cancel" onClick={cancel} minimal />
<Button intent={Intent.PRIMARY} text="Save" loading={saving}
onClick={save} disabled={!editBody.trim()} />
</div>
</div>
)}
</div>
);
}
// -- Commit row (shared) -------------------------------------------------------
function CommitRow({ commit, onPin, isPinned, onViewDiff }: { commit: LinkedCommit; onPin?: () => void; isPinned?: boolean; onViewDiff?: (hash: string) => void }) {
return (
<div className="opc-commit-row">
<Tooltip content="View diff" placement="left">
<code className="opc-commit-hash" style={{ cursor: 'pointer' }}
onClick={() => onViewDiff ? onViewDiff(commit.hash) : navigator.clipboard.writeText(commit.hash)}>
{commit.shortHash}
</code>
</Tooltip>
<div className="opc-commit-info">
<div className="opc-commit-msg">{commit.subject}</div>
<div className="opc-commit-meta">{commit.author} · {commit.date}</div>
{commit.files.length > 0 && (
<div className="opc-commit-files">
{commit.files.slice(0, 5).map(f => <span key={f}>{f}</span>)}
{commit.files.length > 5 && <span>+{commit.files.length - 5} more</span>}
</div>
)}
</div>
{onPin && (
<Tooltip content={isPinned ? 'Locked in' : 'Lock in this commit'} placement="left">
<Button
icon={isPinned ? 'lock' : 'pin'}
minimal small
intent={isPinned ? Intent.SUCCESS : Intent.NONE}
disabled={isPinned}
style={{ flexShrink: 0, alignSelf: 'center' }}
onClick={onPin}
/>
</Tooltip>
)}
</div>
);
}
// -- Commits tab ---------------------------------------------------------------
function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
const [autoCommits, setAutoCommits] = useState<LinkedCommit[]>([]);
const [pinned, setPinned] = useState<PinnedCommit[]>([]);
const [coverage, setCoverage] = useState<BranchCoverage[]>([]);
const [loaded, setLoaded] = useState(false);
const [pinInput, setPinInput] = useState('');
const [pinning, setPinning] = useState(false);
const [pinError, setPinError] = useState<string | null>(null);
const [viewingHash, setViewingHash] = useState<string | null>(null);
// undefined = not yet loaded, null = not found, GiteaBranch = found
const [linkedBranch, setLinkedBranch] = useState<GiteaBranch | null | undefined>(undefined);
const [creatingBranch, setCreatingBranch] = useState(false);
const [branchError, setBranchError] = useState<string | null>(null);
useEffect(() => {
if (!isActive || loaded) return;
(async () => {
try {
const [auto, pins] = await Promise.all([
getLinkedCommits(opc.number),
getPinnedCommits(opc.id),
]);
setAutoCommits(auto);
setPinned(pins);
const allHashes = [...new Set([...auto.map(c => c.hash), ...pins.map(c => c.hash)])];
if (allHashes.length > 0) setCoverage(await getBranchCoverage(allHashes));
} catch { /* non-critical — API may not be up */ }
finally { setLoaded(true); }
})();
// Load Gitea branch independently — don't block commit rendering
const opcTag = opc.number.replace('OPC # ', 'OPC-');
listGiteaBranches()
.then(branches => {
const found = branches.find(b => b.name.includes(opcTag));
setLinkedBranch(found ?? null);
})
.catch(() => setLinkedBranch(null));
}, [isActive, loaded, opc.id, opc.number]);
const handleCreateBranch = async () => {
setCreatingBranch(true); setBranchError(null);
try {
const branch = await createGiteaBranch(opc.number, opc.title);
setLinkedBranch(branch);
} catch (e) { setBranchError(String(e)); }
finally { setCreatingBranch(false); }
};
const handlePin = async () => {
if (!pinInput.trim()) return;
setPinning(true); setPinError(null);
try {
const c = await pinCommit(opc.id, pinInput.trim(), 'amadzarak');
setPinned(prev => [...prev, c]);
setPinInput('');
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
setCoverage(await getBranchCoverage(allHashes));
} catch (e) { setPinError(String(e)); }
finally { setPinning(false); }
};
const handleLockIn = async (commit: LinkedCommit) => {
try {
const c = await pinCommit(opc.id, commit.hash, 'amadzarak');
setPinned(prev => [...prev, c]);
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
setCoverage(await getBranchCoverage(allHashes));
} catch { /* no-op */ }
};
const handleUnpin = async (hash: string) => {
try {
await unpinCommit(opc.id, hash);
setPinned(prev => prev.filter(c => c.hash !== hash));
} catch { /* no-op */ }
};
if (!loaded) return <NonIdealState icon={<Spinner size={20} />} title="Scanning commits..." />;
return (
<div className="opc-tab-panel">
{/* Git branch */}
<div style={{ marginBottom: '0.75rem' }}>
<div className="opc-field-label" style={{ marginBottom: '0.4rem' }}>Git Branch</div>
{linkedBranch === undefined ? (
<Spinner size={14} />
) : linkedBranch !== null ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<Tag icon="git-branch" minimal round intent={Intent.PRIMARY}
style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{linkedBranch.name}
</Tag>
<a
href={`https://opc.clarity.test/Clarity/Clarity/src/branch/${encodeURIComponent(linkedBranch.name)}`}
target="_blank" rel="noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}>
Open in Gitea
</a>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span className="opc-empty-hint" style={{ margin: 0 }}>No branch yet</span>
<Button
icon="git-branch"
intent={Intent.PRIMARY}
text="Create Branch"
small
loading={creatingBranch}
onClick={handleCreateBranch}
/>
</div>
)}
{branchError && (
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
{branchError}
</Callout>
)}
</div>
<Divider style={{ margin: '0.5rem 0 0.75rem' }} />
{/* Branch coverage */}
{coverage.length > 0 && (
<div className="opc-branch-coverage">
<div className="opc-field-label" style={{ marginBottom: '0.5rem' }}>Branch Coverage</div>
<div className="opc-branch-chips">
{coverage.map(b => (
<Tooltip key={b.branch}
content={b.contains
? `All linked commits reachable from ${b.branch}`
: `Not all linked commits have reached ${b.branch} yet`}>
<Tag intent={b.contains ? Intent.SUCCESS : Intent.NONE}
icon={b.contains ? 'tick-circle' : 'minus'}
minimal={!b.contains} round>
{b.branch}{b.isHead ? ' ★' : ''}
</Tag>
</Tooltip>
))}
</div>
</div>
)}
{/* Auto-detected */}
<div className="opc-commits-section-label">
<span className="opc-field-label">Auto-linked</span>
<span className="opc-section-hint"> commits with <code>{opc.number}</code> in the message</span>
</div>
{autoCommits.length === 0
? <p className="opc-empty-hint">None found include <strong>{opc.number}</strong> in your commit message.</p>
: <div className="opc-commits-list">{autoCommits.map(c => <CommitRow key={c.hash} commit={c} onPin={() => handleLockIn(c)} isPinned={pinned.some(p => p.hash === c.hash)} onViewDiff={setViewingHash} />)}</div>
}
<Divider />
{/* Pinned */}
<div className="opc-commits-section-label">
<span className="opc-field-label">Pinned</span>
<span className="opc-section-hint"> manually linked commits</span>
</div>
{pinned.length === 0
? <p className="opc-empty-hint">No commits pinned yet.</p>
: (
<div className="opc-commits-list">
{pinned.map(c => (
<div key={c.hash} className="opc-commit-row">
<Tooltip content="View diff" placement="left">
<code className="opc-commit-hash" style={{ cursor: 'pointer' }}
onClick={() => setViewingHash(c.hash)}>
{c.shortHash}
</code>
</Tooltip>
<div className="opc-commit-info">
<div className="opc-commit-msg">{c.subject || c.hash}</div>
<div className="opc-commit-meta">{c.author} · pinned by {c.pinnedBy}</div>
</div>
<Button icon="unpin" minimal small intent={Intent.DANGER}
style={{ flexShrink: 0, alignSelf: 'center' }}
onClick={() => handleUnpin(c.hash)} />
</div>
))}
</div>
)
}
<Divider />
{/* Pin input */}
<div className="opc-field-label" style={{ marginBottom: '0.4rem' }}>Pin a commit</div>
{pinError && <Callout intent={Intent.DANGER} style={{ marginBottom: '0.5rem', fontSize: '0.8rem' }}>{pinError}</Callout>}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<InputGroup fill placeholder="Enter commit hash (full or short)"
value={pinInput} onChange={e => setPinInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handlePin(); }} />
<Button intent={Intent.PRIMARY} text="Pin" loading={pinning}
disabled={!pinInput.trim()} onClick={handlePin} />
</div>
<GitCommitDrawer hash={viewingHash} onClose={() => setViewingHash(null)} />
</div>
);
}
// -- Detail drawer -------------------------------------------------------------
function OpcDetailDrawer({ opc, onClose, onUpdate }: {
opc: Opc; onClose: () => void; onUpdate: (updated: Opc) => void;
}) {
const [notes, setNotes] = useState<OpcNote[]>([]);
const [notesLoaded, setNotesLoaded] = useState(false);
const [noteText, setNoteText] = useState('');
const [savingNote, setSavingNote] = useState(false);
const [editDesc, setEditDesc] = useState(opc.description);
const [activeTab, setActiveTab] = useState('details');
const handleStatusChange = async (s: OpcStatus) => {
const updated = await updateOpc(opc.id, { status: s });
onUpdate({ ...opc, ...updated });
};
const handleDescConfirm = async (val: string) => {
const updated = await updateOpc(opc.id, { description: val });
onUpdate({ ...opc, ...updated });
};
const loadNotes = useCallback(async () => {
if (notesLoaded) return;
setNotes(await listNotes(opc.id));
setNotesLoaded(true);
}, [opc.id, notesLoaded]);
useEffect(() => { loadNotes(); }, [loadNotes]);
const handleAddNote = async () => {
if (!noteText.trim()) return;
setSavingNote(true);
try {
const note = await addNote(opc.id, 'amadzarak', noteText.trim());
setNotes(prev => [...prev, note]);
setNoteText('');
} finally { setSavingNote(false); }
};
const [copied, setCopied] = useState(false);
const commitRef = `${opc.number}: ${opc.title}`;
const handleCopy = () => {
navigator.clipboard.writeText(commitRef).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<Drawer isOpen onClose={onClose} size="760px"
title={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code className="opc-number-chip">{opc.number}</code>
<span style={{ fontWeight: 600, fontSize: '0.975rem' }}>{opc.title}</span>
<Tooltip content={copied ? 'Copied!' : 'Copy commit message'} placement="top">
<Button
icon={copied ? 'tick' : 'clipboard'}
minimal
small
intent={copied ? Intent.SUCCESS : Intent.NONE}
onClick={handleCopy}
style={{ marginLeft: '0.1rem' }}
/>
</Tooltip>
</span>
}
>
<div className="opc-drawer-body">
<div className="opc-meta-strip">
<Tag intent={TYPE_INTENT[opc.type]} minimal round>{TYPE_LABELS[opc.type]}</Tag>
<Tag intent={PRIORITY_INTENT[opc.priority]} minimal>{opc.priority}</Tag>
<div style={{ flex: 1 }} />
<span className="opc-meta-label">Status</span>
<HTMLSelect minimal value={opc.status}
onChange={e => handleStatusChange(e.target.value as OpcStatus)}
options={Object.entries(STATUS_LABELS).map(([v, l]) => ({ value: v, label: l }))} />
</div>
<Divider style={{ margin: '0.75rem 0' }} />
<Tabs animate selectedTabId={activeTab} onChange={id => setActiveTab(String(id))}>
{/* Details */}
<Tab id="details" title="Details" panel={
<div className="opc-tab-panel">
<div className="opc-field-label">Description</div>
<EditableText multiline minLines={3} maxLines={12} value={editDesc}
onChange={setEditDesc} onConfirm={handleDescConfirm}
placeholder="Click to add description..." className="opc-editable-desc" />
<AiAssistBox context={editDesc || opc.title}
onApply={t => setEditDesc(prev => prev ? `${prev}\n\n${t}` : t)} />
<div className="opc-field-grid">
<div><div className="opc-field-label">Assignee</div><div className="opc-field-value">{opc.assignee}</div></div>
<div><div className="opc-field-label">Created</div><div className="opc-field-value">{fmtDate(opc.createdAt)}</div></div>
<div><div className="opc-field-label">Updated</div><div className="opc-field-value">{fmtDate(opc.updatedAt)}</div></div>
</div>
</div>
} />
{/* Artifact tabs */}
{ARTIFACT_TABS.map(at => (
<Tab key={at.type} id={at.type} title={at.label} panel={
<div className="opc-tab-panel">
<ArtifactPanel opcId={opc.id} opcNumber={opc.number}
artifactType={at.type} placeholder={at.placeholder} />
</div>
} />
))}
{/* Notes */}
<Tab id="notes" title="Notes" panel={
<div className="opc-tab-panel">
<div className="opc-notes-feed">
{!notesLoaded
? <NonIdealState icon={<Spinner size={20} />} title="Loading..." />
: notes.length === 0
? <NonIdealState icon="comment" title="No notes yet" description="Add the first note below." />
: notes.map(n => (
<div key={n.id} className="opc-note-card">
<div className="opc-note-header">
<span className="opc-note-author">{n.author}</span>
<span className="opc-note-time">{fmtDateTime(n.timestamp)}</span>
</div>
<div className="opc-note-content">{n.content}</div>
</div>
))
}
</div>
<div className="opc-note-compose">
<TextArea fill rows={4} placeholder="Add a stakeholder note..."
value={noteText} onChange={e => setNoteText(e.target.value)} />
<AiAssistBox context={opc.description}
onApply={t => setNoteText(prev => prev ? `${prev}\n\n${t}` : t)} />
<Button intent={Intent.PRIMARY} text="Add Note" loading={savingNote}
style={{ marginTop: '0.5rem', alignSelf: 'flex-end' }}
onClick={handleAddNote} disabled={!noteText.trim()} />
</div>
</div>
} />
{/* Commits */}
<Tab id="commits" title="Commits" panel={
<CommitsTab opc={opc} isActive={activeTab === 'commits'} />
} />
</Tabs>
</div>
</Drawer>
);
}
// -- Create drawer -------------------------------------------------------------
interface CreateForm {
title: string; type: OpcType; priority: OpcPriority; assignee: string; description: string;
}
function OpcCreateDrawer({ onClose, onCreate }: { onClose: () => void; onCreate: (opc: Opc) => void }) {
const [form, setForm] = useState<CreateForm>({ title: '', type: 'General', priority: 'Medium', assignee: 'amadzarak', description: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextNumber, setNextNumber] = useState<string | null>(null);
useEffect(() => {
getNextNumber().then(setNextNumber).catch(() => { /* non-critical */ });
}, []);
const patch = <K extends keyof CreateForm>(key: K, value: CreateForm[K]) => setForm(f => ({ ...f, [key]: value }));
const applyAi = (t: string) => patch('description', form.description ? `${form.description}\n\n${t}` : t);
const handleCreate = async () => {
setSaving(true); setError(null);
try { onCreate(await createOpc(form)); }
catch (e) { setError(String(e)); }
finally { setSaving(false); }
};
return (
<Drawer isOpen onClose={onClose} size="520px"
title={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
{nextNumber
? <code className="opc-number-chip">{nextNumber}</code>
: <Spinner size={14} />}
<span style={{ fontWeight: 600 }}>New OPC</span>
</span>
}
>
<div className="opc-drawer-body">
<Callout intent={Intent.PRIMARY} icon="info-sign" style={{ marginBottom: '1.25rem' }}>
An OPC tracks a change, task, or business requirement through its full lifecycle.
{nextNumber && <> This will be saved as <strong>{nextNumber}</strong>.</>}{' '}
Include the OPC number in commit messages on <code>develop</code> to link check-ins automatically.
</Callout>
{error && <Callout intent={Intent.DANGER} style={{ marginBottom: '1rem' }}>{error}</Callout>}
<FormGroup label="Title" labelInfo="(required)">
<InputGroup placeholder="Short descriptive title" value={form.title}
onChange={e => patch('title', e.target.value)} autoFocus />
</FormGroup>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FormGroup label="Type">
<HTMLSelect fill value={form.type} onChange={e => patch('type', e.target.value as OpcType)}
options={[
{ value: 'ChangeOrder', label: 'Change Order' },
{ value: 'NonDevTask', label: 'Non-Dev Task' },
{ value: 'QaTask', label: 'QA Task' },
{ value: 'BusinessRequirement', label: 'Business Requirement (JAD / ARD)' },
{ value: 'Feature', label: 'Feature' },
{ value: 'General', label: 'General' },
]} />
</FormGroup>
<FormGroup label="Priority">
<HTMLSelect fill value={form.priority} onChange={e => patch('priority', e.target.value as OpcPriority)}
options={(['Low', 'Medium', 'High', 'Critical'] as OpcPriority[]).map(v => ({ value: v, label: v }))} />
</FormGroup>
</div>
<FormGroup label="Assignee">
<InputGroup value={form.assignee} onChange={e => patch('assignee', e.target.value)} />
</FormGroup>
<FormGroup label="Description">
<TextArea fill rows={6} placeholder="Goals, scope, acceptance criteria..."
value={form.description} onChange={e => patch('description', e.target.value)} />
</FormGroup>
<AiAssistBox context={form.title || undefined} onApply={applyAi} />
<div className="opc-drawer-actions">
<Button text="Cancel" onClick={onClose} minimal />
<Button intent={Intent.PRIMARY} text="Create OPC" loading={saving}
disabled={!form.title.trim() || saving} onClick={handleCreate} />
</div>
</div>
</Drawer>
);
}
// -- Main page -----------------------------------------------------------------
export default function OpcPage() {
const [opcs, setOpcs] = useState<Opc[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Opc | null>(null);
const [creating, setCreating] = useState(false);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const load = useCallback(async () => {
setLoading(true);
try { setOpcs(await listOpcs()); }
catch { /* API may not be reachable in dev */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return opcs.filter(o => {
if (typeFilter !== 'all' && o.type !== typeFilter) return false;
if (statusFilter !== 'all' && o.status !== statusFilter) return false;
if (q && !o.title.toLowerCase().includes(q) && !o.number.toLowerCase().includes(q)) return false;
return true;
});
}, [opcs, typeFilter, statusFilter, search]);
const handleCreated = (opc: Opc) => { setOpcs(prev => [opc, ...prev]); setCreating(false); setSelected(opc); };
const handleUpdated = (updated: Opc) => { setOpcs(prev => prev.map(o => o.id === updated.id ? updated : o)); setSelected(updated); };
return (
<div>
<div className="page-header">
<div>
<h1>OPC</h1>
<p>Online Project Communication change orders, tasks, and business requirements.</p>
</div>
<Button intent={Intent.PRIMARY} icon="plus" text="New OPC"
onClick={() => { setSelected(null); setCreating(true); }} />
</div>
<div className="opc-filter-bar">
<InputGroup leftIcon="search" placeholder="Search OPCs..." value={search}
onChange={e => setSearch(e.target.value)} style={{ width: 260 }} />
<HTMLSelect value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
options={[
{ value: 'all', label: 'All Types' },
{ value: 'ChangeOrder', label: 'Change Orders' },
{ value: 'NonDevTask', label: 'Non-Dev Tasks' },
{ value: 'QaTask', label: 'QA Tasks' },
{ value: 'BusinessRequirement', label: 'Business Requirements' },
{ value: 'Feature', label: 'Features' },
{ value: 'General', label: 'General' },
]} />
<HTMLSelect value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
options={[
{ value: 'all', label: 'All Statuses' },
...Object.entries(STATUS_LABELS).map(([v, l]) => ({ value: v, label: l })),
]} />
<Button icon="refresh" minimal onClick={load} />
<span className="opc-count-badge">{filtered.length} OPC{filtered.length !== 1 ? 's' : ''}</span>
</div>
{loading ? (
<NonIdealState icon={<Spinner />} title="Loading OPCs..." />
) : filtered.length === 0 ? (
<NonIdealState icon="search" title="No OPCs match" description="Adjust your filters or create a new OPC." />
) : (
<HTMLTable className="opc-table" interactive striped bordered>
<thead>
<tr>
<th>Number</th><th>Title</th><th>Type</th><th>Priority</th><th>Status</th><th>Assignee</th><th>Updated</th>
</tr>
</thead>
<tbody>
{filtered.map(o => (
<tr key={o.id} onClick={() => { setCreating(false); setSelected(o); }}
style={{ cursor: 'pointer' }} className={selected?.id === o.id ? 'opc-row-selected' : undefined}>
<td><code className="opc-number-chip">{o.number}</code></td>
<td className="opc-title-cell">{o.title}</td>
<td><Tag intent={TYPE_INTENT[o.type]} minimal round>{TYPE_LABELS[o.type]}</Tag></td>
<td><Tag intent={PRIORITY_INTENT[o.priority]} minimal>{o.priority}</Tag></td>
<td><Tag intent={STATUS_INTENT[o.status]} round>{STATUS_LABELS[o.status]}</Tag></td>
<td>{o.assignee}</td>
<td className="opc-date-cell">{fmtDate(o.updatedAt)}</td>
</tr>
))}
</tbody>
</HTMLTable>
)}
{selected && !creating && (
<OpcDetailDrawer opc={selected} onClose={() => setSelected(null)} onUpdate={handleUpdated} />
)}
{creating && (
<OpcCreateDrawer onClose={() => setCreating(false)} onCreate={handleCreated} />
)}
</div>
);
}
@@ -0,0 +1,381 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Callout, Intent, Tag, Spinner,
Dialog, DialogBody, DialogFooter,
HTMLTable, Collapse, Card, Elevation, TextArea,
} from '@blueprintjs/core';
import {
getLadderStatus, getPromotionHistory, triggerPromotion,
type BranchStatus, type PromotionRecord,
} from '../api/provisioningApi';
// ── Constants ─────────────────────────────────────────────────────────────────
const LADDER: { branch: string; label: string; env: string; intent: Intent }[] = [
{ branch: 'develop', label: 'Develop', env: 'fdev', intent: Intent.PRIMARY },
{ branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING },
{ branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER },
{ branch: 'master', label: 'Master', env: 'prod', intent: Intent.SUCCESS },
];
const STATUS_INTENT: Record<string, Intent> = {
Succeeded: Intent.SUCCESS,
Failed: Intent.DANGER,
Running: Intent.PRIMARY,
Pending: Intent.NONE,
};
// ── Promotion terminal ────────────────────────────────────────────────────────
function PromotionTerminal({ lines }: { lines: string[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
return (
<div ref={ref} style={{
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '0.75rem',
lineHeight: 1.6,
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75rem 1rem',
borderRadius: 6,
height: 300,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
border: '1px solid #30363d',
}}>
{lines.length === 0
? <span style={{ color: '#484f58' }}>Waiting for promotion output</span>
: lines.map((l, i) => {
const color = l.startsWith('✔') ? '#3fb950'
: l.startsWith('✖') ? '#f85149'
: l.startsWith('⚠') ? '#d29922'
: l.startsWith('──') ? '#484f58'
: undefined;
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
})
}
</div>
);
}
// ── Promote dialog ────────────────────────────────────────────────────────────
function PromoteDialog({
from, to, onClose, onDone,
}: {
from: string; to: string;
onClose: () => void;
onDone: () => void;
}) {
const [note, setNote] = useState('');
const [running, setRunning] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancelRef = useRef<(() => void) | null>(null);
const handlePromote = () => {
setRunning(true);
setLogs([]);
setError(null);
cancelRef.current = triggerPromotion(
from, to, 'control-plane', note || undefined,
(line) => setLogs((p) => [...p, line]),
() => { setRunning(false); setDone(true); onDone(); },
(err) => { setError(err); setRunning(false); },
);
};
const handleClose = () => {
cancelRef.current?.();
onClose();
};
const fromLabel = LADDER.find((l) => l.branch === from)?.label ?? from;
const toLabel = LADDER.find((l) => l.branch === to)?.label ?? to;
return (
<Dialog
isOpen
onClose={handleClose}
title={`Promote ${fromLabel}${toLabel}`}
style={{ width: 640 }}
>
<DialogBody>
{!running && !done && (
<>
<p style={{ marginBottom: '0.75rem', color: '#8f99a8' }}>
This will merge <code>{from}</code> into <code>{to}</code> with a
no-fast-forward commit and push to origin.
</p>
<TextArea
fill
rows={3}
placeholder="Optional note (e.g. sprint 12 release, hotfix for JIRA-123)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</>
)}
{(running || logs.length > 0) && (
<div style={{ marginTop: running && !logs.length ? 0 : '0.75rem' }}>
<PromotionTerminal lines={logs} />
</div>
)}
{error && (
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
)}
{done && (
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
Promotion complete. Tenants on <strong>{to}</strong> can now be released.
</Callout>
)}
</DialogBody>
<DialogFooter
minimal
actions={
done ? (
<Button intent={Intent.SUCCESS} text="Close" onClick={handleClose} />
) : (
<>
<Button text="Cancel" onClick={handleClose} disabled={running} />
<Button
intent={Intent.WARNING}
icon="arrow-right"
text={running ? 'Promoting…' : `Promote ${fromLabel}${toLabel}`}
loading={running}
disabled={running}
onClick={handlePromote}
/>
</>
)
}
/>
</Dialog>
);
}
// ── Branch ladder card ────────────────────────────────────────────────────────
function LadderCard({
status, nextBranch, onPromote,
}: {
status: BranchStatus;
nextBranch: string | null;
onPromote: (from: string, to: string) => void;
}) {
const [open, setOpen] = useState(false);
const meta = LADDER.find((l) => l.branch === status.branch)!;
return (
<Card elevation={Elevation.ONE} style={{ marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{/* Branch name + env badge */}
<div style={{ flex: '0 0 auto', minWidth: 120 }}>
<Tag intent={meta.intent} large minimal style={{ fontFamily: 'monospace', fontWeight: 600 }}>
{status.branch}
</Tag>
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#8f99a8' }}>
{meta.env}
</span>
</div>
{/* Last commit */}
<div style={{ flex: 1, minWidth: 0 }}>
{status.exists ? (
<span style={{ fontSize: '0.8rem', color: '#8f99a8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
<code style={{ color: '#4a90d9', marginRight: '0.4rem' }}>{status.shortHash}</code>
{status.lastCommitSummary}
</span>
) : (
<span style={{ fontSize: '0.8rem', color: '#484f58' }}>Branch does not exist yet</span>
)}
</div>
{/* Ahead badge + unreleased toggle */}
{status.exists && status.aheadOfNext > 0 && nextBranch && (
<Button
minimal small
intent={Intent.WARNING}
icon={open ? 'chevron-up' : 'layers'}
text={`${status.aheadOfNext} unreleased`}
onClick={() => setOpen((o) => !o)}
/>
)}
{status.exists && status.aheadOfNext === 0 && nextBranch && (
<Tag minimal intent={Intent.SUCCESS} icon="tick">In sync</Tag>
)}
{/* Promote button */}
{status.exists && nextBranch && (
<Button
intent={Intent.PRIMARY}
small
icon="arrow-right"
text={`Promote → ${nextBranch}`}
disabled={status.aheadOfNext === 0}
onClick={() => onPromote(status.branch, nextBranch)}
/>
)}
</div>
{/* Unreleased commit list */}
<Collapse isOpen={open && status.unreleasedLines.length > 0}>
<div style={{ marginTop: '0.75rem', paddingLeft: '0.5rem', borderLeft: '2px solid #30363d' }}>
{status.unreleasedLines.map((line, i) => {
const [hash, ...rest] = line.split(' ');
return (
<div key={i} style={{ fontSize: '0.78rem', marginBottom: '0.2rem' }}>
<code style={{ color: '#4a90d9', marginRight: '0.5rem' }}>{hash}</code>
<span style={{ color: '#c9d1d9' }}>{rest.join(' ')}</span>
</div>
);
})}
</div>
</Collapse>
</Card>
);
}
// ── History table ─────────────────────────────────────────────────────────────
function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
const [expanded, setExpanded] = useState<string | null>(null);
if (records.length === 0)
return <p style={{ color: '#8f99a8', fontSize: '0.85rem' }}>No promotions yet.</p>;
return (
<HTMLTable style={{ width: '100%', fontSize: '0.8rem' }}>
<thead>
<tr>
<th>Promotion</th>
<th>Status</th>
<th>Commits</th>
<th>By</th>
<th>When</th>
<th />
</tr>
</thead>
<tbody>
{records.map((r) => (
<>
<tr key={r.id}>
<td style={{ fontFamily: 'monospace' }}>{r.fromBranch} {r.toBranch}</td>
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal round>{r.status}</Tag></td>
<td style={{ color: '#8f99a8' }}>{r.commitCount}</td>
<td style={{ color: '#8f99a8' }}>{r.requestedBy}</td>
<td style={{ color: '#8f99a8' }}>{r.createdAt ? new Date(r.createdAt).toLocaleString() : '—'}</td>
<td>
<Button minimal small icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
onClick={() => setExpanded(expanded === r.id ? null : r.id)} />
</td>
</tr>
{expanded === r.id && (
<tr key={`${r.id}-log`}>
<td colSpan={6} style={{ padding: '0.5rem 1rem' }}>
{r.note && <p style={{ color: '#8f99a8', fontSize: '0.78rem', margin: '0 0 0.5rem' }}>Note: {r.note}</p>}
<PromotionTerminal lines={r.log} />
</td>
</tr>
)}
</>
))}
</tbody>
</HTMLTable>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function BranchPage() {
const [ladder, setLadder] = useState<BranchStatus[]>([]);
const [history, setHistory] = useState<PromotionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [l, h] = await Promise.all([getLadderStatus(), getPromotionHistory()]);
setLadder(l);
setHistory(h);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load');
} finally {
setLoading(false);
}
};
useEffect(() => { (async () => { await load(); })(); }, []);
return (
<>
<div className="page-header">
<div>
<h1>Branch Ladder</h1>
<p>Promote code through <code>develop staging uat main</code>. Developers merge to <code>develop</code>, Control Plane handles everything above.</p>
</div>
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
</div>
{error && (
<Callout intent={Intent.DANGER} title="Failed to load branch status" style={{ marginBottom: '1rem' }}>
{error}
</Callout>
)}
{loading && !ladder.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8' }}>
<Spinner size={16} /> Loading branch status
</div>
) : (
<>
{/* ── Ladder ── */}
<section style={{ marginBottom: '2rem' }}>
{ladder.map((s, i) => (
<LadderCard
key={s.branch}
status={s}
nextBranch={i + 1 < LADDER.length ? LADDER[i + 1].branch : null}
onPromote={(from, to) => setDialog({ from, to })}
/>
))}
</section>
{/* ── History ── */}
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Promotion History
</h3>
</div>
<PromotionHistoryTable records={history} />
</section>
</>
)}
{/* ── Promotion dialog ── */}
{dialog && (
<PromoteDialog
from={dialog.from}
to={dialog.to}
onClose={() => setDialog(null)}
onDone={() => { setDialog(null); load(); }}
/>
)}
</>
);
}
@@ -0,0 +1,303 @@
import { useEffect, useRef, useState } from 'react';
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';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
const KIND_INTENT: Record<string, Intent> = {
DotnetProject: Intent.PRIMARY,
NpmProject: Intent.WARNING,
DockerImage: Intent.NONE,
};
const STATUS_INTENT: Record<string, Intent> = {
Succeeded: Intent.SUCCESS,
Failed: Intent.DANGER,
Running: Intent.PRIMARY,
};
// ── Git history panel ─────────────────────────────────────────────────────────
function GitHistoryPanel({ relativePath }: { relativePath: string }) {
const [commits, setCommits] = useState<GitCommit[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [fetched, setFetched] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = async () => {
if (!open && !fetched) {
setLoading(true);
setError(null);
try {
const data = await getGitLog(relativePath, 10);
setCommits(data);
setFetched(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load git history');
} finally {
setLoading(false);
}
}
setOpen((o) => !o);
};
return (
<div style={{ marginTop: '0.5rem' }}>
<Button
minimal small
icon="git-branch"
text={open ? 'Hide history' : 'Git history'}
onClick={toggle}
loading={loading}
/>
<Collapse isOpen={open && !loading}>
{error && <Callout intent={Intent.DANGER} compact style={{ marginTop: '0.25rem' }}>{error}</Callout>}
{commits.length === 0 && !error && (
<p style={{ fontSize: '0.75rem', color: '#8f99a8', marginTop: '0.5rem' }}>No commits found for this path.</p>
)}
{commits.length > 0 && (
<HTMLTable className="bp5-html-table-condensed bp5-html-table-striped" style={{ width: '100%', marginTop: '0.5rem', fontSize: '0.72rem' }}>
<thead>
<tr>
<th style={{ width: 60 }}>Commit</th>
<th>Message</th>
<th>Author</th>
<th style={{ width: 140 }}>Date</th>
<th style={{ width: 40, textAlign: 'right' }}>Files</th>
</tr>
</thead>
<tbody>
{commits.map((c) => (
<tr key={c.hash}>
<td>
<code style={{ fontSize: '0.7rem', color: '#4a90d9' }}>{c.shortHash}</code>
</td>
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.subject}
</td>
<td style={{ color: '#738091' }}>{c.author}</td>
<td style={{ color: '#738091' }}>
{new Date(c.date).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td style={{ textAlign: 'right', color: '#8f99a8' }}>
<span title={c.files.join('\n')}>{c.files.length}</span>
</td>
</tr>
))}
</tbody>
</HTMLTable>
)}
</Collapse>
</div>
);
}
// ── Per-project card ──────────────────────────────────────────────────────────
function ProjectCard({
project,
lastBuild,
onBuilt,
}: {
project: ProjectDefinition;
lastBuild: BuildRecord | undefined;
onBuilt: () => void;
}) {
const [building, setBuilding] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const handleBuild = async () => {
setBuilding(true);
setOpen(true);
setLogs([]);
setError(null);
try {
const res = await fetch(
`${BASE_URL}/api/builds/${encodeURIComponent(project.name)}`,
{ method: 'POST' }
);
if (!res.ok || !res.body) throw new Error(res.statusText);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
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) onBuilt();
else if (typeof msg.line === 'string')
setLogs((p) => [...p.slice(-1000), msg.line]);
} catch { /* ignore */ }
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setBuilding(false);
}
};
const statusIntent = lastBuild ? STATUS_INTENT[lastBuild.status] : Intent.NONE;
const statusLabel = lastBuild?.status ?? 'Never built';
const lastRun = lastBuild ? new Date(lastBuild.startedAt).toLocaleString() : '—';
const duration = lastBuild?.durationMs != null
? `${(lastBuild.durationMs / 1000).toFixed(1)}s` : null;
return (
<div className="job-card">
<div className="job-card-header">
<div>
<strong>{project.name}</strong>
<span className="job-card-subdomain" style={{ fontFamily: 'monospace', fontSize: '0.72rem' }}>
{project.relativePath}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<Tag intent={KIND_INTENT[project.kind] ?? Intent.NONE} minimal round>{project.kind}</Tag>
<Tag intent={statusIntent} round>{statusLabel}</Tag>
{duration && <Tag minimal round>{duration}</Tag>}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', padding: '0.5rem 0 0.25rem' }}>
<Button
icon="play"
small
intent={Intent.PRIMARY}
loading={building}
onClick={handleBuild}
text="Build"
/>
<span style={{ fontSize: '0.75rem', color: '#999' }}>Last run: {lastRun}</span>
{logs.length > 0 && (
<Button minimal small
icon={open ? 'chevron-up' : 'chevron-down'}
onClick={() => setOpen((o) => !o)}
text={open ? 'Hide log' : 'Show log'}
/>
)}
</div>
{error && <Callout intent={Intent.DANGER} compact style={{ marginTop: '0.25rem' }}>{error}</Callout>}
{open && logs.length > 0 && (
<div
ref={logRef}
style={{
fontFamily: 'monospace', fontSize: '0.7rem',
background: '#111', color: '#d4d4d4',
padding: '0.5rem 0.75rem', borderRadius: 4,
height: 200, overflowY: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
marginTop: '0.5rem',
}}
>
{logs.map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
<GitHistoryPanel relativePath={project.relativePath} />
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function BuildMonitorPage() {
const [projects, setProjects] = useState<ProjectDefinition[]>([]);
const [history, setHistory] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [p, h] = await Promise.all([getProjects(), getBuildHistory()]);
setProjects(p);
setHistory(h);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load');
} finally {
setLoading(false);
}
};
useEffect(() => { (async () => { await load(); })(); }, []);
// Find latest build per project
const lastBuildFor = (name: string): BuildRecord | undefined =>
history.find((b) => b.target.includes(name.split(' ')[0]) || b.target.endsWith(name));
return (
<>
<div className="page-header">
<div>
<h1>Build Monitor</h1>
<p>Trigger and track builds for every project in the solution.</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
<Button
icon="play"
text="Build All"
intent={Intent.WARNING}
disabled={loading || projects.length === 0}
onClick={async () => {
for (const p of projects) {
await fetch(`${BASE_URL}/api/builds/${encodeURIComponent(p.name)}`, { method: 'POST' });
}
load();
}}
/>
</div>
</div>
{error && (
<Callout intent={Intent.DANGER} title="Failed to load projects" style={{ marginBottom: '1rem' }}>
{error}
</Callout>
)}
{loading && <NonIdealState icon={<Spinner />} title="Loading projects..." />}
{!loading && !error && (
<div className="job-list">
{projects.map((p) => (
<ProjectCard
key={p.name}
project={p}
lastBuild={lastBuildFor(p.name)}
onBuilt={load}
/>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,156 @@
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 type { TenantRecord } from '../types/provisioning';
const ENV_INTENT: Record<string, Intent> = {
prod: Intent.DANGER,
uat: Intent.WARNING,
fdev: Intent.PRIMARY,
};
function TenantCard({ t }: { t: TenantRecord }) {
const [logs, setLogs] = useState<string[]>([]);
const [logsOpen, setLogsOpen] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
const sourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!logsOpen) return;
const src = subscribeToTenantLogs(
t.subdomain,
(line) => setLogs((prev) => [...prev.slice(-500), line]), // cap at 500 lines
() => {}
);
sourceRef.current = src;
return () => { src.close(); sourceRef.current = null; };
}, [logsOpen, t.subdomain]);
// Auto-scroll to bottom when new lines arrive
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
return (
<div className="job-card">
<div className="job-card-header">
<div>
<strong>{t.clientName}</strong>
<span className="job-card-subdomain">{t.subdomain}</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<Tag intent={ENV_INTENT[t.environment] ?? Intent.NONE} round minimal>{t.environment}</Tag>
<Tag intent={t.status === 'Provisioned' ? Intent.SUCCESS : Intent.WARNING} round>{t.status}</Tag>
</div>
</div>
<Tabs onChange={(id) => setLogsOpen(id === 'logs')}>
<Tab id="info" title="Info" panel={
<div className="job-card-meta" style={{ paddingTop: '0.5rem' }}>
<span>Site: <code>{t.siteCode}</code></span>
<span>Container: <code>{t.containerName ?? '—'}{t.containerPort ? `:${t.containerPort}` : ''}</code></span>
<span>{new Date(t.provisionedAt).toLocaleString()}</span>
{t.status === 'Provisioned' && (
<AnchorButton
icon="share"
minimal
small
href={tenantUrl(t.subdomain)}
target="_blank"
rel="noopener noreferrer"
style={{ marginTop: '0.25rem' }}
>
{t.subdomain}.{CLARITY_DOMAIN}
</AnchorButton>
)}
</div>
} />
<Tab id="logs" title="Server" panel={
<div
ref={logRef}
style={{
fontFamily: 'monospace',
fontSize: '0.75rem',
background: '#1a1a1a',
color: '#d4d4d4',
padding: '0.5rem',
borderRadius: '4px',
height: '260px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
marginTop: '0.5rem',
}}
>
{logs.length === 0
? <span style={{ color: '#666' }}>Connecting to container logs</span>
: logs.map((l, i) => <div key={i}>{l}</div>)
}
</div>
} />
</Tabs>
</div>
);
}
export default function DashboardPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const [tenants, setTenants] = useState<TenantRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = () => {
getTenants()
.then((data) => { setTenants(data); setError(null); })
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleWizardClose = () => { setWizardOpen(false); setLoading(true); load(); };
if (wizardOpen) return <DeployWizard onClose={handleWizardClose} />;
return (
<>
<div className="page-header">
<div>
<h1>Provisioned Tenants</h1>
<p>Manage and monitor client deployments.</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
<Button intent={Intent.PRIMARY} text="Deploy New Client" icon="plus" large onClick={() => setWizardOpen(true)} />
</div>
</div>
{loading && <NonIdealState icon={<Spinner />} title="Loading tenants..." />}
{error && (
<Callout intent={Intent.DANGER} title="Failed to load tenants" style={{ marginBottom: '1rem' }}>
{error}
</Callout>
)}
{!loading && !error && tenants.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">🏗</div>
<h3>No tenants provisioned yet</h3>
<p>Deploy your first client to get started.</p>
<Button intent={Intent.PRIMARY} text="Deploy New Client" icon="plus" onClick={() => setWizardOpen(true)} />
</div>
)}
{!loading && tenants.length > 0 && (
<div className="job-list">
{tenants.map((t) => (
<TenantCard key={t.subdomain} t={t} />
))}
</div>
)}
</>
);
}
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Callout, Intent, Tag, Spinner,
HTMLTable, Card, Elevation,
} from '@blueprintjs/core';
import { getImageStatus, getBuildHistory, type ImageBuildStatus, type BuildRecord } from '../api/provisioningApi';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
const STATUS_INTENT: Record<string, Intent> = {
Succeeded: Intent.SUCCESS,
Failed: Intent.DANGER,
Running: Intent.PRIMARY,
};
// ── Live terminal ─────────────────────────────────────────────────────────────
function BuildTerminal({ lines }: { lines: string[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [lines]);
return (
<div
ref={ref}
style={{
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '0.75rem',
lineHeight: 1.5,
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75rem 1rem',
borderRadius: 6,
height: 420,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
border: '1px solid #30363d',
}}
>
{lines.length === 0 ? (
<span style={{ color: '#484f58' }}>Waiting for build output</span>
) : (
lines.map((l, i) => {
const isError = l.startsWith('✖');
const isSuccess = l.startsWith('✔');
const isSep = l.startsWith('──');
const color = isError ? '#f85149' : isSuccess ? '#3fb950' : isSep ? '#484f58' : undefined;
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
})
)}
</div>
);
}
// ── Build history table ────────────────────────────────────────────────────────
function BuildHistoryTable({ records }: { records: BuildRecord[] }) {
const docker = records.filter((r) => r.kind === 'DockerImage');
if (docker.length === 0)
return <p style={{ color: '#8f99a8', fontSize: '0.85rem', marginTop: '0.5rem' }}>No builds yet.</p>;
return (
<HTMLTable className="bp5-html-table-condensed bp5-html-table-striped" style={{ width: '100%', fontSize: '0.8rem' }}>
<thead>
<tr>
<th>Image</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{docker.map((r) => (
<tr key={r.id}>
<td style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>{r.target}</td>
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal round>{r.status}</Tag></td>
<td style={{ color: '#8f99a8' }}>{new Date(r.startedAt).toLocaleString()}</td>
<td style={{ color: '#8f99a8' }}>
{r.durationMs != null ? `${(r.durationMs / 1000).toFixed(1)}s` : '—'}
</td>
</tr>
))}
</tbody>
</HTMLTable>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function ImageBuildPage() {
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
const [history, setHistory] = useState<BuildRecord[]>([]);
const [building, setBuilding] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const refreshStatus = () =>
Promise.all([getImageStatus(), getBuildHistory()]).then(([s, h]) => {
setStatus(s);
setHistory(h);
}).catch(() => {});
useEffect(() => { refreshStatus(); }, []);
const handleBuild = async () => {
if (building) return;
setBuilding(true);
setLogs([]);
setError(null);
try {
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
if (!res.ok || !res.body) {
setError(`Build failed to start: ${res.statusText}`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
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) {
await refreshStatus();
} else if (typeof msg.line === 'string') {
setLogs((prev) => [...prev.slice(-2000), msg.line]);
}
} catch { /* non-JSON chunk */ }
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error during build');
} finally {
setBuilding(false);
}
};
const lastBuilt = status?.builtAt
? new Date(status.builtAt).toLocaleString()
: null;
return (
<>
{/* ── Header ── */}
<div className="page-header">
<div>
<h1>Image Build</h1>
<p>Build the <code style={{ fontSize: '0.85em' }}>clarity-server</code> Docker image from the current repo.</p>
</div>
<Button
icon="build"
intent={Intent.WARNING}
large
loading={building}
disabled={building}
onClick={handleBuild}
text={building ? 'Building…' : 'Build Image'}
/>
</div>
{/* ── Status bar ── */}
<Card elevation={Elevation.ONE} style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.75rem 1rem' }}>
{status ? (
<>
<Tag minimal style={{ fontFamily: 'monospace' }}>{status.imageName ?? 'clarity-server:latest'}</Tag>
{building ? (
<Tag intent={Intent.PRIMARY} icon="refresh">Building</Tag>
) : status.isBuilding ? (
<Tag intent={Intent.PRIMARY} icon={<Spinner size={12} />}>In progress</Tag>
) : (
<Tag
intent={status.lastMessage === 'Succeeded' ? Intent.SUCCESS : status.lastMessage === 'Failed' ? Intent.DANGER : Intent.NONE}
round
>
{status.lastMessage}
</Tag>
)}
{lastBuilt && (
<span style={{ fontSize: '0.8rem', color: '#8f99a8' }}>Last built: {lastBuilt}</span>
)}
</>
) : (
<Spinner size={16} />
)}
</Card>
{error && (
<Callout intent={Intent.DANGER} title="Build error" style={{ marginBottom: '1rem' }}>
{error}
</Callout>
)}
{/* ── Terminal ── */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Output</h3>
{logs.length > 0 && !building && (
<Button minimal small icon="trash" text="Clear" onClick={() => setLogs([])} />
)}
</div>
<BuildTerminal lines={logs} />
</div>
{/* ── History ── */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Build History</h3>
<Button minimal small icon="refresh" onClick={refreshStatus} />
</div>
<BuildHistoryTable records={history} />
</div>
</>
);
}
@@ -0,0 +1,284 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Intent, Tag, Spinner, Card, Elevation,
HTMLTable, NonIdealState, Callout,
} from '@blueprintjs/core';
import {
getInfraStatus, infraServiceAction, streamComposeUp, streamComposeDown,
type InfraService, type ServiceStatus,
} from '../api/infraApi';
const STATUS_INTENT: Record<ServiceStatus, Intent> = {
running: Intent.SUCCESS,
stopped: Intent.DANGER,
unhealthy: Intent.WARNING,
unknown: Intent.NONE,
};
const SERVICE_ICONS: Record<string, string> = {
'clarity-postgres': '🐘',
'clarity-keycloak': '🔐',
'clarity-vault': '🔒',
'clarity-minio': '📦',
'clarity-gitea': '🐙',
'clarity-nginx': '🌐',
'clarity-dnsmasq': '🔎',
};
function ServiceRow({
svc,
onAction,
busy,
}: {
svc: InfraService;
onAction: (name: string, action: 'start' | 'stop' | 'restart') => void;
busy: string | null;
}) {
const isBusy = busy === svc.container;
return (
<tr>
<td>
<span style={{ marginRight: 6 }}>{SERVICE_ICONS[svc.container] ?? '🔧'}</span>
<strong>{svc.name}</strong>
<span style={{ marginLeft: 6, fontSize: '0.75rem', color: '#8f99a8' }}>{svc.container}</span>
</td>
<td>
<Tag intent={STATUS_INTENT[svc.status]} round minimal>
{svc.status}
</Tag>
</td>
<td style={{ fontSize: '0.8rem', color: '#8f99a8' }}>
{svc.ports.join(', ') || '—'}
</td>
<td style={{ fontSize: '0.8rem', color: '#8f99a8' }}>
{svc.uptime ?? '—'}
</td>
<td>
<div style={{ display: 'flex', gap: '0.3rem' }}>
{svc.status !== 'running' ? (
<Button
small intent={Intent.SUCCESS} icon="play"
loading={isBusy}
onClick={() => onAction(svc.container, 'start')}
>Start</Button>
) : (
<>
<Button
small intent={Intent.WARNING} icon="refresh"
loading={isBusy}
onClick={() => onAction(svc.container, 'restart')}
>Restart</Button>
<Button
small intent={Intent.DANGER} icon="stop"
loading={isBusy}
onClick={() => onAction(svc.container, 'stop')}
>Stop</Button>
</>
)}
</div>
</td>
</tr>
);
}
// ── Compose terminal ─────────────────────────────────────────────────────────
function ComposeTerminal({ lines }: { lines: string[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [lines]);
return (
<div ref={ref} style={{
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: '0.75rem', lineHeight: 1.5,
background: '#0d1117', color: '#c9d1d9',
padding: '0.75rem 1rem', borderRadius: 6,
height: 300, overflowY: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
border: '1px solid #30363d',
}}>
{lines.length === 0
? <span style={{ color: '#484f58' }}>Waiting for output</span>
: lines.map((l, i) => {
const color = l.startsWith('✔') ? '#3fb950'
: l.startsWith('✖') ? '#f85149'
: l.includes('Error') ? '#f85149'
: l.includes('Pull') || l.includes('pull') ? '#79c0ff'
: undefined;
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
})
}
</div>
);
}
export default function InfraPage() {
const [services, setServices] = useState<InfraService[]>([]);
const [checkedAt, setCheckedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyService, setBusy] = useState<string | null>(null);
const [composeBusy, setComposeBusy] = useState<'up' | 'down' | null>(null);
const [terminalLines, setLines] = useState<string[]>([]);
const sseRef = useRef<EventSource | null>(null);
const refresh = async () => {
setLoading(true);
setError(null);
try {
const data = await getInfraStatus();
setServices(data.services);
setCheckedAt(data.checkedAt);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
let cancelled = false;
getInfraStatus()
.then(data => { if (!cancelled) { setServices(data.services); setCheckedAt(data.checkedAt); } })
.catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
async function handleAction(container: string, action: 'start' | 'stop' | 'restart') {
setBusy(container);
try {
await infraServiceAction(container, action);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
}
function startStream(streamer: typeof streamComposeUp, label: 'up' | 'down') {
sseRef.current?.close();
setLines([`▶ compose ${label}`]);
setComposeBusy(label);
const src = streamer(
(line) => setLines(prev => [...prev, line]),
() => { setComposeBusy(null); void refresh(); }
);
sseRef.current = src;
}
function handleComposeUp() { startStream(streamComposeUp, 'up'); }
function handleComposeDown() { startStream(streamComposeDown, 'down'); }
const runningCount = services.filter(s => s.status === 'running').length;
const allHealthy = runningCount === services.length && services.length > 0;
return (
<div style={{ padding: '1.5rem' }}>
{/* ── Header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ margin: 0 }}>Platform Infrastructure</h2>
<p style={{ margin: '0.25rem 0 0', color: '#8f99a8', fontSize: '0.85rem' }}>
Managed by <code>infra/docker-compose.yml</code> independent of Aspire orchestration.
{checkedAt && (
<span style={{ marginLeft: 8 }}>
Last checked: {new Date(checkedAt).toLocaleTimeString()}
</span>
)}
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button icon="refresh" onClick={refresh} loading={loading} minimal>Refresh</Button>
<Button
icon="play" intent={Intent.SUCCESS}
loading={composeBusy === 'up'}
disabled={composeBusy !== null}
onClick={handleComposeUp}
>Compose Up</Button>
<Button
icon="stop" intent={Intent.DANGER}
loading={composeBusy === 'down'}
disabled={composeBusy !== null}
onClick={handleComposeDown}
>Compose Down</Button>
</div>
</div>
{/* ── Health summary ── */}
{!loading && services.length > 0 && (
<Callout
intent={allHealthy ? Intent.SUCCESS : Intent.WARNING}
icon={allHealthy ? 'tick-circle' : 'warning-sign'}
style={{ marginBottom: '1.25rem' }}
>
{allHealthy
? `All ${services.length} platform services are running.`
: `${runningCount} of ${services.length} platform services are running.`}
</Callout>
)}
{error && (
<Callout intent={Intent.DANGER} icon="error" style={{ marginBottom: '1.25rem' }}>
{error}
</Callout>
)}
{/* ── Service table ── */}
<Card elevation={Elevation.ONE} style={{ padding: 0 }}>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<Spinner size={32} />
</div>
) : services.length === 0 ? (
<NonIdealState
icon="offline"
title="No services found"
description="Run Compose Up to start the platform infrastructure, or check that the API can reach the Docker socket."
action={
<Button intent={Intent.SUCCESS} icon="play" onClick={handleComposeUp}>
Compose Up
</Button>
}
/>
) : (
<HTMLTable striped interactive style={{ width: '100%' }}>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Ports</th>
<th>Uptime</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{services.map(svc => (
<ServiceRow
key={svc.container}
svc={svc}
onAction={handleAction}
busy={busyService}
/>
))}
</tbody>
</HTMLTable>
)}
</Card>
{/* ── Compose terminal ── */}
{terminalLines.length > 0 && (
<Card elevation={Elevation.ONE} style={{ marginTop: '1rem', padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<strong style={{ fontSize: '0.85rem' }}>Compose Output</strong>
{composeBusy && <Spinner size={14} />}
</div>
<ComposeTerminal lines={terminalLines} />
</Card>
)}
</div>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Callout, Intent, Tag, Spinner, HTMLTable,
NonIdealState,
} from '@blueprintjs/core';
import {
getReleaseHistory, getBuildHistory,
type ReleaseRecord, type BuildRecord,
} from '../api/provisioningApi';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
const ENV_INTENT: Record<string, Intent> = {
prod: Intent.DANGER,
uat: Intent.WARNING,
fdev: Intent.PRIMARY,
all: Intent.NONE,
};
const STATUS_INTENT: Record<string, Intent> = {
Succeeded: Intent.SUCCESS,
Failed: Intent.DANGER,
PartialFailure: Intent.WARNING,
Running: Intent.PRIMARY,
};
// ── Release trigger panel ─────────────────────────────────────────────────────
function ReleaseTrigger({ onReleased }: { onReleased: () => void }) {
const [releasing, setReleasing] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const release = async (env: string) => {
setReleasing(env);
setLogs([]);
setError(null);
try {
const res = await fetch(`${BASE_URL}/api/release/${env}`, { method: 'POST' });
if (!res.ok || !res.body) throw new Error(res.statusText);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
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) onReleased();
else if (typeof msg.line === 'string')
setLogs((p) => [...p.slice(-1000), msg.line]);
} catch { /* ignore */ }
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setReleasing(null);
}
};
return (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ margin: '0 0 0.75rem' }}>Release to Environment</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{(['fdev', 'uat', 'prod', 'all'] as const).map((env) => (
<Button
key={env}
intent={ENV_INTENT[env]}
icon="rocket"
loading={releasing === env}
disabled={releasing !== null && releasing !== env}
onClick={() => release(env)}
text={env === 'all' ? 'Release ALL' : `Release ${env}`}
/>
))}
</div>
{error && <Callout intent={Intent.DANGER} compact style={{ marginTop: '0.5rem' }}>{error}</Callout>}
{logs.length > 0 && (
<div
ref={logRef}
style={{
fontFamily: 'monospace', fontSize: '0.72rem',
background: '#111', color: '#d4d4d4',
padding: '0.6rem 0.8rem', borderRadius: 4,
height: 200, overflowY: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
marginTop: '0.75rem',
}}
>
{logs.map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
);
}
// ── History tables ────────────────────────────────────────────────────────────
function ReleaseHistoryTable({ records }: { records: ReleaseRecord[] }) {
const [expanded, setExpanded] = useState<string | null>(null);
if (records.length === 0)
return <NonIdealState icon="history" title="No releases yet." />;
return (
<HTMLTable striped compact style={{ width: '100%', fontSize: '0.82rem' }}>
<thead>
<tr>
<th>ID</th><th>Env</th><th>Image</th><th>Status</th>
<th>Started</th><th>Tenants</th><th></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<>
<tr key={r.id}>
<td><code>{r.id}</code></td>
<td><Tag intent={ENV_INTENT[r.environment] ?? Intent.NONE} minimal>{r.environment}</Tag></td>
<td><code style={{ fontSize: '0.72rem' }}>{r.imageName}</code></td>
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal>{r.status}</Tag></td>
<td>{new Date(r.startedAt).toLocaleString()}</td>
<td>{r.tenants.length}</td>
<td>
<Button minimal small icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
onClick={() => setExpanded(expanded === r.id ? null : r.id)} />
</td>
</tr>
{expanded === r.id && (
<tr key={r.id + '-detail'}>
<td colSpan={7} style={{ padding: '0.4rem 1rem 0.8rem' }}>
{r.tenants.map((t) => (
<div key={t.subdomain} style={{ display: 'flex', gap: '0.5rem', marginBottom: 2 }}>
<Tag intent={t.success ? Intent.SUCCESS : Intent.DANGER} minimal round>
{t.success ? '✔' : '✖'}
</Tag>
<code style={{ fontSize: '0.75rem' }}>{t.containerName}</code>
{t.error && <span style={{ color: '#e06c75', fontSize: '0.72rem' }}>{t.error}</span>}
</div>
))}
</td>
</tr>
)}
</>
))}
</tbody>
</HTMLTable>
);
}
function BuildHistoryTable({ records }: { records: BuildRecord[] }) {
const [expanded, setExpanded] = useState<string | null>(null);
if (records.length === 0)
return <NonIdealState icon="build" title="No builds yet." />;
return (
<HTMLTable striped compact style={{ width: '100%', fontSize: '0.82rem' }}>
<thead>
<tr><th>ID</th><th>Kind</th><th>Target</th><th>Status</th><th>Started</th><th>Duration</th><th></th></tr>
</thead>
<tbody>
{records.map((b) => (
<>
<tr key={b.id}>
<td><code>{b.id}</code></td>
<td><Tag minimal>{b.kind}</Tag></td>
<td style={{ maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.72rem' }}>{b.target}</code>
</td>
<td><Tag intent={STATUS_INTENT[b.status] ?? Intent.NONE} minimal>{b.status}</Tag></td>
<td>{new Date(b.startedAt).toLocaleString()}</td>
<td>{b.durationMs != null ? `${(b.durationMs / 1000).toFixed(1)}s` : '—'}</td>
<td>
<Button minimal small icon={expanded === b.id ? 'chevron-up' : 'chevron-down'}
onClick={() => setExpanded(expanded === b.id ? null : b.id)} />
</td>
</tr>
{expanded === b.id && (
<tr key={b.id + '-log'}>
<td colSpan={7}>
<div style={{
fontFamily: 'monospace', fontSize: '0.7rem',
background: '#111', color: '#d4d4d4',
padding: '0.5rem 0.75rem', borderRadius: 4,
maxHeight: 200, overflowY: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{b.log.join('\n')}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</HTMLTable>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function PipelinesPage() {
const [releases, setReleases] = useState<ReleaseRecord[]>([]);
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
try {
const [r, b] = await Promise.all([getReleaseHistory(), getBuildHistory()]);
setReleases(r);
setBuilds(b.filter((b) => b.kind === 'DockerImage'));
} finally {
setLoading(false);
}
};
useEffect(() => { (async () => { await load(); })(); }, []);
return (
<>
<div className="page-header">
<div>
<h1>Pipelines</h1>
<p>Build image · release to environment · view history.</p>
</div>
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
</div>
<ReleaseTrigger onReleased={load} />
<section style={{ marginBottom: '2rem' }}>
<h3 style={{ margin: '0 0 0.5rem' }}>Release History</h3>
{loading ? <Spinner size={20} /> : <ReleaseHistoryTable records={releases} />}
</section>
<section>
<h3 style={{ margin: '0 0 0.5rem' }}>Image Build History</h3>
{loading ? <Spinner size={20} /> : <BuildHistoryTable records={builds} />}
</section>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
export type OpcType =
| 'ChangeOrder'
| 'NonDevTask'
| 'QaTask'
| 'BusinessRequirement'
| 'Feature'
| 'General';
export type OpcStatus =
| 'New'
| 'InProgress'
| 'InReview'
| 'Blocked'
| 'Closed'
| 'Cancelled';
export type OpcPriority = 'Low' | 'Medium' | 'High' | 'Critical';
export type ArtifactType =
| 'BusinessRequirement'
| 'Rule'
| 'Spec'
| 'Documentation'
| 'QaTestPath';
export interface OpcNote {
id: string;
author: string;
timestamp: string;
content: string;
}
export interface OpcCommit {
hash: string;
shortHash: string;
message: string;
author: string;
timestamp: string;
branch: string;
files: string[];
}
export interface OpcArtifact {
id: string;
opcId: string;
artifactType: ArtifactType;
title: string;
content: string;
createdAt: string;
updatedAt: string;
}
export interface Opc {
id: string;
number: string; // e.g. "OPC # 0001"
title: string;
description: string;
type: OpcType;
status: OpcStatus;
priority: OpcPriority;
assignee: string;
createdAt: string;
updatedAt: string;
notes: OpcNote[];
commits: OpcCommit[];
}
@@ -0,0 +1,50 @@
export type TenantTier = 'Shared' | 'Isolated' | 'Dedicated';
export type TenantEnvironment = 'fdev' | 'uat' | 'prod';
export interface ProvisioningRequest {
clientName: string;
stateCode: string;
subdomain: string;
adminEmail: string;
siteCode: string;
environment: TenantEnvironment;
tier: TenantTier;
}
export interface ProvisioningJob {
id: string;
clientName: string;
stateCode: string;
subdomain: string;
adminEmail: string;
status: 'Pending' | 'Running' | 'Compensating' | 'Failed' | 'Completed';
completedSteps: number;
failureReason?: string;
createdAt: string;
completedAt?: string;
}
export interface TenantRecord {
subdomain: string;
clientName: string;
stateCode: string;
adminEmail: string;
siteCode: string;
environment: TenantEnvironment;
tier: string;
status: string;
provisionedAt: string;
jobId: string;
containerName?: string;
containerPort?: string;
apiBaseUrl?: string;
}
export interface ProvisioningProgressEvent {
jobId: string;
type: 'step_started' | 'step_complete' | 'step_failed' | 'job_complete' | 'job_failed' | 'diagnostic' | 'compensation_started' | 'compensation_complete';
step?: string;
message?: string;
detail?: string;
timestamp: string;
}