fix: show running job status when viewing in-progress jobs and auto-poll for completion
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user