fix(web-dashboard): add 401 auto-logout, error states, WS backoff fix, regime data wiring

- api.ts: intercept 401 responses, clear tokens, redirect to /login
- websocket.ts: fix backoff ordering (increase before scheduling retry)
- All dashboards: add error banners with retry buttons when API queries fail
- MLDashboard: replace hardcoded regime data with WebSocket metrics stream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-22 08:04:54 +01:00
parent f2340c2ecf
commit 5b96d64432
6 changed files with 60 additions and 7 deletions

View File

@@ -1,6 +1,8 @@
import { clearTokens } from './auth';
const API_BASE = '/api';
/** Fetch wrapper that injects JWT Authorization header */
/** Fetch wrapper that injects JWT Authorization header and handles 401 auto-logout */
async function apiFetch<T>(
path: string,
options: RequestInit = {},
@@ -22,6 +24,13 @@ async function apiFetch<T>(
});
if (!res.ok) {
// Auto-logout on 401 (expired/invalid token)
if (res.status === 401) {
clearTokens();
window.location.href = '/login';
throw new Error('Session expired. Please log in again.');
}
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `API error: ${res.status}`);
}

View File

@@ -99,11 +99,12 @@ export class WsManager {
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
const delay = this.backoff;
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
}, this.backoff);
}, delay);
}
}

View File

@@ -2,8 +2,16 @@ import { ModelCard } from '../components/ml/ModelCard';
import { EnsemblePanel } from '../components/ml/EnsemblePanel';
import { TrainingProgress } from '../components/ml/TrainingProgress';
import { useApiQuery } from '../hooks/useApi';
import { useWebSocketTopic } from '../hooks/useWebSocket';
import type { MlPrediction, TrainingJob } from '../lib/types';
interface RegimeData {
regime?: string;
volatility?: string;
confidence?: number;
duration?: string;
}
const MODELS = ['DQN', 'PPO', 'TFT', 'Mamba2'] as const;
export function MLDashboard() {
@@ -19,10 +27,24 @@ export function MLDashboard() {
{ refetchInterval: 5000 },
);
const { messages: metricsMessages } = useWebSocketTopic('metrics');
// Extract regime data from most recent metrics message
const latestMetrics = metricsMessages.length > 0
? (metricsMessages.at(-1) as { data?: RegimeData })?.data
: undefined;
const preds = predictions.data ?? [];
return (
<div className="space-y-4">
{(predictions.isError || trainingJobs.isError) && (
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
<span>Failed to load {predictions.isError ? 'predictions' : 'training jobs'}: {(predictions.error ?? trainingJobs.error)?.message}</span>
<button onClick={() => { predictions.refetch(); trainingJobs.refetch(); }} className="text-xs underline">Retry</button>
</div>
)}
{/* Model Cards */}
<div className="grid grid-cols-4 gap-4">
{MODELS.map((model) => {
@@ -55,19 +77,19 @@ export function MLDashboard() {
<div className="p-3 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Current Regime</span>
<span className="text-[var(--color-accent)]">Trending</span>
<span className="text-[var(--color-accent)]">{latestMetrics?.regime ?? '--'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Volatility</span>
<span>Medium</span>
<span>{latestMetrics?.volatility ?? '--'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Regime Confidence</span>
<span>72.3%</span>
<span>{latestMetrics?.confidence !== undefined ? `${(latestMetrics.confidence * 100).toFixed(1)}%` : '--'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">Regime Duration</span>
<span>4h 23m</span>
<span>{latestMetrics?.duration ?? '--'}</span>
</div>
</div>
</div>

View File

@@ -25,6 +25,13 @@ export function PerformanceDashboard() {
return (
<div className="space-y-4">
{(metrics.isError || pnlData.isError) && (
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
<span>Failed to load {metrics.isError ? 'metrics' : 'P&L data'}: {(metrics.error ?? pnlData.error)?.message}</span>
<button onClick={() => { metrics.refetch(); pnlData.refetch(); }} className="text-xs underline">Retry</button>
</div>
)}
{/* Top: Metric cards */}
<div className="grid grid-cols-5 gap-4">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">

View File

@@ -16,6 +16,13 @@ export function RiskDashboard() {
return (
<div className="space-y-4">
{riskMetrics.isError && (
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
<span>Failed to load risk metrics: {riskMetrics.error.message}</span>
<button onClick={() => riskMetrics.refetch()} className="text-xs underline">Retry</button>
</div>
)}
{/* Top row: Risk gauges */}
<div className="grid grid-cols-3 gap-4">
<div className="h-48 rounded border border-[var(--color-border)] bg-[var(--color-bg-card)]">

View File

@@ -38,6 +38,13 @@ export function TradingDashboard() {
return (
<div className="space-y-4">
{(positions.isError || orders.isError) && (
<div className="px-3 py-2 rounded border border-[var(--color-red)] bg-red-500/10 text-sm text-[var(--color-red)] flex items-center justify-between">
<span>Failed to load {positions.isError ? 'positions' : 'orders'}: {(positions.error ?? orders.error)?.message}</span>
<button onClick={() => { positions.refetch(); orders.refetch(); }} className="text-xs underline">Retry</button>
</div>
)}
{/* Top row: Chart + Order Book */}
<div className="grid grid-cols-[1fr_320px] gap-4 h-80">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg-card)] overflow-hidden">