Files
2026-04-26 11:32:23 -04:00

133 lines
4.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Button, Callout, Intent, Tag } from '@blueprintjs/core';
import { getImageStatus, type ImageBuildStatus } from '../api/imageApi';
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
export default function ImageBuildPanel() {
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
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(() => {
getImageStatus().then(setStatus).catch(() => {});
}, []);
// Auto-scroll log panel
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const handleBuild = async () => {
if (building) return;
setBuilding(true);
setOpen(true);
setLogs([]);
setError(null);
try {
// POST /api/image/build — the response body IS the SSE stream
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
if (!res.ok || !res.body) {
setError(`Build failed to start: ${res.statusText}`);
setBuilding(false);
return;
}
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) {
// Build finished — refresh status
getImageStatus().then(setStatus).catch(() => {});
} else if (typeof msg.line === 'string') {
setLogs((prev) => [...prev.slice(-1000), msg.line]);
}
} catch { /* ignore non-JSON */ }
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error during build');
} finally {
setBuilding(false);
}
};
const lastBuilt = status?.builtAt
? new Date(status.builtAt).toLocaleString()
: 'Never';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Button
icon="build"
intent={Intent.WARNING}
loading={building}
onClick={handleBuild}
text="Build Image"
/>
{!building && (
<Tag minimal intent={Intent.NONE} style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}>
{status?.imageName ?? 'clarity-server:latest'} · last built {lastBuilt}
</Tag>
)}
{building && (
<Tag minimal intent={Intent.WARNING}>Building</Tag>
)}
{logs.length > 0 && !building && (
<Button
icon={open ? 'chevron-up' : 'chevron-down'}
minimal
small
onClick={() => setOpen((o) => !o)}
text={open ? 'Hide log' : 'Show log'}
/>
)}
</div>
{error && (
<Callout intent={Intent.DANGER} compact>{error}</Callout>
)}
{open && logs.length > 0 && (
<div
ref={logRef}
style={{
fontFamily: 'monospace',
fontSize: '0.72rem',
background: '#111',
color: '#d4d4d4',
padding: '0.6rem 0.8rem',
borderRadius: '4px',
height: '220px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{logs.map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
);
}