From 8436c407f655d22c7ef3a4007524a0ce5e46277e Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Sun, 23 Aug 2026 09:57:47 -0600 Subject: [PATCH] Add ability to delete the loss logs from a selected range on the loss graph --- ui/src/app/api/jobs/[jobID]/loss/route.ts | 69 ++++++++++++++++++++++ ui/src/components/JobLossGraph.tsx | 70 ++++++++++++++++++++--- ui/src/hooks/useJobLossLog.tsx | 23 +++++++- 3 files changed, 152 insertions(+), 10 deletions(-) diff --git a/ui/src/app/api/jobs/[jobID]/loss/route.ts b/ui/src/app/api/jobs/[jobID]/loss/route.ts index 89ac0df..6ad0e0b 100644 --- a/ui/src/app/api/jobs/[jobID]/loss/route.ts +++ b/ui/src/app/api/jobs/[jobID]/loss/route.ts @@ -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((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); + } +} diff --git a/ui/src/components/JobLossGraph.tsx b/ui/src/components/JobLossGraph.tsx index 766dc1a..cda523f 100644 --- a/ui/src/components/JobLossGraph.tsx +++ b/ui/src/components/JobLossGraph.tsx @@ -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 && ( - +
+ + +
)}
diff --git a/ui/src/hooks/useJobLossLog.tsx b/ui/src/hooks/useJobLossLog.tsx index 380a5e6..fb80575 100644 --- a/ui/src/hooks/useJobLossLog.tsx +++ b/ui/src/hooks/useJobLossLog.tsx @@ -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 }; }