Skip to content

Commit 582a69d

Browse files
authored
feat(web): stop, replay and remove runs from the analysis history (#7)
1 parent 6159458 commit 582a69d

4 files changed

Lines changed: 185 additions & 5 deletions

File tree

apps/web/src/actions.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
addAthlete,
1313
addVideo,
1414
analyzeProject,
15+
cancelJob,
1516
clipsFromMoments,
1617
createProject,
1718
createReel,
19+
getJob,
1820
isReelEelError,
1921
listExports,
2022
listJobLogsSince,
@@ -25,6 +27,7 @@ import {
2527
projectDir,
2628
removeAthlete,
2729
removeExport,
30+
removeJob,
2831
removeProject,
2932
removeVideo,
3033
renderReel,
@@ -209,6 +212,37 @@ const uploadFailed = (
209212
return back(c, to, undefined, `${described.error}${hint}${suffix}`);
210213
};
211214

215+
/**
216+
* Analyses running in this process, by job id.
217+
*
218+
* Cancelling has to reach the work, not just relabel the row: `cancelJob`
219+
* alone would mark a job canceled while FFmpeg and the detector carried on
220+
* chewing through the video. The signal is what makes Stop mean stop.
221+
*/
222+
const runningAnalyses = new Map<string, AbortController>();
223+
224+
/** Kicks off an analysis and keeps hold of its cancel handle. */
225+
const startAnalysis = (root: string, options: { preset: Preset; videoId?: string }): void => {
226+
const controller = new AbortController();
227+
let jobId: string | null = null;
228+
229+
void analyzeProject(root, {
230+
preset: options.preset,
231+
...(options.videoId === undefined ? {} : { videoId: options.videoId }),
232+
signal: controller.signal,
233+
onStart: (job) => {
234+
jobId = job.id;
235+
runningAnalyses.set(job.id, controller);
236+
},
237+
})
238+
.catch((error: unknown) => {
239+
process.stderr.write(`analysis failed: ${failed(error)}\n`);
240+
})
241+
.finally(() => {
242+
if (jobId !== null) runningAnalyses.delete(jobId);
243+
});
244+
};
245+
212246
export const registerActions = (app: Hono): void => {
213247
const guard = async (c: Context): Promise<Response | null> =>
214248
originAllowed(c) ? null : c.text('Bad origin', 403);
@@ -384,6 +418,78 @@ export const registerActions = (app: Hono): void => {
384418
return c.json({ ok: true, upload: view(record) });
385419
});
386420

421+
// ── Job controls: stop, replay, discard ───────────────────────────────────
422+
//
423+
// The pipeline could already cancel and re-run; none of it was reachable from
424+
// the browser, so a run that went wrong could only be waited out.
425+
426+
/** Stop. Aborts the actual work, then records the cancellation. */
427+
app.post('/projects/:ref/jobs/:id/cancel', async (c) => {
428+
const bad = await guard(c);
429+
if (bad !== null) return bad;
430+
const ref = c.req.param('ref') ?? '';
431+
const to = `/projects/${encodeURIComponent(ref)}`;
432+
433+
try {
434+
const root = await rootOf(c);
435+
const id = c.req.param('id') ?? '';
436+
// Abort first: the job row is the record, the signal is the mechanism.
437+
runningAnalyses.get(id)?.abort();
438+
runningAnalyses.delete(id);
439+
await cancelJob(root, id);
440+
return back(c, to, 'Analysis canceled');
441+
} catch (error) {
442+
return back(c, to, undefined, failed(error));
443+
}
444+
});
445+
446+
/**
447+
* Replay. Re-runs with the settings the original used, rather than whatever
448+
* the form happens to show now — the point of replaying a specific run.
449+
*/
450+
app.post('/projects/:ref/jobs/:id/retry', async (c) => {
451+
const bad = await guard(c);
452+
if (bad !== null) return bad;
453+
const ref = c.req.param('ref') ?? '';
454+
const to = `/projects/${encodeURIComponent(ref)}`;
455+
456+
try {
457+
const root = await rootOf(c);
458+
const job = await getJob(root, c.req.param('id') ?? '');
459+
if (job.status === 'running' || job.status === 'queued') {
460+
return back(c, to, undefined, 'That analysis is still running.');
461+
}
462+
463+
const params = job.params as { preset?: string; videoIds?: unknown };
464+
const preset = (typeof params.preset === 'string' ? params.preset : 'balanced') as Preset;
465+
const videoIds = Array.isArray(params.videoIds) ? params.videoIds : [];
466+
// One video means it was a single-video run; several means "all", and
467+
// analyzeProject reads that as "no filter".
468+
const videoId = videoIds.length === 1 && typeof videoIds[0] === 'string' ? videoIds[0] : undefined;
469+
470+
startAnalysis(root, { preset, ...(videoId === undefined ? {} : { videoId }) });
471+
return back(c, to, 'Analysis restarted — watch the live log');
472+
} catch (error) {
473+
return back(c, to, undefined, failed(error));
474+
}
475+
});
476+
477+
/** Discard a finished run from the history. */
478+
app.post('/projects/:ref/jobs/:id/delete', async (c) => {
479+
const bad = await guard(c);
480+
if (bad !== null) return bad;
481+
const ref = c.req.param('ref') ?? '';
482+
const to = `/projects/${encodeURIComponent(ref)}`;
483+
484+
try {
485+
const root = await rootOf(c);
486+
await removeJob(root, c.req.param('id') ?? '');
487+
return back(c, to, 'Removed from history');
488+
} catch (error) {
489+
return back(c, to, undefined, failed(error));
490+
}
491+
});
492+
387493
/**
388494
* Downloads a rendered reel.
389495
*
@@ -791,11 +897,7 @@ export const registerActions = (app: Hono): void => {
791897

792898
// Analysis takes minutes; holding the request open would time out at the
793899
// proxy. It records a job, so the page can report progress instead.
794-
void analyzeProject(root, { preset, ...(videoId === undefined ? {} : { videoId }) }).catch(
795-
(error: unknown) => {
796-
process.stderr.write(`analysis failed: ${failed(error)}\n`);
797-
},
798-
);
900+
startAnalysis(root, { preset, ...(videoId === undefined ? {} : { videoId }) });
799901

800902
return back(c, to, 'Analysis started — watch the live log');
801903
} catch (error) {

apps/web/src/client/jobs.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const JobLog = ({ base }: { base: string }) => {
6767
const [jobs, setJobs] = useState<Job[]>([]);
6868
const [lines, setLines] = useState<LogLine[]>([]);
6969
const [connection, setConnection] = useState<Connection>('connecting');
70+
const [busy, setBusy] = useState<string | null>(null);
71+
const [actionError, setActionError] = useState<string | null>(null);
7072
const pane = useRef<HTMLDivElement | null>(null);
7173
/** Forces a repaint once a second so elapsed time and ETA actually advance. */
7274
const [, tick] = useState(0);
@@ -171,6 +173,34 @@ const JobLog = ({ base }: { base: string }) => {
171173
const running = jobs.filter((job) => job.status === 'running' || job.status === 'queued');
172174
const recent = jobs.slice(0, 5);
173175

176+
/**
177+
* Job controls post as ordinary form actions so the no-JavaScript page can
178+
* use the identical routes; here they go over fetch to avoid losing the log.
179+
* No optimistic update — the SSE feed reports the real state a moment later,
180+
* and inventing one would only risk disagreeing with it.
181+
*/
182+
const act = async (job: Job, action: 'cancel' | 'retry' | 'delete'): Promise<void> => {
183+
if (action === 'delete' && !window.confirm('Remove this run from the history?')) return;
184+
if (action === 'cancel' && !window.confirm('Stop this analysis? Progress so far is lost.')) return;
185+
setBusy(job.id);
186+
try {
187+
const response = await fetch(`${base}/jobs/${job.id}/${action}`, {
188+
method: 'POST',
189+
headers: { accept: 'application/json' },
190+
});
191+
// These routes redirect for the no-JS path; a redirect is still a success.
192+
if (!response.ok && response.type !== 'opaqueredirect') {
193+
setActionError(`Could not ${action} that run (${response.status}).`);
194+
} else {
195+
setActionError(null);
196+
}
197+
} catch (error) {
198+
setActionError(error instanceof Error ? error.message : String(error));
199+
} finally {
200+
setBusy(null);
201+
}
202+
};
203+
174204
return (
175205
<div>
176206
{/* The page already has the "Analysis" heading; this only adds the state
@@ -218,6 +248,23 @@ const JobLog = ({ base }: { base: string }) => {
218248
{Math.round(job.progress * 100)}%
219249
{clock(job.etaSeconds) === '' ? '' : ` — ${clock(job.etaSeconds)}`}
220250
</span>
251+
252+
{job.status === 'running' || job.status === 'queued' ? (
253+
<button type="button" disabled={busy === job.id} onClick={() => void act(job, 'cancel')}>
254+
Stop
255+
</button>
256+
) : (
257+
<>
258+
{/* Replays with the settings that run used, not whatever the
259+
form shows now. */}
260+
<button type="button" disabled={busy === job.id} onClick={() => void act(job, 'retry')}>
261+
Replay
262+
</button>
263+
<button type="button" disabled={busy === job.id} onClick={() => void act(job, 'delete')}>
264+
Remove
265+
</button>
266+
</>
267+
)}
221268
</div>
222269

223270
{/* The bit that was missing entirely: why it failed. */}
@@ -226,6 +273,8 @@ const JobLog = ({ base }: { base: string }) => {
226273
))
227274
)}
228275

276+
{actionError === null ? null : <p class="pill reject upload-error">{actionError}</p>}
277+
229278
<div class="log-pane" ref={pane} onScroll={onScroll} role="log" aria-live="polite">
230279
{lines.length === 0 ? (
231280
<p class="muted">Waiting for the first stage…</p>

apps/web/src/views/pages.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export const ProjectPage: FC<ProjectView> = ({
337337
<th>Stage</th>
338338
<th>Progress</th>
339339
<th>Detail</th>
340+
<th />
340341
</tr>
341342
</thead>
342343
<tbody>
@@ -351,6 +352,26 @@ export const ProjectPage: FC<ProjectView> = ({
351352
<td class="muted">{job.stage ?? '—'}</td>
352353
<td>{Math.round(job.progress * 100)}%</td>
353354
<td class="muted">{job.error ?? '—'}</td>
355+
<td>
356+
{/* The same routes the live log posts to, so stop and
357+
replay work without JavaScript too. */}
358+
<div class="row">
359+
{job.status === 'running' || job.status === 'queued' ? (
360+
<form method="post" action={`${base}/jobs/${job.id}/cancel`}>
361+
<button type="submit">Stop</button>
362+
</form>
363+
) : (
364+
<>
365+
<form method="post" action={`${base}/jobs/${job.id}/retry`}>
366+
<button type="submit">Replay</button>
367+
</form>
368+
<form method="post" action={`${base}/jobs/${job.id}/delete`}>
369+
<button type="submit">Remove</button>
370+
</form>
371+
</>
372+
)}
373+
</div>
374+
</td>
354375
</tr>
355376
))}
356377
</tbody>

packages/core/src/analyze.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ export interface AnalyzeOptions {
125125
scoreOnly?: boolean;
126126
signal?: AbortSignal;
127127
onStage?: (stage: string, detail?: string) => void;
128+
/**
129+
* Called once the job row exists, before any work starts. The caller needs
130+
* the id at that moment to be able to cancel the run it just kicked off;
131+
* waiting for the returned result means waiting for the thing it wants to
132+
* interrupt.
133+
*/
134+
onStart?: (job: Job) => void;
128135
}
129136

130137
export interface AnalyzeResult {
@@ -177,6 +184,7 @@ export const analyzeProject = async (
177184
}
178185

179186
const job = await createJob(root, 'detection', { preset, videoIds: videos.map((v) => v.id) });
187+
options.onStart?.(job);
180188
const stage = async (name: string, progress: number, detail?: string): Promise<void> => {
181189
options.onStage?.(name, detail);
182190
await updateJob(root, job.id, { status: 'running', stage: name, progress });

0 commit comments

Comments
 (0)