diff --git a/clarity.controlplane/src/pages/BuildMonitorPage.css b/clarity.controlplane/src/pages/BuildMonitorPage.css new file mode 100644 index 0000000..0721ab5 --- /dev/null +++ b/clarity.controlplane/src/pages/BuildMonitorPage.css @@ -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; +} diff --git a/clarity.controlplane/src/pages/BuildMonitorPage.tsx b/clarity.controlplane/src/pages/BuildMonitorPage.tsx index d9ecf75..e37f7dd 100644 --- a/clarity.controlplane/src/pages/BuildMonitorPage.tsx +++ b/clarity.controlplane/src/pages/BuildMonitorPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { - AnchorButton, Button, Callout, Collapse, HTMLTable, Intent, NonIdealState, Spinner, Tag, + Button, Callout, Collapse, Intent, NonIdealState, Spinner, Tag, } from '@blueprintjs/core'; import { getProjects, getBuildHistory, type ProjectDefinition, type BuildRecord } from '../api/buildApi'; +import './BuildMonitorPage.css'; const BASE_URL = import.meta.env.VITE_API_URL ?? ''; @@ -20,168 +21,182 @@ const KIND_INTENT: Record = { SolutionBuild: Intent.SUCCESS, }; -const STATUS_INTENT: Record = { - Succeeded: Intent.SUCCESS, - Failed: Intent.DANGER, - Running: Intent.PRIMARY, +const STATUS_COLOR: Record = { + Succeeded: '#2d9e2d', + Failed: '#c23030', + 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 no history; + + const W = 5, GAP = 2, H = 28; + return ( + + {bars.map((b, i) => ( + + ))} + + ); +} + +// LogViewer ----------------------------------------------------------------- + +function LogViewer({ lines }: { lines: string[] }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); return ( - - -
- {lines.map((l, i) =>
{l}
)} -
- - +
+ {lines.map((l, i) =>
{l}
)} +
); } -// ── Manual project row ──────────────────────────────────────────────────────── +// PipelineCard -------------------------------------------------------------- -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); +interface PipelineCardProps { + name: string; + kind: string; + subtitle?: string; + builds: BuildRecord[]; + onBuild?: () => void; + building?: boolean; + buildLogs?: string[]; + buildError?: string | 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); - } - }; +function PipelineCard({ name, kind, subtitle, builds, onBuild, building, buildLogs, buildError }: PipelineCardProps) { + const [logsOpen, setLogsOpen] = useState(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() : '—'; + const latest = [...builds].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())[0]; + const dotColor = building + ? 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 ( - <> - - - {project.name} -
- {project.relativePath} - - {KIND_LABEL[project.kind] ?? project.kind} - manual - {lastBuild?.status ?? 'Never'} - {lastRun} - {duration} - -