Add job status endpoint, persist lastJobId; replace in-process queue with BullMQ-based queue and worker; link job status in UI

This commit is contained in:
2026-05-16 14:28:34 +02:00
parent d9f9150d68
commit 3234a09096
6 changed files with 174 additions and 4 deletions
+62 -2
View File
@@ -13,6 +13,7 @@ interface LoaderData {
bars: any[];
timeframe: string;
range: string;
stockRecord?: any;
}
const TIMEFRAMES = [
@@ -49,6 +50,17 @@ export async function loader({ params, request }: { params: { ticker: string };
let position = null;
let orders = [];
let bars = [];
let stockRecord: any = null;
let stockRecord: any = null;
try {
const stockRes = await fetch(`${baseUrl}/api/stocks`);
if (stockRes.ok) {
const list = await stockRes.json();
stockRecord = list.find((s: any) => s.ticker === ticker) || null;
}
} catch (e) {
// ignore
}
try {
// Fetch positions
@@ -65,6 +77,17 @@ export async function loader({ params, request }: { params: { ticker: string };
const barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&range=${range}`);
const barsData = barsRes.ok ? await barsRes.json() : null;
bars = barsData?.bars || [];
// Fetch stock record (to get lastJobId)
try {
const stockRes = await fetch(`${baseUrl}/api/stocks`);
if (stockRes.ok) {
const list = await stockRes.json();
stockRecord = list.find((s: any) => s.ticker === ticker) || null;
}
} catch (e) {
// ignore
}
} catch (err) {
console.error(`analyze/${ticker}: loader error`, err);
}
@@ -73,7 +96,7 @@ export async function loader({ params, request }: { params: { ticker: string };
}
export default function StockDetail() {
const { ticker, position, orders, bars, timeframe, range } = useLoaderData() as LoaderData;
const { ticker, position, orders, bars, timeframe, range, stockRecord } = useLoaderData() as LoaderData;
const navigate = useNavigate();
const location = useLocation();
@@ -83,6 +106,8 @@ export default function StockDetail() {
const [decision, setDecision] = useState<TradingDecision | null>(null);
const [showAnalysts, setShowAnalysts] = useState(false);
const [showDebate, setShowDebate] = useState(false);
const [jobStatus, setJobStatus] = useState<any>(null);
const [jobPolling, setJobPolling] = useState(false);
// Cache key for this ticker
const cacheKey = `tradinggraph-${ticker}`;
@@ -100,7 +125,32 @@ export default function StockDetail() {
console.error("Failed to parse cached trading graph data:", e);
}
}
}, [cacheKey]);
// If stock record contains a job id, start polling job status
if (stockRecord?.lastJobId) {
setJobPolling(true);
let cancelled = false;
const poll = async () => {
try {
const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`);
if (!res.ok) return;
const j = await res.json();
if (cancelled) return;
setJobStatus(j);
if (j.state === "completed" || j.state === "failed") {
setJobPolling(false);
cancelled = true;
return;
}
} catch (e) {
console.warn("Failed to poll job status:", e);
}
setTimeout(poll, 1000);
};
poll();
return () => { cancelled = true; };
}
}, [cacheKey, stockRecord]);
const updateParams = (newTimeframe: string, newRange: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -245,6 +295,16 @@ export default function StockDetail() {
>
{analysisLoading ? "Running Trading Graph..." : "Run Trading Graph Analysis"}
</button>
{/* Job status link */}
{stockRecord?.lastJobId && (
<div className="mt-3 text-sm text-gray-600">
Background job: <a href={`/api/jobs/${stockRecord.lastJobId}`} className="text-blue-600 hover:underline">{stockRecord.lastJobId}</a>
{jobStatus && (
<span className="ml-3">Status: <strong>{jobStatus.state}</strong></span>
)}
</div>
)}
</div>
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
+17
View File
@@ -0,0 +1,17 @@
import { Queue } from "bullmq";
export async function loader({ params }: { params: { jobId: string } }) {
const jobId = params.jobId;
if (!jobId) return Response.json({ error: "jobId required" }, { status: 400 });
try {
const q = new Queue("analyze", { connection: process.env.REDIS_URL ? { connection: process.env.REDIS_URL } : undefined });
const job = await q.getJob(jobId);
if (!job) return Response.json({ error: "Job not found" }, { status: 404 });
const state = await job.getState();
return Response.json({ id: job.id, state, failedReason: job.failedReason || null, returnValue: job.returnvalue || null });
} catch (err) {
console.error("/api/jobs loader error:", err);
return Response.json({ error: "internal" }, { status: 500 });
}
}
+3
View File
@@ -27,6 +27,7 @@ export async function action({ request }: { request: Request }) {
const lastDecision = formData.get("lastDecision")?.toString();
const lastExplanation = formData.get("lastExplanation")?.toString();
const lastExecutionPlan = formData.get("lastExecutionPlan")?.toString();
const lastJobId = formData.get("lastJobId")?.toString();
// Upsert the stock record so ticker is ensured and optional fields are saved
const stock = await db.stock.upsert({
@@ -35,12 +36,14 @@ export async function action({ request }: { request: Request }) {
lastDecision: lastDecision ?? undefined,
lastExplanation: lastExplanation ?? undefined,
lastExecutionPlan: lastExecutionPlan ?? undefined,
lastJobId: lastJobId ?? undefined,
},
create: {
ticker,
lastDecision: lastDecision ?? undefined,
lastExplanation: lastExplanation ?? undefined,
lastExecutionPlan: lastExecutionPlan ?? undefined,
lastJobId: lastJobId ?? undefined,
},
});