frontend: Recompute all button on /scenarios + live queue depth
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
2fc92c12f5
commit
95b4b4ddd7
2 changed files with 47 additions and 1 deletions
|
|
@ -40,6 +40,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
health: () => request<{ status: string; queue_depth: number }>('/healthz'),
|
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: {
|
networth: {
|
||||||
current: () =>
|
current: () =>
|
||||||
request<{
|
request<{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* The Cartesian set is whatever the latest /recompute produced (default
|
* The Cartesian set is whatever the latest /recompute produced (default
|
||||||
* 120 scenarios). User scenarios survive recomputes.
|
* 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 { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -18,12 +18,35 @@ export function Scenarios() {
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const scenarios = useQuery({
|
const scenarios = useQuery({
|
||||||
queryKey: ['scenarios', filter],
|
queryKey: ['scenarios', filter],
|
||||||
queryFn: () => api.scenarios.list(filter === 'all' ? undefined : 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) =>
|
const toggle = (id: number) =>
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -57,6 +80,19 @@ export function Scenarios() {
|
||||||
Compare {selected.size}
|
Compare {selected.size}
|
||||||
</button>
|
</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
|
<Link
|
||||||
to="/scenarios/new"
|
to="/scenarios/new"
|
||||||
className="rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 ? (
|
{scenarios.isLoading ? (
|
||||||
<p className="text-slate-500">Loading…</p>
|
<p className="text-slate-500">Loading…</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue