OPC # 0001: Extract OPC into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:42 -04:00
commit 42383bdc03
170 changed files with 21365 additions and 0 deletions
@@ -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>
)}
</>
);
}