feat(queue): adapter queue overhaul — pause/resume, error capture, scheduling

- Pause/resume persisted in queue_state; drain loop checks isPaused() and skips
- Error capture: every failed/backoff attempt logged to queue_errors with the
  full stack trace; admin queue page expands a failed job to stream it
- Retry controls: retryJob(key), retrySource(kind), clearDone(olderThanMs)
- Per-source scheduling: queue_schedules table + 30s enqueueDueSchedules loop
  (seed defaults sec-fetch 24h, yfinance 5min); admin UI lists/adds/deletes
- Startup recovery: interrupted in_flight jobs reset to pending on boot
- fix(edgar): archive URLs use the filer CIK (accession-number prefix), not the
  company CIK — resolves Cloudflare 429 that left SEC backfills sparse/empty
- Migration: add queue_errors, queue_schedules, queue_state tables plus error/
  scheduled_for columns on adapter_queue (idempotent ALTER on startup)

Verified: full sec-fetch backfill now succeeds for all watched symbols
(NVDA 14,675 institution filings, CIFR 274 insider txns, TSLA 6,011, etc.).
503 backend tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Investor Flow Build
2026-07-12 12:55:59 -04:00
co-authored by Claude
parent 55f07e6b42
commit ca385c1960
10 changed files with 1345 additions and 141 deletions
+614
View File
@@ -0,0 +1,614 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
interface QueueItem {
source: string;
status: string;
count: number;
last_attempt: string | null;
retry_count: number;
backoff_until: string | null;
}
interface QueueStatus {
paused: boolean;
queued: number;
in_flight: number;
failed: number;
backoff: number;
done: number;
backoff_until: string | null;
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
}
interface ErrorLogEntry {
attempt: number;
errorMessage: string | null;
errorStack: string | null;
ts: string;
}
interface ScheduleEntry {
sourceKind: string;
intervalMs: number;
lastEnqueued: string | null;
nextEnqueue: string | null;
}
function Spinner() {
return (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
);
}
function Banner({ type, message, onDismiss }: { type: "success" | "error"; message: string; onDismiss?: () => void }) {
const styles = type === "success"
? "border-green-500/30 bg-green-950/20 text-up"
: "border-red-500/30 bg-red-950/20 text-danger";
return (
<div className={`rounded-lg border ${styles} px-4 py-3 text-sm flex items-center justify-between`}>
<span>{message}</span>
{onDismiss && (
<button onClick={onDismiss} className="text-fg-faint hover:text-fg ml-4">&times;</button>
)}
</div>
);
}
function formatTimestamp(ts: string | null): string {
if (!ts) return "Never";
try {
const d = new Date(ts);
return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return ts.slice(0, 16);
}
}
function msToHuman(ms: number): string {
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
if (ms < 86400000) return `${Math.round(ms / 3600000)}h`;
return `${Math.round(ms / 86400000)}d`;
}
export default function QueuePage() {
const [queue, setQueue] = useState<QueueItem[]>([]);
const [status, setStatus] = useState<QueueStatus | null>(null);
const [schedules, setSchedules] = useState<ScheduleEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [logs, setLogs] = useState<Record<string, ErrorLogEntry[]>>({});
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [symbol, setSymbol] = useState("");
const [triggering, setTriggering] = useState(false);
const [resettingSource, setResettingSource] = useState<string | null>(null);
const [resetResult, setResetResult] = useState<Record<string, number>>({});
const [newScheduleKind, setNewScheduleKind] = useState("sec-fetch");
const [newScheduleInterval, setNewScheduleInterval] = useState("86400000");
const [addingSchedule, setAddingSchedule] = useState(false);
const [pausing, setPausing] = useState(false);
const loadData = useCallback(async () => {
try {
const [queueData, statusData, scheduleData] = await Promise.all([
api.admin.queueHealth(),
api.admin.queueStatus(),
api.admin.queueSchedules(),
]);
setQueue(queueData);
setStatus(statusData);
setSchedules(scheduleData);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load queue data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 5000);
return () => { clearInterval(interval); };
}, [loadData]);
const handlePause = async () => {
setPausing(true);
try {
if (status?.paused) {
await api.admin.queueResume();
setSuccess("Queue resumed");
} else {
await api.admin.queuePause();
setSuccess("Queue paused");
}
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to toggle pause");
} finally {
setPausing(false);
}
};
const handleClearDone = async () => {
try {
const result = await api.admin.queueClearDone(24);
setSuccess(`Cleared ${result.cleared} done jobs`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to clear done jobs");
}
};
const handleQueueFetch = async (e: React.FormEvent) => {
e.preventDefault();
if (!symbol.trim()) return;
setTriggering(true);
setError(null);
setSuccess(null);
try {
await api.admin.queueSecFetch(symbol.trim().toUpperCase());
setSuccess(`Queued SEC fetch for ${symbol.toUpperCase()}`);
setSymbol("");
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to queue fetch");
} finally {
setTriggering(false);
}
};
const handleResetBackoff = async (sourceKind: string) => {
setResettingSource(sourceKind);
try {
const result = await api.admin.queueRetrySource(sourceKind);
setResetResult((prev) => ({ ...prev, [sourceKind]: result.cleared }));
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reset backoff");
} finally {
setResettingSource(null);
}
};
const handleRetryJob = async (key: string) => {
try {
await api.admin.queueRetryJob(key);
setSuccess(`Retried job: ${key}`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to retry job");
}
};
const toggleLog = async (key: string) => {
if (expandedLog === key) {
setExpandedLog(null);
return;
}
setExpandedLog(key);
if (!logs[key]) {
try {
const entries = await api.admin.queueLogs(key, 10);
setLogs((prev) => ({ ...prev, [key]: entries }));
} catch {
// ignore
}
}
};
const handleAddSchedule = async (e: React.FormEvent) => {
e.preventDefault();
setAddingSchedule(true);
try {
await api.admin.queueSetSchedule(newScheduleKind, Number(newScheduleInterval));
setSuccess(`Schedule added for ${newScheduleKind}`);
setNewScheduleKind("sec-fetch");
setNewScheduleInterval("86400000");
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add schedule");
} finally {
setAddingSchedule(false);
}
};
const handleDeleteSchedule = async (sourceKind: string) => {
try {
await api.admin.queueDeleteSchedule(sourceKind);
setSuccess(`Schedule deleted for ${sourceKind}`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete schedule");
}
};
type SourceAgg = {
pending: number;
inFlight: number;
done: number;
backoff: number;
failed: number;
lastAttempt: string | null;
hasBackoff: boolean;
};
const sources: Record<string, SourceAgg> = {};
for (const item of queue) {
if (!sources[item.source]) {
sources[item.source] = { pending: 0, inFlight: 0, done: 0, backoff: 0, failed: 0, lastAttempt: null, hasBackoff: false };
}
const agg = sources[item.source];
if (item.status === "backoff") { agg.backoff += item.count; agg.hasBackoff = true; }
else if (item.status === "failed") { agg.failed += item.count; }
else if (item.status === "in_flight") { agg.inFlight += item.count; }
else if (item.status === "pending") { agg.pending += item.count; }
else if (item.status === "done") { agg.done += item.count; }
if (item.last_attempt) {
if (!agg.lastAttempt || new Date(item.last_attempt) > new Date(agg.lastAttempt)) {
agg.lastAttempt = item.last_attempt;
}
}
}
const sortedSources = Object.entries(sources).sort(([a], [b]) => a.localeCompare(b));
if (loading) {
return <AdminLayout><Spinner /></AdminLayout>;
}
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-fg">Queue Management</h1>
<p className="text-sm text-fg-faint mt-1">Monitor queue health, schedules, and manually trigger data fetches</p>
</div>
{error && <Banner type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Banner type="success" message={success} onDismiss={() => setSuccess(null)} />}
{Object.entries(resetResult).map(([src, cleared]) => (
<Banner key={`reset-${src}`} type="success" message={`${cleared} job${cleared !== 1 ? "s" : ""} retried for ${src}`} onDismiss={() => setResetResult({})} />
))}
{/* Control Bar */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-surface">
<div className="flex items-center gap-4">
<button
onClick={handlePause}
disabled={pausing}
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
status?.paused
? "border-green-500/40 bg-green-950/30 text-up hover:bg-green-950/50"
: "border-orange-500/40 bg-orange-950/30 text-[#fb923c] hover:bg-orange-950/50"
}`}
>
{pausing ? (
<span className="h-3 w-3 border border-current border-t-transparent rounded-full animate-spin" />
) : null}
{status?.paused ? "Resume" : "Pause"}
</button>
<button
onClick={handleClearDone}
className="inline-flex items-center gap-2 rounded border border-line-strong bg-surface px-3 py-1.5 text-xs font-medium text-fg-faint hover:text-fg transition-colors"
>
Clear Done
</button>
</div>
<div className="flex items-center gap-3 text-xs text-fg-faint">
<span className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${status?.paused ? "bg-[#fb923c]" : "bg-[#34d399]"}`} />
{status?.paused ? "Paused" : "Active"}
</span>
<span className="font-mono text-fg-muted">
{status?.counts.pending ?? 0}p / {status?.counts.in_flight ?? 0}i / {status?.counts.failed ?? 0}f / {status?.counts.backoff ?? 0}b
</span>
</div>
</div>
</section>
{/* Queue Health Overview */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${status?.paused ? "bg-[#fb923c]" : "bg-[#34d399]"}`} />
Queue Health Overview
</h2>
<span className="text-xs font-mono text-fg-faint">{sortedSources.length} sources</span>
</div>
{sortedSources.length === 0 ? (
<p className="text-xs text-fg-faint italic py-8 text-center">No queue data available</p>
) : (
<>
<div className="flex items-center gap-4 px-4 pt-3 border-b border-[#1a1b28]">
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" /> Pending
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#34d399]" /> In-flight
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-fg-muted/40" /> Done
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c]" /> Backoff
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-danger" /> Failed
</span>
</div>
{sortedSources.some(([, v]) => v.backoff > 0) && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-orange-500/30 bg-orange-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c] animate-pulse" />
<span className="text-xs text-[#fb923c] font-medium">Backoff active — jobs stalled and awaiting retry</span>
</div>
)}
<table className="w-full text-xs mt-3">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-4 pl-4 font-medium w-48">Source</th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-accent">Pending</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-up">In-flight</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-fg-muted/60">Done</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-[#fb923c]">Backoff</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-danger">Failed</span></th>
<th className="pb-2 pr-4 font-medium">Last Attempt</th>
<th className="pb-2 font-medium w-48 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{sortedSources.map(([source, agg]) => (
<tr key={source} className={`hover:bg-surface/50 transition-colors ${agg.hasBackoff ? "bg-orange-950/10" : ""}`}>
<td className="py-2.5 pr-4 pl-4 font-mono text-fg">{source}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.pending > 0 ? "text-accent" : "text-fg-faint"}`}>{agg.pending}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.inFlight > 0 ? "text-up" : "text-fg-faint"}`}>{agg.inFlight}</td>
<td className="py-2.5 pr-4 font-mono text-right text-fg-muted">{agg.done}</td>
<td className="py-2.5 pr-4 font-mono text-right text-[#fb923c]">{agg.backoff}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.failed > 0 ? "text-danger" : "text-fg-faint"}`}>{agg.failed}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint whitespace-nowrap font-mono">{formatTimestamp(agg.lastAttempt)}</td>
<td className="py-2.5 text-center">
<div className="flex items-center justify-center gap-1.5">
{agg.hasBackoff && (
<button
onClick={() => handleResetBackoff(source)}
disabled={resettingSource === source}
className="inline-flex items-center gap-1 rounded border border-orange-500/40 bg-orange-950/30 px-2 py-1 text-[10px] font-medium text-[#fb923c] hover:bg-orange-950/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{resettingSource === source ? (
<span className="h-3 w-3 border border-[#fb923c] border-t-transparent rounded-full animate-spin" />
) : null}
Retry
</button>
)}
{agg.failed > 0 && (
<button
onClick={() => handleResetBackoff(source)}
disabled={resettingSource === source}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Retry All
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{sortedSources.some(([, v]) => v.failed > 0) && (
<div className="mx-4 mb-3 mt-2 flex items-center gap-2 rounded border border-red-500/30 bg-red-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" />
<span className="text-xs text-danger font-medium">Failed jobs detected — expand below for details</span>
</div>
)}
</>
)}
</section>
{/* Failed Jobs Log */}
{status && status.lastErrors.length > 0 && (
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-danger" />
Failed Jobs
</h2>
<span className="text-xs text-fg-faint">{status.lastErrors.length} recent</span>
</div>
<div className="divide-y divide-[#1a1b28]">
{status.lastErrors.map((job) => (
<div key={job.key}>
<button
onClick={() => toggleLog(job.key)}
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface/50 transition-colors text-left"
>
<div className="flex items-center gap-3 min-w-0">
<span className="h-1.5 w-1.5 rounded-full bg-danger shrink-0" />
<code className="text-xs font-mono text-fg truncate">{job.key}</code>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[10px] text-fg-faint max-w-[200px] truncate">{job.error}</span>
<svg className={`h-3 w-3 text-fg-faint transition-transform ${expandedLog === job.key ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{expandedLog === job.key && (
<div className="px-4 pb-3">
<div className="flex gap-2 mb-2">
<button
onClick={() => handleRetryJob(job.key)}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors"
>
Retry
</button>
</div>
<div className="bg-surface rounded border border-line-strong p-3 max-h-64 overflow-y-auto">
{logs[job.key]?.length ? (
logs[job.key].map((entry, i) => (
<div key={i} className="mb-2 last:mb-0">
<div className="text-[10px] text-fg-faint font-mono mb-0.5">
Attempt #{entry.attempt} — {formatTimestamp(entry.ts)}
</div>
<div className="text-xs text-danger font-mono whitespace-pre-wrap break-all bg-surface-raised rounded p-2 border border-line-strong">
{entry.errorStack ?? entry.errorMessage ?? "Unknown error"}
</div>
</div>
))
) : (
<div className="flex items-center gap-2">
<span className="h-3 w-3 border border-fg-faint border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] text-fg-faint">Loading logs...</span>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Schedule Configuration */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-accent" />
Schedule Configuration
</h2>
<span className="text-xs text-fg-faint">{schedules.length} schedules</span>
</div>
{schedules.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-4 pl-4 font-medium">Source</th>
<th className="pb-2 pr-4 font-medium">Interval</th>
<th className="pb-2 pr-4 font-medium">Last Enqueued</th>
<th className="pb-2 pr-4 font-medium">Next Enqueue</th>
<th className="pb-2 font-medium w-24 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{schedules.map((s) => (
<tr key={s.sourceKind} className="hover:bg-surface/50 transition-colors">
<td className="py-2.5 pr-4 pl-4 font-mono text-fg">{s.sourceKind}</td>
<td className="py-2.5 pr-4 text-fg-muted">{msToHuman(s.intervalMs)}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint font-mono">{formatTimestamp(s.lastEnqueued)}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint font-mono">{formatTimestamp(s.nextEnqueue)}</td>
<td className="py-2.5 text-center">
<button
onClick={() => handleDeleteSchedule(s.sourceKind)}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Add Schedule Form */}
<form onSubmit={handleAddSchedule} className="p-4 border-t border-line-strong">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-[10px] font-medium text-fg-faint mb-1 uppercase tracking-wide">Source Kind</label>
<select
value={newScheduleKind}
onChange={(e) => setNewScheduleKind(e.target.value)}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-xs font-mono text-fg focus:outline-none focus:border-accent/50 transition-colors"
disabled={addingSchedule}
>
<option value="sec-fetch">sec-fetch</option>
<option value="yfinance">yfinance</option>
<option value="sec">sec</option>
<option value="x">x</option>
<option value="reddit">reddit</option>
</select>
</div>
<div className="flex-1">
<label className="block text-[10px] font-medium text-fg-faint mb-1 uppercase tracking-wide">Interval (ms)</label>
<input
type="number"
value={newScheduleInterval}
onChange={(e) => setNewScheduleInterval(e.target.value)}
min={60000}
max={604800000}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-xs font-mono text-fg focus:outline-none focus:border-accent/50 transition-colors"
disabled={addingSchedule}
/>
</div>
<button
type="submit"
disabled={addingSchedule}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent/10 px-4 py-2 text-xs font-medium text-accent hover:bg-accent/20 transition-colors disabled:opacity-30"
>
{addingSchedule ? (
<span className="h-3 w-3 border border-accent border-t-transparent rounded-full animate-spin" />
) : null}
Add Schedule
</button>
</div>
</form>
</section>
{/* Manual Trigger */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-accent" />
Manual Trigger
</h2>
<span className="text-xs text-fg-faint">Queue a fresh SEC data fetch for any symbol</span>
</div>
<form onSubmit={handleQueueFetch} className="p-4 flex items-end gap-3">
<div className="flex-1">
<label htmlFor="symbol-input" className="block text-xs font-medium text-fg-faint mb-1.5 uppercase tracking-wide">Symbol</label>
<input
id="symbol-input"
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value.toUpperCase().slice(0, 10))}
placeholder="NVDA"
maxLength={10}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm font-mono text-fg placeholder:text-fg-faint focus:outline-none focus:border-accent/50 transition-colors"
disabled={triggering}
/>
</div>
<button
type="submit"
disabled={!symbol.trim() || triggering}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent/10 px-4 py-2 text-sm font-medium text-accent hover:bg-accent/20 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{triggering ? (
<span className="h-4 w-4 border border-accent border-t-transparent rounded-full animate-spin" />
) : null}
Queue Fetch
</button>
</form>
</section>
</div>
</AdminLayout>
);
}
+50 -2
View File
@@ -210,12 +210,12 @@ export const api = {
removeHolding: (symbol: string) => trpcMutate<{ removed: boolean }>("portfolio.removeHolding", { symbol }),
},
dashboard: {
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup"),
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup", {}),
},
institutional: {
flow: (symbol: string) =>
trpcQuery<{ symbol: string; flow: Array<Record<string, unknown>>; quarters: string[] }>("institutional.flow", { symbol }),
insiderStream: (symbol: string, limit = 50) =>
insiderStream: (symbol: string, limit = 200) =>
trpcQuery<{ symbol: string; events: Array<Record<string, unknown>>; netShares: number; count: number }>("institutional.insiderStream", { symbol, limit }),
},
emotionLogger: {
@@ -230,6 +230,26 @@ export const api = {
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll"),
},
admin: {
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number }>>("admin.usersList"),
userSessions: (userId: string) => trpcQuery<Array<{ id: string; user_id: string; expires_at: string; created_at: string }>>("admin.userSessions", { userId }),
resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }),
queueHealth: () => trpcQuery<Array<{ source: string; status: string; count: number; last_attempt: string | null; retry_count: number; backoff_until: string | null }>>("admin.queueHealth"),
resetBackoff: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.resetBackoff", { sourceKind }),
queueSecFetch: (symbol: string) => trpcMutate<{ ok: boolean }>("admin.queueSecFetch", { symbol }),
queueStatus: () => trpcQuery<QueueStatus>("admin.queueStatus"),
queuePause: () => trpcMutate<{ paused: boolean }>("admin.queuePause", undefined),
queueResume: () => trpcMutate<{ paused: boolean }>("admin.queueResume", undefined),
queueClearDone: (olderThanHours: number) => trpcMutate<{ cleared: number }>("admin.queueClearDone", { olderThanHours }),
queueSchedules: () => trpcQuery<ScheduleEntry[]>("admin.queueSchedules"),
queueSetSchedule: (sourceKind: string, intervalMs: number) => trpcMutate<{ ok: boolean }>("admin.queueSetSchedule", { sourceKind, intervalMs }),
queueDeleteSchedule: (sourceKind: string) => trpcMutate<{ ok: boolean }>("admin.queueDeleteSchedule", { sourceKind }),
queueLogs: (key: string, limit: number) => trpcQuery<ErrorLogEntry[]>("admin.queueLogs", { key, limit }),
queueRetryJob: (key: string) => trpcMutate<{ ok: boolean }>("admin.queueRetryJob", { key }),
queueRetrySource: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.queueRetrySource", { sourceKind }),
auditLog: (limit = 50, offset = 0, actor?: string, action?: string) =>
trpcQuery<Array<{ id: string; actor: string; action: string; target: string | null; ts: string; meta: string | null }>>("admin.auditLog", { limit, offset, ...(actor ? { actor } : {}), ...(action ? { action } : {}) }),
},
};
export interface WatchlistEntry {
@@ -244,3 +264,31 @@ export interface PortfolioHolding {
avg_cost: number;
added_at: string;
}
export interface QueueStatus {
paused: boolean;
queued: number;
in_flight: number;
failed: number;
backoff: number;
done: number;
backoff_until: string | null;
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
}
export interface ScheduleEntry {
sourceKind: string;
intervalMs: number;
lastEnqueued: string | null;
nextEnqueue: string | null;
}
export interface ErrorLogEntry {
attempt: number;
errorMessage: string | null;
errorStack: string | null;
ts: string;
}