frontend: Recompute all button on /scenarios + live queue depth
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Header gets a third button: "Recompute all". POSTs /recompute
(202-accepted, async background worker drains the queue). Once
work is in flight, the existing /healthz query auto-refetches
every 3s and the button label switches to "Computing… (queue N)";
flips back to idle when the queue drains.

Removes the need to drop to CLI (`python -m fire_planner
recompute-all`) for routine refreshes. Bearer auth still applies in
prod once API_BEARER_TOKEN is set — frontend will need a token
header in that mode (TODO).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:32:34 +00:00
parent 2fc92c12f5
commit 95b4b4ddd7
2 changed files with 47 additions and 1 deletions

View file

@ -40,6 +40,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
health: () => request<{ status: string; queue_depth: number }>('/healthz'),
recompute: (body?: Record<string, unknown>) =>
request<{ status: string; depth: number }>('/recompute', {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
networth: {
current: () =>
request<{

View file

@ -5,7 +5,7 @@
* The Cartesian set is whatever the latest /recompute produced (default
* 120 scenarios). User scenarios survive recomputes.
*/
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
@ -18,12 +18,35 @@ export function Scenarios() {
const [filter, setFilter] = useState<Filter>('all');
const [selected, setSelected] = useState<Set<number>>(new Set());
const navigate = useNavigate();
const qc = useQueryClient();
const scenarios = useQuery({
queryKey: ['scenarios', filter],
queryFn: () => api.scenarios.list(filter === 'all' ? undefined : filter),
});
// Poll healthz once a recompute is in flight so the user sees the
// queue empty in roughly real time.
const health = useQuery({
queryKey: ['health'],
queryFn: api.health,
refetchInterval: (q) => {
const data = q.state.data as { queue_depth: number } | undefined;
return data && data.queue_depth > 0 ? 3000 : false;
},
});
const recompute = useMutation({
mutationFn: () => api.recompute(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['health'] });
qc.invalidateQueries({ queryKey: ['scenarios'] });
},
});
const queueDepth = health.data?.queue_depth ?? 0;
const isComputing = queueDepth > 0;
const toggle = (id: number) =>
setSelected((prev) => {
const next = new Set(prev);
@ -57,6 +80,19 @@ export function Scenarios() {
Compare {selected.size}
</button>
)}
<button
type="button"
onClick={() => recompute.mutate()}
disabled={recompute.isPending || isComputing}
title="Re-run the full Cartesian (default 120 scenarios) and persist."
className="rounded-md border border-slate-300 bg-white text-sm font-medium px-4 py-2 hover:bg-slate-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{isComputing
? `Computing… (queue ${queueDepth})`
: recompute.isPending
? 'Queueing…'
: 'Recompute all'}
</button>
<Link
to="/scenarios/new"
className="rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800"
@ -65,6 +101,11 @@ export function Scenarios() {
</Link>
</div>
</header>
{recompute.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
{String((recompute.error as Error)?.message ?? recompute.error)}
</div>
)}
{scenarios.isLoading ? (
<p className="text-slate-500">Loading</p>