OPC # 0009: Gitea and OPC Build Webhooks
controlplane/build ✔ Build succeeded.
controlplane/build ✔ Build succeeded.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AnchorButton, Button, Callout, Collapse, HTMLTable, Intent, NonIdealState, Spinner, Tag,
|
AnchorButton, Button, Callout, Collapse, HTMLTable, Intent, NonIdealState, Spinner, Tag,
|
||||||
} from '@blueprintjs/core';
|
} from '@blueprintjs/core';
|
||||||
@@ -26,7 +26,7 @@ const STATUS_INTENT: Record<string, Intent> = {
|
|||||||
Running: Intent.PRIMARY,
|
Running: Intent.PRIMARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Inline log viewer ─────────────────────────────────────────────────────────
|
// ── Inline log viewer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function LogRow({ lines }: { lines: string[] }) {
|
function LogRow({ lines }: { lines: string[] }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -47,7 +47,7 @@ function LogRow({ lines }: { lines: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manual project row ────────────────────────────────────────────────────────
|
// ── Manual project row ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ProjectRow({
|
function ProjectRow({
|
||||||
project, lastBuild, onBuilt,
|
project, lastBuild, onBuilt,
|
||||||
@@ -96,8 +96,8 @@ function ProjectRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusIntent = lastBuild ? STATUS_INTENT[lastBuild.status] : Intent.NONE;
|
const statusIntent = lastBuild ? STATUS_INTENT[lastBuild.status] : Intent.NONE;
|
||||||
const duration = lastBuild?.durationMs != null ? `${(lastBuild.durationMs / 1000).toFixed(1)}s` : '—';
|
const duration = lastBuild?.durationMs != null ? `${(lastBuild.durationMs / 1000).toFixed(1)}s` : '—';
|
||||||
const lastRun = lastBuild ? new Date(lastBuild.startedAt).toLocaleString() : '—';
|
const lastRun = lastBuild ? new Date(lastBuild.startedAt).toLocaleString() : '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -129,17 +129,17 @@ function ProjectRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CI (SolutionBuild) row ───────────────────────────────────────────────────
|
// ── CI (SolutionBuild) row ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function CiRow({ build, giteaBase }: { build: BuildRecord; giteaBase: string }) {
|
function CiRow({ 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?$/, ''); // "ControlPlane.slnx" → "ControlPlane"
|
||||||
const shortSha = build.commitSha?.slice(0, 7);
|
const shortSha = build.commitSha?.slice(0, 7);
|
||||||
const shaUrl = build.commitSha
|
const shaUrl = build.commitSha
|
||||||
? `${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 lastRun = new Date(build.startedAt).toLocaleString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,7 +157,7 @@ function CiRow({ build, giteaBase }: { build: BuildRecord; giteaBase: string })
|
|||||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '0 4px' }}>
|
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '0 4px' }}>
|
||||||
{shortSha}
|
{shortSha}
|
||||||
</AnchorButton>
|
</AnchorButton>
|
||||||
) : <span style={{ color: '#8f99a8', fontSize: '0.8rem' }}>—</span>}
|
) : <span style={{ color: '#8f99a8', fontSize: '0.8rem' }}>—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td><Tag intent={STATUS_INTENT[build.status] ?? Intent.NONE} minimal round>{build.status}</Tag></td>
|
<td><Tag intent={STATUS_INTENT[build.status] ?? Intent.NONE} minimal round>{build.status}</Tag></td>
|
||||||
<td style={{ color: '#8f99a8', fontSize: '0.8rem' }}>{lastRun}</td>
|
<td style={{ color: '#8f99a8', fontSize: '0.8rem' }}>{lastRun}</td>
|
||||||
@@ -175,7 +175,7 @@ function CiRow({ build, giteaBase }: { build: BuildRecord; giteaBase: string })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function BuildMonitorPage() {
|
export default function BuildMonitorPage() {
|
||||||
const [projects, setProjects] = useState<ProjectDefinition[]>([]);
|
const [projects, setProjects] = useState<ProjectDefinition[]>([]);
|
||||||
@@ -261,298 +261,3 @@ export default function BuildMonitorPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
|
||||||
|
|
||||||
const KIND_INTENT: Record<string, Intent> = {
|
|
||||||
DotnetProject: Intent.PRIMARY,
|
|
||||||
NpmProject: Intent.WARNING,
|
|
||||||
DockerImage: Intent.NONE,
|
|
||||||
SolutionBuild: Intent.SUCCESS,
|
|
||||||
};
|
|
||||||
|
|
||||||
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="bp6-html-table-condensed bp6-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 — match exactly by relativePath (= build target)
|
|
||||||
const lastBuildFor = (project: ProjectDefinition): BuildRecord | undefined =>
|
|
||||||
history.find((b) => b.target === project.relativePath);
|
|
||||||
|
|
||||||
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)}
|
|
||||||
onBuilt={load}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user