Add ability to delete the loss logs from a selected range on the loss graph

This commit is contained in:
Jaret Burkett
2026-08-23 09:57:47 -06:00
parent 27a03a91f2
commit 8436c407f6
3 changed files with 152 additions and 10 deletions

View File

@@ -96,3 +96,72 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
await closeDb(db);
}
}
function run(db: sqlite3.Database, sql: string, params: any[] = []) {
return new Promise<void>((resolve, reject) => {
db.run(sql, params, (err) => (err ? reject(err) : resolve()));
});
}
// Delete every logged step in [min_step, max_step] (inclusive) across all
// metric keys. Used by the loss graph's "Delete Selected Range" action.
export async function DELETE(request: NextRequest, { params }: { params: { jobID: string } }) {
const { jobID } = await params;
const job = await prisma.job.findUnique({ where: { id: jobID } });
if (!job) return NextResponse.json({ error: 'Job not found' }, { status: 404 });
let body: { min_step?: unknown; max_step?: unknown } = {};
try {
body = await request.json();
} catch {
// fall through to validation below
}
const minStep = Number(body.min_step);
const maxStep = Number(body.max_step);
if (!Number.isFinite(minStep) || !Number.isFinite(maxStep) || minStep > maxStep) {
return NextResponse.json({ error: 'min_step and max_step must be numbers with min_step <= max_step' }, { status: 400 });
}
const trainingFolder = await getTrainingFolder();
const jobFolder = path.join(trainingFolder, job.name);
const logPath = path.join(jobFolder, 'loss_log.db');
try {
await fs.promises.access(logPath);
} catch {
return NextResponse.json({ error: 'No loss log for this job' }, { status: 404 });
}
const db = openDb(logPath);
try {
await run(db, 'BEGIN;');
try {
// The FK cascade on metrics only fires with PRAGMA foreign_keys=ON, so
// delete metrics explicitly rather than relying on it.
await run(db, `DELETE FROM metrics WHERE step >= ? AND step <= ?;`, [minStep, maxStep]);
await run(db, `DELETE FROM steps WHERE step >= ? AND step <= ?;`, [minStep, maxStep]);
await run(
db,
`DELETE FROM metric_keys WHERE NOT EXISTS (SELECT 1 FROM metrics WHERE metrics.key = metric_keys.key);`
);
await run(
db,
`UPDATE metric_keys SET
first_seen_step = (SELECT MIN(step) FROM metrics WHERE metrics.key = metric_keys.key),
last_seen_step = (SELECT MAX(step) FROM metrics WHERE metrics.key = metric_keys.key);`
);
await run(db, 'COMMIT;');
} catch (e) {
await run(db, 'ROLLBACK;').catch(() => { });
throw e;
}
return NextResponse.json({ ok: true, min_step: minStep, max_step: maxStep });
} catch (e: any) {
console.error('Error deleting loss range:', e);
return NextResponse.json({ error: e?.message ?? 'Failed to delete range' }, { status: 500 });
} finally {
await closeDb(db);
}
}

View File

@@ -5,6 +5,7 @@ import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { openConfirm } from '@/components/ConfirmModal';
interface Props {
job: Job;
@@ -138,7 +139,7 @@ function dulledColor(rgba: string): string {
}
export default function JobLossGraph({ job }: Props) {
const { series, lossKeys, status, refreshLoss } = useJobLossLog(job.id, 2000);
const { series, lossKeys, status, refreshLoss, deleteRange } = useJobLossLog(job.id, 2000);
// Controls
const [useLogScale, setUseLogScale] = useState(false);
@@ -222,7 +223,8 @@ export default function JobLossGraph({ job }: Props) {
setEnabled(prev => {
const next = { ...prev };
for (const k of lossKeys) {
if (next[k] === undefined) next[k] = persistedEnabledRef.current?.[k] ?? (k === 'loss/loss' || k === 'val/loss');
if (next[k] === undefined)
next[k] = persistedEnabledRef.current?.[k] ?? (k === 'loss/loss' || k === 'val/loss');
}
for (const k of Object.keys(next)) {
if (!lossKeys.includes(k)) delete next[k];
@@ -529,6 +531,46 @@ export default function JobLossGraph({ job }: Props) {
u.setScale('x', { min: xs[0], max: xs[xs.length - 1] });
}, []);
const [deleting, setDeleting] = useState(false);
// Delete every logged step inside the current zoom window, then zoom back out.
const handleDeleteSelectedRange = useCallback(() => {
const u = uplotRef.current;
if (!u) return;
const xs = u.data[0] as number[];
if (!xs || !xs.length) return;
const sx = u.scales.x;
if (sx.min == null || sx.max == null) return;
// Snap the visible window to whole steps that actually fall inside it.
const minStep = Math.ceil(sx.min);
const maxStep = Math.floor(sx.max);
if (minStep > maxStep) return;
const count = xs.filter(x => x >= minStep && x <= maxStep).length;
openConfirm({
title: 'Delete Selected Range',
message: `Permanently delete steps ${minStep.toLocaleString()}–${maxStep.toLocaleString()} (${count.toLocaleString()} plotted points) from the loss log for all metrics? This cannot be undone.`,
type: 'danger',
confirmText: 'Delete',
onConfirm: async () => {
setDeleting(true);
try {
await deleteRange(minStep, maxStep);
// Zoom out: clear the zoom flag first so the data-update effect
// refits the x-scale to the remaining data instead of holding the
// old window.
isZoomedRef.current = false;
setIsZoomed(false);
handleResetZoom();
} catch (e) {
console.error('Error deleting loss range:', e);
} finally {
setDeleting(false);
}
},
});
}, [deleteRange, handleResetZoom]);
const totalPoints = built.data[0]?.length ?? 0;
return (
@@ -568,13 +610,23 @@ export default function JobLossGraph({ job }: Props) {
) : (
<>
{isZoomed && (
<button
type="button"
onClick={handleResetZoom}
className="absolute top-2 right-2 z-10 px-2 py-1 rounded text-xs bg-blue-600/80 hover:bg-blue-600 text-white border border-blue-500/50"
>
Reset zoom
</button>
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
<button
type="button"
onClick={handleDeleteSelectedRange}
disabled={deleting}
className="px-2 py-1 rounded text-xs bg-red-600/80 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white border border-red-500/50"
>
{deleting ? 'Deleting...' : 'Delete Selected Range'}
</button>
<button
type="button"
onClick={handleResetZoom}
className="px-2 py-1 rounded text-xs bg-blue-600/80 hover:bg-blue-600 text-white border border-blue-500/50"
>
Reset zoom
</button>
</div>
)}
<div ref={chartHostRef} className="absolute top-0 left-0 right-0 bottom-2 overflow-hidden">
<div ref={containerRef} />

View File

@@ -116,6 +116,27 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
}
}, [jobID, reloadInterval]);
// Delete every logged step in [minStep, maxStep] from the on-disk log, then
// drop those points locally so the chart updates without a full reload.
const deleteRange = useCallback(
async (minStep: number, maxStep: number) => {
if (!jobID) return;
await apiClient.delete(`/api/jobs/${jobID}/loss`, { data: { min_step: minStep, max_step: maxStep } });
setSeries(prev => {
const next: SeriesMap = {};
for (const k of Object.keys(prev)) {
const kept = prev[k].filter(p => p.step < minStep || p.step > maxStep);
next[k] = kept;
// Re-anchor incremental polling to the new tail so a deleted tail
// isn't treated as already-fetched.
lastStepByKeyRef.current[k] = kept.length ? kept[kept.length - 1].step : null;
}
return next;
});
},
[jobID],
);
// reset when job changes. Declared before the poll loop so the reset runs
// before the first fetch when jobID changes.
useEffect(() => {
@@ -128,5 +149,5 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
usePollLoop(refreshLoss, reloadInterval, [jobID]);
return { series, keys, lossKeys, status, refreshLoss, setSeries };
return { series, keys, lossKeys, status, refreshLoss, deleteRange, setSeries };
}