LeLab / src /components /training /TrainingExtraGate.tsx
GitHub CI
Sync from leLab @ 98140414a50981488ebdb523c6f050a7fb0b28b7
f878f2e
import React, { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { useApi } from "@/contexts/ApiContext";
import {
AlertTriangle,
CheckCircle2,
Copy,
Loader2,
XCircle,
} from "lucide-react";
type InstallState = "idle" | "installing" | "done" | "error";
interface LogEntry {
timestamp: number;
message: string;
}
interface InstallStatus {
state: InstallState;
error: string | null;
logs: LogEntry[];
}
interface Props {
installHint: string;
}
const POLL_INTERVAL_MS = 1500;
const TrainingExtraGate: React.FC<Props> = ({ installHint }) => {
const { baseUrl, fetchWithHeaders } = useApi();
const { toast } = useToast();
const [state, setState] = useState<InstallState>("idle");
const [error, setError] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const logBoxRef = useRef<HTMLDivElement>(null);
// Seed local state from the backend on mount so refresh-mid-install picks
// up where we left off (or shows Done/Error if the install already finished).
useEffect(() => {
let cancelled = false;
fetchWithHeaders(`${baseUrl}/system/training-extra/install-status`)
.then((r) => r.json())
.then((status: InstallStatus) => {
if (cancelled) return;
setState(status.state);
setError(status.error);
if (status.logs.length > 0) {
setLogs(status.logs);
}
})
.catch(() => {
// Backend unreachable — stay in idle; the user can still try.
});
return () => {
cancelled = true;
};
}, [baseUrl, fetchWithHeaders]);
// Poll while installing.
useEffect(() => {
if (state !== "installing") return;
const id = setInterval(async () => {
try {
const r = await fetchWithHeaders(`${baseUrl}/system/training-extra/install-status`);
if (!r.ok) return;
const status: InstallStatus = await r.json();
if (status.logs && status.logs.length > 0) {
setLogs((prev) => [...prev, ...status.logs]);
}
if (status.state !== "installing") {
setState(status.state);
setError(status.error);
}
} catch {
// Transient errors are fine; we'll retry on next tick.
}
}, POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [state, baseUrl, fetchWithHeaders]);
// Auto-scroll the log panel as new lines arrive.
useEffect(() => {
if (logBoxRef.current) {
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
}
}, [logs]);
const handleInstall = async () => {
// Optimistically transition so the polling effect kicks in even if the
// backend response is slow.
setState("installing");
setError(null);
setLogs([]);
try {
const r = await fetchWithHeaders(`${baseUrl}/system/training-extra/install`, {
method: "POST",
});
const body: { started: boolean; message: string } = await r.json();
if (!body.started && r.ok) {
// Backend says "already installing" — that's fine; polling already running.
return;
}
if (!r.ok) {
setState("error");
setError(body.message || `Install request failed (${r.status})`);
}
} catch (e) {
setState("error");
setError(`Install request failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleRetry = () => {
setState("idle");
setError(null);
setLogs([]);
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(installHint);
toast({ title: "Copied", description: installHint });
} catch {
toast({
title: "Copy failed",
description: "Select the command and copy manually.",
variant: "destructive",
});
}
};
return (
<div className="max-w-3xl mx-auto">
<Card className="bg-slate-800/50 border-slate-700 rounded-xl">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-white">
{state === "done" ? (
<CheckCircle2 className="w-6 h-6 text-green-400" />
) : state === "error" ? (
<XCircle className="w-6 h-6 text-red-400" />
) : state === "installing" ? (
<Loader2 className="w-6 h-6 text-sky-400 animate-spin" />
) : (
<AlertTriangle className="w-6 h-6 text-amber-400" />
)}
{state === "done"
? "Install Complete"
: state === "error"
? "Install Failed"
: state === "installing"
? "Installing…"
: "Training Extra Not Installed"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{state === "idle" && (
<>
<p className="text-slate-300">
Training requires the <code className="px-1 py-0.5 rounded bg-slate-900 text-sky-300">accelerate</code> package, which isn't installed in this environment. Install it to enable the Training page.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 font-mono">
{installHint}
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="text-slate-400 hover:text-white"
aria-label="Copy install command"
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleInstall}
className="bg-green-500 hover:bg-green-600 text-white font-semibold"
>
Install Now
</Button>
</>
)}
{state === "installing" && (
<p className="text-slate-300">
Installing <code className="px-1 py-0.5 rounded bg-slate-900 text-sky-300">accelerate</code>. This usually takes about 10 seconds.
</p>
)}
{state === "done" && (
<div className="space-y-3 text-slate-300">
<p>
Install complete. Restart <code className="px-1 py-0.5 rounded bg-slate-900 text-sky-300">lelab</code> to enable training:
</p>
<ol className="list-decimal list-inside space-y-2 pl-1">
<li>
Press <kbd className="px-1.5 py-0.5 rounded bg-slate-900 border border-slate-600 text-xs font-mono text-slate-200">Ctrl+C</kbd> in the terminal running <code className="px-1 py-0.5 rounded bg-slate-900 text-sky-300">lelab</code>.
</li>
<li>
Run <code className="px-1 py-0.5 rounded bg-slate-900 text-sky-300">lelab</code> again.
</li>
</ol>
</div>
)}
{state === "error" && (
<>
<p className="text-red-300">{error || "Install failed."}</p>
<Button
onClick={handleRetry}
className="bg-slate-700 hover:bg-slate-600 text-white"
>
Try again
</Button>
</>
)}
{state === "error" && logs.length > 0 && (
<div
ref={logBoxRef}
className="bg-slate-900 rounded-lg p-3 h-48 overflow-y-auto font-mono text-xs border border-slate-700 text-slate-300 whitespace-pre-wrap break-words"
>
{logs.map((log, idx) => (
<div key={idx}>{log.message}</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};
export default TrainingExtraGate;