From 66ef6117617ed5635cffb144db8d8e9954bda03b Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sun, 26 Apr 2026 16:47:40 -0400 Subject: [PATCH] OPC # 0006: Build monitor grid, CI SolutionBuild rows, by-sha endpoint, dashboard SHA badge --- .../Endpoints/ProjectBuildEndpoints.cs | 27 ++ clarity.controlplane/src/api/buildApi.ts | 23 ++ .../src/pages/BuildMonitorPage.tsx | 262 +++++++++++++++++- .../src/pages/DashboardPage.tsx | 9 + 4 files changed, 318 insertions(+), 3 deletions(-) diff --git a/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs b/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs index c4fac2f..fe56c8f 100644 --- a/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs +++ b/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs @@ -1,4 +1,5 @@ using ControlPlane.Api.Services; +using ControlPlane.Core.Models; using ControlPlane.Core.Services; using System.Text.Json; @@ -16,6 +17,7 @@ public static class ProjectBuildEndpoints var group = app.MapGroup("/api/builds").WithTags("Builds"); group.MapGet("/projects", GetProjects); group.MapGet("/history", GetHistory); + group.MapGet("/by-sha", GetBySha); group.MapPost("/{projectName}", TriggerProjectBuild); return app; } @@ -27,6 +29,31 @@ public static class ProjectBuildEndpoints private static async Task GetHistory(BuildHistoryService history) => Results.Ok(await history.GetBuildsAsync()); + /// + /// Returns the latest SolutionBuild record for the given commit SHA, or null if none exists. + /// Used by the OPC CommitsTab to render per-commit CI status dots. + /// + private static async Task GetBySha(string sha, BuildHistoryService history) + { + if (string.IsNullOrWhiteSpace(sha)) + return Results.BadRequest("sha is required"); + + var builds = await history.GetBuildsByShaAsync(sha); + var latest = builds + .Where(b => b.Kind == BuildKind.SolutionBuild) + .MaxBy(b => b.StartedAt); + + return Results.Ok(latest is null ? null : new + { + latest.Id, + latest.Status, + latest.StartedAt, + latest.FinishedAt, + latest.DurationMs, + latest.CommitSha, + }); + } + /// /// Triggers a build for a named project and streams SSE output. /// projectName must match one of the names returned by GET /api/builds/projects. diff --git a/clarity.controlplane/src/api/buildApi.ts b/clarity.controlplane/src/api/buildApi.ts index d95104c..16a2976 100644 --- a/clarity.controlplane/src/api/buildApi.ts +++ b/clarity.controlplane/src/api/buildApi.ts @@ -30,6 +30,29 @@ export async function getBuildHistory(): Promise { return res.json(); } +export interface ShaBuildResult { + id: string; + status: 'Running' | 'Succeeded' | 'Failed'; + startedAt: string; + finishedAt?: string; + durationMs?: number; + commitSha?: string; +} + +/** Returns the latest SolutionBuild for a given SHA, or null if none exists. */ +export async function getBuildBySha(sha: string): Promise { + const res = await fetch(`${BASE_URL}/api/builds/by-sha?sha=${encodeURIComponent(sha)}`); + if (!res.ok) throw new Error(`getBuildBySha failed: ${res.statusText}`); + return res.json(); +} + +/** Batch-fetches SolutionBuild status for multiple SHAs. Returns a map of sha -> result|null. */ +export async function getBuildsByShas(shas: string[]): Promise> { + const unique = [...new Set(shas.filter(Boolean))]; + const entries = await Promise.all(unique.map(async (sha) => [sha, await getBuildBySha(sha)] as const)); + return new Map(entries); +} + export function triggerProjectBuild( projectName: string, onLine: (line: string) => void, diff --git a/clarity.controlplane/src/pages/BuildMonitorPage.tsx b/clarity.controlplane/src/pages/BuildMonitorPage.tsx index dd9635e..1d49de3 100644 --- a/clarity.controlplane/src/pages/BuildMonitorPage.tsx +++ b/clarity.controlplane/src/pages/BuildMonitorPage.tsx @@ -1,10 +1,266 @@ import { useEffect, useRef, useState } from 'react'; import { - Button, Callout, Intent, Tag, Spinner, NonIdealState, - Collapse, HTMLTable, + AnchorButton, Button, Callout, Collapse, HTMLTable, Intent, NonIdealState, Spinner, Tag, } from '@blueprintjs/core'; import { getProjects, getBuildHistory, type ProjectDefinition, type BuildRecord } from '../api/buildApi'; -import { getGitLog, type GitCommit } from '../api/gitApi'; + +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +const KIND_LABEL: Record = { + DotnetProject: '.NET', + NpmProject: 'npm', + DockerImage: 'Docker', + SolutionBuild: 'CI', +}; + +const KIND_INTENT: Record = { + DotnetProject: Intent.PRIMARY, + NpmProject: Intent.WARNING, + DockerImage: Intent.NONE, + SolutionBuild: Intent.SUCCESS, +}; + +const STATUS_INTENT: Record = { + Succeeded: Intent.SUCCESS, + Failed: Intent.DANGER, + Running: Intent.PRIMARY, +}; + +// ── Inline log viewer ───────────────────────────────────────────────────────── + +function LogRow({ lines }: { lines: string[] }) { + const ref = useRef(null); + useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); + return ( + + +
+ {lines.map((l, i) =>
{l}
)} +
+ + + ); +} + +// ── Manual project row ──────────────────────────────────────────────────────── + +function ProjectRow({ + project, lastBuild, onBuilt, +}: { + project: ProjectDefinition; + lastBuild: BuildRecord | undefined; + onBuilt: () => void; +}) { + const [building, setBuilding] = useState(false); + const [logs, setLogs] = useState([]); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + + 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 duration = lastBuild?.durationMs != null ? `${(lastBuild.durationMs / 1000).toFixed(1)}s` : '—'; + const lastRun = lastBuild ? new Date(lastBuild.startedAt).toLocaleString() : '—'; + + return ( + <> + + + {project.name} +
+ {project.relativePath} + + {KIND_LABEL[project.kind] ?? project.kind} + manual + {lastBuild?.status ?? 'Never'} + {lastRun} + {duration} + +