133 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|