fix: training panel stuck button, stale runs on startup, add delete model

This commit is contained in:
Marko Djordjevic 2026-02-17 22:23:50 +01:00
parent d34dc9d729
commit 6ef102cf21
4 changed files with 240 additions and 24 deletions

View file

@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { ChevronDown } from 'lucide-react';
import { ChevronDown, Trash2 } from 'lucide-react';
interface DatasetInfo {
path: string;
@ -68,6 +68,7 @@ export default function TrainingPanel() {
const [statusMessage, setStatusMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [isLoadingDataset, setIsLoadingDataset] = useState(false);
const [isLoadingRuns, setIsLoadingRuns] = useState(false);
const [deletingRunIds, setDeletingRunIds] = useState<Set<string>>(new Set());
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const fetchDatasetInfo = useCallback(async () => {
@ -95,39 +96,53 @@ export default function TrainingPanel() {
}
}, []);
// Load data when panel expands
useEffect(() => {
if (expanded) {
fetchDatasetInfo();
setIsLoadingRuns(true);
fetchRuns().finally(() => setIsLoadingRuns(false));
// Fetch the authoritative active run from the backend
const fetchActiveRun = useCallback(async (): Promise<string | null> => {
try {
const res = await fetch('/api/training/active');
if (!res.ok) return null;
const data = await res.json();
return data.active ? (data.run_id ?? null) : null;
} catch {
return null;
}
}, [expanded, fetchDatasetInfo, fetchRuns]);
}, []);
// Check if there's an active training run on expand
// Load data when panel expands; check backend for truly active run
useEffect(() => {
if (expanded && runs.length > 0) {
const running = runs.find((r) => r.status === 'running');
if (running && !activeRunId) {
setActiveRunId(running.run_id);
if (!expanded) return;
fetchDatasetInfo();
setIsLoadingRuns(true);
Promise.all([fetchRuns(), fetchActiveRun()]).then(([_runs, serverActiveRunId]) => {
if (serverActiveRunId) {
setActiveRunId(serverActiveRunId);
setIsTraining(true);
} else {
// Ensure we are not stuck in training state
setIsTraining(false);
setActiveRunId(null);
}
}
}, [expanded, runs, activeRunId]);
}).finally(() => setIsLoadingRuns(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded]);
// Poll while training is active
useEffect(() => {
if (isTraining && activeRunId) {
pollIntervalRef.current = setInterval(async () => {
const updatedRuns = await fetchRuns();
const activeRun = updatedRuns.find((r) => r.run_id === activeRunId);
if (activeRun && activeRun.status !== 'running') {
const [updatedRuns, serverActiveRunId] = await Promise.all([fetchRuns(), fetchActiveRun()]);
// If the server says nothing is active, training is done
if (!serverActiveRunId) {
setIsTraining(false);
const finishedRun = updatedRuns.find((r) => r.run_id === activeRunId);
setActiveRunId(null);
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
if (activeRun.status === 'completed') {
const metrics = activeRun.metrics_summary;
if (finishedRun?.status === 'completed') {
const metrics = finishedRun.metrics_summary;
const metricStr = metrics
? Object.entries(metrics)
.map(([k, v]) => `${k}: ${typeof v === 'number' ? (v * 100).toFixed(1) + '%' : v}`)
@ -137,10 +152,10 @@ export default function TrainingPanel() {
type: 'success',
text: `Training complete!${metricStr ? ' ' + metricStr : ''}`,
});
} else {
} else if (finishedRun?.status === 'failed') {
setStatusMessage({
type: 'error',
text: activeRun.error || 'Training failed',
text: finishedRun.error || 'Training failed',
});
}
}
@ -155,7 +170,7 @@ export default function TrainingPanel() {
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [isTraining, activeRunId, fetchRuns]);
}, [isTraining, activeRunId, fetchRuns, fetchActiveRun]);
const handleStartTraining = async () => {
setStatusMessage(null);
@ -192,6 +207,27 @@ export default function TrainingPanel() {
}
};
const handleDeleteRun = async (runId: string) => {
setDeletingRunIds((prev) => new Set(prev).add(runId));
try {
const res = await fetch(`/api/training/runs/${runId}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setStatusMessage({ type: 'error', text: data.error || data.detail || 'Failed to delete run' });
return;
}
await fetchRuns();
} catch {
setStatusMessage({ type: 'error', text: 'Failed to delete run' });
} finally {
setDeletingRunIds((prev) => {
const next = new Set(prev);
next.delete(runId);
return next;
});
}
};
const datasetMissing = datasetInfo !== null && !datasetInfo.exists;
const canTrain = !isTraining && !datasetMissing && datasetInfo !== null;
@ -301,7 +337,19 @@ export default function TrainingPanel() {
<span className="text-foreground font-medium capitalize">
{run.model_type.replace('_', ' ')}
</span>
<StatusBadge status={run.status} />
<div className="flex items-center gap-1">
<StatusBadge status={run.status} />
{run.status !== 'running' && (
<button
onClick={() => handleDeleteRun(run.run_id)}
disabled={deletingRunIds.has(run.run_id)}
title="Delete run"
className="text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
)}
</div>
</div>
<div className="text-muted-foreground">{formatDate(run.created_at)}</div>
{run.status === 'completed' && run.metrics_summary && (