fix: show running job status when viewing in-progress jobs and auto-poll for completion

This commit is contained in:
2026-05-16 21:54:54 +02:00
parent 115363baad
commit 898f4f48dc
+72 -11
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useLoaderData, useNavigate, useLocation, Link } from "react-router"; import { useLoaderData, Link, useSearchParams } from "react-router";
import TradingViewChart from "../components/TradingViewChart"; import TradingViewChart from "../components/TradingViewChart";
import Navbar from "../components/Navbar"; import Navbar from "../components/Navbar";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -324,8 +324,27 @@ function JobHistoryInline({ ticker, runningJob, latestJob, onJobSelect }: { tick
); );
} }
function TradingResultCard({ job, expanded }: { job: any; expanded: boolean }) { function TradingResultCard({ job, expanded, onRefresh }: { job: any; expanded: boolean; onRefresh?: () => void }) {
const decision = job?.returnValue; const decision = job?.returnValue;
const isRunning = job && !decision && (job.state === "active" || job.state === "waiting" || job.state === "queued" || job.state === "processing");
if (isRunning) {
return (
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800">Job in Progress</h3>
{onRefresh && <button onClick={onRefresh} className="text-xs text-blue-600 hover:underline">Refresh</button>}
</div>
<div className="flex items-center gap-3 mb-3">
<span className="animate-pulse w-2.5 h-2.5 bg-blue-500 rounded-full" />
<span className="font-mono text-xs text-gray-600 truncate">{job.id}</span>
{stateBadge(job.state)}
</div>
<p className="text-xs text-gray-500">The TradingGraph analysis is running. Results will appear here when complete.</p>
</div>
);
}
if (!decision) return null; if (!decision) return null;
return ( return (
@@ -367,8 +386,7 @@ function TradingResultCard({ job, expanded }: { job: any; expanded: boolean }) {
export default function StockDetail() { export default function StockDetail() {
const { ticker, position, orders, bars, timeframe, range, stockRecord, latestJob, runningJob } = useLoaderData() as LoaderData; const { ticker, position, orders, bars, timeframe, range, stockRecord, latestJob, runningJob } = useLoaderData() as LoaderData;
const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisLoading, setAnalysisLoading] = useState(false);
const [jobStatus, setJobStatus] = useState<any>(runningJob || null); const [jobStatus, setJobStatus] = useState<any>(runningJob || null);
@@ -376,6 +394,40 @@ export default function StockDetail() {
const [showExpanded, setShowExpanded] = useState(false); const [showExpanded, setShowExpanded] = useState(false);
const [selectedJob, setSelectedJob] = useState<any>(latestJob || null); const [selectedJob, setSelectedJob] = useState<any>(latestJob || null);
// Poll selected job if it's in progress
useEffect(() => {
if (!selectedJob?.id) return;
const state = selectedJob.state;
if (state !== "active" && state !== "waiting" && state !== "queued" && state !== "processing") return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let currentController: AbortController | null = null;
const poll = async () => {
if (currentController) { try { currentController.abort(); } catch (e) {} }
currentController = new AbortController();
try {
const res = await fetch(`/api/jobs/${selectedJob.id}`, { signal: currentController.signal });
if (!res.ok) { if (!cancelled) timer = setTimeout(poll, 2000); return; }
const j = await res.json();
if (cancelled) return;
setSelectedJob(j);
if (j.state === "completed" || j.state === "failed") {
cancelled = true;
// Also update the main job status if this is the same job
if (jobStatus?.id === j.id) setJobStatus(j);
}
} catch (e: any) {
if (e?.name !== "AbortError") console.warn("Selected job poll error:", e);
} finally {
if (!cancelled) timer = setTimeout(poll, 2000);
}
};
poll();
return () => { cancelled = true; if (timer) clearTimeout(timer); if (currentController) { try { currentController.abort(); } catch (e) {} } };
}, [selectedJob?.id, selectedJob?.state, jobStatus?.id]);
const cacheKey = `tradinggraph-${ticker}`; const cacheKey = `tradinggraph-${ticker}`;
// Poll running job if exists // Poll running job if exists
@@ -446,10 +498,7 @@ export default function StockDetail() {
}, [ticker]); }, [ticker]);
const updateParams = (newTimeframe: string, newRange: string) => { const updateParams = (newTimeframe: string, newRange: string) => {
const searchParams = new URLSearchParams(location.search); setSearchParams({ timeframe: newTimeframe, range: newRange }, { replace: true, preventScrollReset: true });
searchParams.set("timeframe", newTimeframe);
searchParams.set("range", newRange);
navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });
}; };
const runTradingGraph = async () => { const runTradingGraph = async () => {
@@ -538,6 +587,18 @@ export default function StockDetail() {
bar.time && bar.open != null && index === arr.findIndex((b: any) => b.time === bar.time) bar.time && bar.open != null && index === arr.findIndex((b: any) => b.time === bar.time)
) || []; ) || [];
const refreshSelectedJob = useCallback(async () => {
if (!selectedJob?.id) return;
try {
const res = await fetch(`/api/jobs/${selectedJob.id}`);
if (res.ok) {
const j = await res.json();
setSelectedJob(j);
if (jobStatus?.id === j.id) setJobStatus(j);
}
} catch (e) { console.warn("Refresh job error:", e); }
}, [selectedJob?.id, jobStatus?.id]);
const displayJob = selectedJob || (jobStatus?.state === "completed" ? jobStatus : null); const displayJob = selectedJob || (jobStatus?.state === "completed" ? jobStatus : null);
return ( return (
@@ -560,11 +621,11 @@ export default function StockDetail() {
<div className="bg-white rounded-xl shadow-lg p-4 mb-4 border border-gray-200"> <div className="bg-white rounded-xl shadow-lg p-4 mb-4 border border-gray-200">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<span className="text-xs text-gray-500">Timeframe:</span> <span className="text-xs text-gray-500">Timeframe:</span>
<select value={timeframe} onChange={(e) => updateParams(e.target.value, range)} className="border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"> <select value={timeframe} onChange={(e) => updateParams(e.target.value, range)} className="border border-gray-300 rounded px-2 py-1 text-sm text-gray-900 bg-white focus:ring-2 focus:ring-blue-500">
{TIMEFRAMES.map((tf) => <option key={tf.value} value={tf.value}>{tf.label}</option>)} {TIMEFRAMES.map((tf) => <option key={tf.value} value={tf.value}>{tf.label}</option>)}
</select> </select>
<span className="text-xs text-gray-500">Range:</span> <span className="text-xs text-gray-500">Range:</span>
<select value={range} onChange={(e) => updateParams(timeframe, e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"> <select value={range} onChange={(e) => updateParams(timeframe, e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm text-gray-900 bg-white focus:ring-2 focus:ring-blue-500">
{RANGES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)} {RANGES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select> </select>
</div> </div>
@@ -578,7 +639,7 @@ export default function StockDetail() {
</div> </div>
{/* Latest analysis result */} {/* Latest analysis result */}
{displayJob && <TradingResultCard job={displayJob} expanded={showExpanded} />} {displayJob && <TradingResultCard job={displayJob} expanded={showExpanded} onRefresh={refreshSelectedJob} />}
{/* Expand toggle */} {/* Expand toggle */}
{displayJob && ( {displayJob && (