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:
co-authored by
Claude
parent
55f07e6b42
commit
ca385c1960
@@ -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">×</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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user