OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/* App-level overrides — component styles live in index.css */
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>✓</Tag>}
|
||||
{status === 'failed' && <Tag intent={Intent.DANGER} minimal round>✗</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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user