OPC # 0009: Gitea and OPC Build Webhooks
controlplane/build ✔ Build succeeded.

This commit is contained in:
amadzarak
2026-04-26 17:19:33 -04:00
parent f046db7fc2
commit ba307747ca
2 changed files with 454 additions and 165 deletions
@@ -0,0 +1,229 @@
/* ── Section headings ───────────────────────────────────────────────────────── */
.bm-section-title {
font-size: 0.75rem;
font-weight: 600;
color: #5f6b7c;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
}
/* ── Pipeline card grid ─────────────────────────────────────────────────────── */
.bm-pipeline-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.bm-pipeline-card {
background: #fff;
border: 1px solid #e5e8eb;
border-radius: 10px;
padding: 0.9rem 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 0.7rem;
transition: box-shadow 0.15s, border-color 0.15s;
}
.bm-pipeline-card:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
border-color: #c5cbd3;
}
.bm-pipeline-card-top {
display: flex;
align-items: flex-start;
gap: 0.55rem;
}
.bm-pipeline-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4px;
}
.bm-pipeline-info {
flex: 1;
min-width: 0;
}
.bm-pipeline-name {
font-weight: 600;
font-size: 0.875rem;
color: #1c2127;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bm-pipeline-sub {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 0.62rem;
color: #8f99a8;
display: block;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bm-pipeline-card-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* ── Spark bar ──────────────────────────────────────────────────────────────── */
.bm-pipeline-card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 30px;
}
.bm-spark { display: block; flex-shrink: 0; }
.bm-spark-empty {
font-size: 0.7rem;
color: #c5cbd3;
font-style: italic;
}
.bm-pipeline-status-text {
font-size: 0.7rem;
color: #8f99a8;
text-align: right;
white-space: nowrap;
}
/* ── Card log area ──────────────────────────────────────────────────────────── */
.bm-pipeline-log-toggle {
border-top: 1px solid #f0f2f5;
padding-top: 0.4rem;
margin-top: -0.1rem;
}
/* ── Log viewer ─────────────────────────────────────────────────────────────── */
.bm-log-viewer {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 0.68rem;
background: #0d1117;
color: #c9d1d9;
padding: 0.5rem 0.75rem;
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
border-radius: 6px;
line-height: 1.55;
}
/* ── Runs list ──────────────────────────────────────────────────────────────── */
.bm-runs-list {
background: #fff;
border: 1px solid #e5e8eb;
border-radius: 10px;
overflow: hidden;
}
.bm-run-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid #f0f2f5;
font-size: 0.825rem;
cursor: default;
}
.bm-run-row:last-of-type {
border-bottom: none;
}
.bm-run-row:hover {
background: #f8f9fb;
}
.bm-run-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bm-run-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.45rem;
}
.bm-run-name {
font-weight: 600;
color: #1c2127;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.bm-run-sha {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 0.7rem;
color: #215db0;
background: #e8f0fc;
padding: 1px 6px;
border-radius: 4px;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.bm-run-sha:hover { text-decoration: underline; }
.bm-run-trigger {
font-size: 0.7rem;
color: #8f99a8;
background: #f0f2f5;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.bm-run-time {
color: #8f99a8;
font-size: 0.78rem;
white-space: nowrap;
min-width: 68px;
text-align: right;
flex-shrink: 0;
}
.bm-run-dur {
color: #8f99a8;
font-size: 0.78rem;
white-space: nowrap;
min-width: 46px;
text-align: right;
flex-shrink: 0;
}
.bm-run-log-panel {
padding: 0 0 0;
background: #0d1117;
}
.bm-run-log-panel .bm-log-viewer {
border-radius: 0;
max-height: 260px;
}
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
AnchorButton, Button, Callout, Collapse, HTMLTable, Intent, NonIdealState, Spinner, Tag, Button, Callout, Collapse, Intent, NonIdealState, Spinner, Tag,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { getProjects, getBuildHistory, type ProjectDefinition, type BuildRecord } from '../api/buildApi'; import { getProjects, getBuildHistory, type ProjectDefinition, type BuildRecord } from '../api/buildApi';
import './BuildMonitorPage.css';
const BASE_URL = import.meta.env.VITE_API_URL ?? ''; const BASE_URL = import.meta.env.VITE_API_URL ?? '';
@@ -20,168 +21,182 @@ const KIND_INTENT: Record<string, Intent> = {
SolutionBuild: Intent.SUCCESS, SolutionBuild: Intent.SUCCESS,
}; };
const STATUS_INTENT: Record<string, Intent> = { const STATUS_COLOR: Record<string, string> = {
Succeeded: Intent.SUCCESS, Succeeded: '#2d9e2d',
Failed: Intent.DANGER, Failed: '#c23030',
Running: Intent.PRIMARY, Running: '#3d8bd4',
}; };
// ── Inline log viewer ───────────────────────────────────────────────────────── function timeAgo(dateStr: string): string {
const s = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function LogRow({ lines }: { lines: string[] }) { // SparkBar ------------------------------------------------------------------
function SparkBar({ builds }: { builds: BuildRecord[] }) {
const bars = [...builds]
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime())
.slice(-20);
if (bars.length === 0) return <span className="bm-spark-empty">no history</span>;
const W = 5, GAP = 2, H = 28;
return (
<svg width={bars.length * (W + GAP) - GAP} height={H} className="bm-spark">
{bars.map((b, i) => (
<rect key={b.id} x={i * (W + GAP)} y={0} width={W} height={H} rx={1}
fill={STATUS_COLOR[b.status] ?? '#c5cbd3'} />
))}
</svg>
);
}
// LogViewer -----------------------------------------------------------------
function LogViewer({ lines }: { lines: string[] }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
return ( return (
<tr> <div ref={ref} className="bm-log-viewer">
<td colSpan={7} style={{ padding: 0 }}>
<div ref={ref} style={{
fontFamily: 'monospace', fontSize: '0.7rem',
background: '#111', color: '#d4d4d4',
padding: '0.5rem 0.75rem', maxHeight: 200, overflowY: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{lines.map((l, i) => <div key={i}>{l}</div>)} {lines.map((l, i) => <div key={i}>{l}</div>)}
</div> </div>
</td>
</tr>
); );
} }
// ── Manual project row ──────────────────────────────────────────────────────── // PipelineCard --------------------------------------------------------------
function ProjectRow({ interface PipelineCardProps {
project, lastBuild, onBuilt, name: string;
}: { kind: string;
project: ProjectDefinition; subtitle?: string;
lastBuild: BuildRecord | undefined; builds: BuildRecord[];
onBuilt: () => void; onBuild?: () => void;
}) { building?: boolean;
const [building, setBuilding] = useState(false); buildLogs?: string[];
const [logs, setLogs] = useState<string[]>([]); buildError?: string | null;
const [open, setOpen] = useState(false); }
const [error, setError] = useState<string | null>(null);
const handleBuild = async () => { function PipelineCard({ name, kind, subtitle, builds, onBuild, building, buildLogs, buildError }: PipelineCardProps) {
setBuilding(true); const [logsOpen, setLogsOpen] = useState(false);
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 latest = [...builds].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())[0];
const duration = lastBuild?.durationMs != null ? `${(lastBuild.durationMs / 1000).toFixed(1)}s` : '—'; const dotColor = building
const lastRun = lastBuild ? new Date(lastBuild.startedAt).toLocaleString() : '—'; ? STATUS_COLOR.Running
: latest ? (STATUS_COLOR[latest.status] ?? '#c5cbd3') : '#c5cbd3';
useEffect(() => { if (building) setLogsOpen(true); }, [building]);
const statusText = building
? 'Building...'
: latest ? `${latest.status} · ${timeAgo(latest.startedAt)}`
: 'Never built';
return ( return (
<div className="bm-pipeline-card">
<div className="bm-pipeline-card-top">
<div className="bm-pipeline-dot" style={{ background: dotColor }} />
<div className="bm-pipeline-info">
<span className="bm-pipeline-name">{name}</span>
{subtitle && <code className="bm-pipeline-sub">{subtitle}</code>}
</div>
<div className="bm-pipeline-card-actions">
<Tag minimal round intent={KIND_INTENT[kind] ?? Intent.NONE} style={{ fontSize: '0.65rem' }}>
{KIND_LABEL[kind] ?? kind}
</Tag>
{onBuild && (
<Button icon="play" small minimal intent={Intent.PRIMARY} loading={building} onClick={onBuild} />
)}
</div>
</div>
<div className="bm-pipeline-card-bottom">
<SparkBar builds={builds} />
<span className="bm-pipeline-status-text">{statusText}</span>
</div>
{buildLogs && buildLogs.length > 0 && (
<> <>
<tr> <div className="bm-pipeline-log-toggle">
<td> <Button minimal small
<span style={{ fontWeight: 600 }}>{project.name}</span> icon={logsOpen ? 'chevron-up' : 'chevron-down'}
<br /> text={logsOpen ? 'Hide log' : 'Show log'}
<code style={{ fontSize: '0.68rem', color: '#8f99a8' }}>{project.relativePath}</code> onClick={() => setLogsOpen((o) => !o)} />
</td> </div>
<td><Tag intent={KIND_INTENT[project.kind] ?? Intent.NONE} minimal round>{KIND_LABEL[project.kind] ?? project.kind}</Tag></td> <Collapse isOpen={logsOpen} keepChildrenMounted>
<td><Tag minimal round style={{ color: '#8f99a8' }}>manual</Tag></td> <LogViewer lines={buildLogs} />
<td><Tag intent={statusIntent} minimal round>{lastBuild?.status ?? 'Never'}</Tag></td>
<td style={{ color: '#8f99a8', fontSize: '0.8rem' }}>{lastRun}</td>
<td style={{ color: '#8f99a8', fontSize: '0.8rem', textAlign: 'right' }}>{duration}</td>
<td style={{ textAlign: 'right' }}>
<Button icon="play" small intent={Intent.PRIMARY} loading={building} onClick={handleBuild} text="Build" />
{logs.length > 0 && (
<Button minimal small icon={open ? 'chevron-up' : 'chevron-down'} onClick={() => setOpen((o) => !o)} style={{ marginLeft: 4 }} />
)}
</td>
</tr>
{error && (
<tr><td colSpan={7}><Callout intent={Intent.DANGER} compact>{error}</Callout></td></tr>
)}
<Collapse isOpen={open && logs.length > 0} keepChildrenMounted>
<LogRow lines={logs} />
</Collapse> </Collapse>
</> </>
)}
{buildError && (
<Callout intent={Intent.DANGER} compact style={{ marginTop: '0.4rem' }}>{buildError}</Callout>
)}
</div>
); );
} }
// ── CI (SolutionBuild) row ─────────────────────────────────────────────────── // RunRow --------------------------------------------------------------------
function CiRow({ build, giteaBase }: { build: BuildRecord; giteaBase: string }) { function RunRow({ build, giteaBase }: { build: BuildRecord; giteaBase: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const repo = build.target.replace(/\.slnx?$/, ''); // "ControlPlane.slnx" → "ControlPlane" const repo = build.target.replace(/\.slnx?$/, '');
const shortSha = build.commitSha?.slice(0, 7); const shortSha = build.commitSha?.slice(0, 7);
const shaUrl = build.commitSha const shaUrl = build.commitSha && build.kind === 'SolutionBuild'
? `${giteaBase}/ClarityStack/${repo === 'ControlPlane' ? 'OPC' : repo}/commit/${build.commitSha}` ? `${giteaBase}/ClarityStack/${repo === 'ControlPlane' ? 'OPC' : repo}/commit/${build.commitSha}`
: null; : null;
const duration = build.durationMs != null ? `${(build.durationMs / 1000).toFixed(1)}s` : '—'; const duration = build.durationMs != null ? `${(build.durationMs / 1000).toFixed(1)}s` : '-';
const lastRun = new Date(build.startedAt).toLocaleString(); const dotColor = STATUS_COLOR[build.status] ?? '#c5cbd3';
const dispName = build.kind === 'SolutionBuild' ? repo : (build.target.split('/').pop() ?? build.target);
return ( return (
<> <>
<tr> <div className="bm-run-row">
<td> <span className="bm-run-dot" style={{ background: dotColor }} />
<span style={{ fontWeight: 600 }}>{repo}</span> <div className="bm-run-main">
<br /> <span className="bm-run-name">{dispName}</span>
<code style={{ fontSize: '0.68rem', color: '#8f99a8' }}>{build.target}</code> {shortSha && shaUrl
</td> ? <a href={shaUrl} target="_blank" rel="noopener noreferrer" className="bm-run-sha">{shortSha}</a>
<td><Tag intent={Intent.SUCCESS} minimal round>CI</Tag></td> : <span className="bm-run-trigger">manual</span>}
<td> </div>
{shortSha && shaUrl ? ( <Tag minimal round intent={KIND_INTENT[build.kind] ?? Intent.NONE} style={{ fontSize: '0.65rem' }}>
<AnchorButton href={shaUrl} target="_blank" rel="noopener noreferrer" minimal small {KIND_LABEL[build.kind] ?? build.kind}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '0 4px' }}> </Tag>
{shortSha} <span className="bm-run-time">{timeAgo(build.startedAt)}</span>
</AnchorButton> <span className="bm-run-dur">{duration}</span>
) : <span style={{ color: '#8f99a8', fontSize: '0.8rem' }}>â</span>} {build.log.length > 0
</td> ? <Button minimal small icon={open ? 'chevron-up' : 'chevron-down'} onClick={() => setOpen((o) => !o)} />
<td><Tag intent={STATUS_INTENT[build.status] ?? Intent.NONE} minimal round>{build.status}</Tag></td> : <span style={{ width: 24, flexShrink: 0 }} />}
<td style={{ color: '#8f99a8', fontSize: '0.8rem' }}>{lastRun}</td> </div>
<td style={{ color: '#8f99a8', fontSize: '0.8rem', textAlign: 'right' }}>{duration}</td>
<td style={{ textAlign: 'right' }}>
{build.log.length > 0 && (
<Button minimal small icon={open ? 'chevron-up' : 'chevron-down'} onClick={() => setOpen((o) => !o)} text={open ? 'Hide' : 'Log'} />
)}
</td>
</tr>
<Collapse isOpen={open} keepChildrenMounted> <Collapse isOpen={open} keepChildrenMounted>
<LogRow lines={build.log} /> <div className="bm-run-log-panel">
<LogViewer lines={build.log} />
</div>
</Collapse> </Collapse>
</> </>
); );
} }
// ── Page ────────────────────────────────────────────────────────────────────── // Page ----------------------------------------------------------------------
interface ProjectBuildState {
building: boolean;
logs: string[];
error: string | null;
}
export default function BuildMonitorPage() { export default function BuildMonitorPage() {
const [projects, setProjects] = useState<ProjectDefinition[]>([]); const [projects, setProjects] = useState<ProjectDefinition[]>([]);
const [history, setHistory] = useState<BuildRecord[]>([]); const [history, setHistory] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buildStates, setBuildStates] = useState<Record<string, ProjectBuildState>>({});
const giteaBase = (import.meta.env.VITE_GITEA_URL as string | undefined) ?? 'https://opc.clarity.test'; const giteaBase = (import.meta.env.VITE_GITEA_URL as string | undefined) ?? 'https://opc.clarity.test';
@@ -201,27 +216,53 @@ export default function BuildMonitorPage() {
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
// Latest SolutionBuild per solution target (one CI row per repo) const makeBuildHandler = (project: ProjectDefinition) => async () => {
const ciBuilds = Object.values( setBuildStates((s) => ({ ...s, [project.name]: { building: true, logs: [], error: null } }));
history try {
.filter((b) => b.kind === 'SolutionBuild') const res = await fetch(`${BASE_URL}/api/builds/${encodeURIComponent(project.name)}`, { method: 'POST' });
.reduce<Record<string, BuildRecord>>((acc, b) => { if (!res.ok || !res.body) throw new Error(res.statusText);
if (!acc[b.target] || new Date(b.startedAt) > new Date(acc[b.target].startedAt)) const reader = res.body.getReader();
acc[b.target] = b; const decoder = new TextDecoder();
return acc; 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) load();
else if (typeof msg.line === 'string')
setBuildStates((s) => ({
...s,
[project.name]: { ...s[project.name], logs: [...(s[project.name]?.logs ?? []).slice(-1000), msg.line] },
}));
} catch { /* ignore */ }
}
}
} catch (e) {
const err = e instanceof Error ? e.message : 'Unknown error';
setBuildStates((s) => ({ ...s, [project.name]: { ...s[project.name], error: err } }));
} finally {
setBuildStates((s) => ({ ...s, [project.name]: { ...s[project.name], building: false } }));
}
};
// Latest manual build per project path const ciTargets = [...new Set(history.filter((b) => b.kind === 'SolutionBuild').map((b) => b.target))];
const lastBuildFor = (project: ProjectDefinition): BuildRecord | undefined => const recentRuns = [...history]
history.find((b) => b.target === project.relativePath); .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
.slice(0, 50);
return ( return (
<> <>
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>Build Monitor</h1> <h1>Build Monitor</h1>
<p>CI gate builds (webhook-triggered) and manual per-project builds.</p> <p>CI gate builds and manual per-project builds</p>
</div> </div>
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" /> <Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
</div> </div>
@@ -233,30 +274,49 @@ export default function BuildMonitorPage() {
{loading && <NonIdealState icon={<Spinner />} title="Loading..." />} {loading && <NonIdealState icon={<Spinner />} title="Loading..." />}
{!loading && !error && ( {!loading && !error && (
<HTMLTable className="bp6-html-table-condensed bp6-html-table-striped" style={{ width: '100%' }}> <>
<thead> <p className="bm-section-title">Pipelines</p>
<tr> <div className="bm-pipeline-grid">
<th>Name</th> {ciTargets.map((target) => {
<th>Kind</th> const repo = target.replace(/\.slnx?$/, '');
<th>Commit / Trigger</th> return (
<th>Status</th> <PipelineCard key={target}
<th>Last Run</th> name={repo}
<th style={{ textAlign: 'right' }}>Duration</th> kind="SolutionBuild"
<th style={{ textAlign: 'right' }}>Actions</th> subtitle={target}
</tr> builds={history.filter((b) => b.target === target)} />
</thead> );
<tbody> })}
{ciBuilds.map((b) => ( {projects.map((p) => {
<CiRow key={`ci-${b.target}`} build={b} giteaBase={giteaBase} /> const bs = buildStates[p.name];
))} return (
{projects.map((p) => ( <PipelineCard key={p.name}
<ProjectRow key={p.name} project={p} lastBuild={lastBuildFor(p)} onBuilt={load} /> name={p.name}
))} kind={p.kind}
{ciBuilds.length === 0 && projects.length === 0 && ( subtitle={p.relativePath}
<tr><td colSpan={7} style={{ textAlign: 'center', color: '#8f99a8', padding: '2rem' }}>No builds yet.</td></tr> builds={history.filter((b) => b.target === p.relativePath)}
onBuild={makeBuildHandler(p)}
building={bs?.building}
buildLogs={bs?.logs}
buildError={bs?.error} />
);
})}
{ciTargets.length === 0 && projects.length === 0 && (
<span style={{ color: '#8f99a8', fontSize: '0.875rem' }}>No pipelines configured.</span>
)} )}
</tbody> </div>
</HTMLTable>
{recentRuns.length > 0 && (
<>
<p className="bm-section-title" style={{ marginTop: '2rem' }}>Recent Runs</p>
<div className="bm-runs-list">
{recentRuns.map((b) => (
<RunRow key={b.id} build={b} giteaBase={giteaBase} />
))}
</div>
</>
)}
</>
)} )}
</> </>
); );