fix: rewrite dashboards to use PostgreSQL API instead of empty Trino lakehouse
The Trino/Iceberg lakehouse has zero tables, so all Trino-backed dashboards showed 'No data available'. Rewrote all four to use existing PostgreSQL-backed API endpoints: - Sentiment Heatmap: useTrends + useCompanies → sector and ticker trend strength bar charts (30k trend_windows in DB) - Prediction Accuracy: useRecommendations → confidence distribution and action distribution charts (30k recommendations in DB) - Paper PnL: useTradingMetrics + useTradingMetricsHistory → equity curve, daily returns, win/loss stats from trading engine - Model Quality: useModelPerformance + useModelFailures → success rate, latency, retries, and failure table from ops API Removed unused Trino query function and ScatterChart imports.
This commit is contained in:
+269
-124
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useCompanies, useTrends, useRecommendations, usePositions } from '../api/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiPost } from '../api/client';
|
||||
import { useCompanies, useTrends, useRecommendations, usePositions, useModelPerformance, useModelFailures } from '../api/hooks';
|
||||
import { useTradingMetrics, useTradingMetricsHistory } from '../api/tradingHooks';
|
||||
import { TrendArrow, StatusBadge, ConfidenceBar, LoadingSpinner, DateRangeSelector, TickerFilter, Card } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, ScatterChart, Scatter,
|
||||
BarChart, Bar,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
LineChart, Line,
|
||||
} from 'recharts';
|
||||
|
||||
type DashboardId = 'gallery' | 'symbol-overview' | 'sentiment-heatmap' | 'prediction-accuracy' | 'paper-pnl' | 'model-quality';
|
||||
@@ -51,9 +51,9 @@ export function DashboardsPage() {
|
||||
← Back to gallery
|
||||
</button>
|
||||
{active === 'symbol-overview' && <SymbolOverview ticker={ticker} />}
|
||||
{active === 'sentiment-heatmap' && <SentimentHeatmap hours={hours} />}
|
||||
{active === 'prediction-accuracy' && <PredictionAccuracy hours={hours} />}
|
||||
{active === 'paper-pnl' && <PaperPnl hours={hours} />}
|
||||
{active === 'sentiment-heatmap' && <SentimentHeatmap />}
|
||||
{active === 'prediction-accuracy' && <PredictionAccuracy />}
|
||||
{active === 'paper-pnl' && <PaperPnl />}
|
||||
{active === 'model-quality' && <ModelQuality hours={hours} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -62,7 +62,7 @@ export function DashboardsPage() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symbol Overview
|
||||
// Symbol Overview (unchanged — already uses PostgreSQL hooks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SymbolOverview({ ticker }: { ticker: string }) {
|
||||
@@ -121,195 +121,340 @@ function SymbolOverview({ ticker }: { ticker: string }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trino-backed dashboards
|
||||
// Sentiment Heatmap — powered by useTrends (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useTrinoQuery(sql: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['trino-dashboard', sql],
|
||||
queryFn: () => apiPost<{ columns: Array<{ name: string }>; rows: unknown[][] }>('query', '/api/analytics/query', { sql, limit: 5000 }),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function SentimentHeatmap({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT entity_id AS ticker, window, trend_direction, trend_strength, confidence, generated_at FROM trend_windows WHERE entity_type = 'company' AND generated_at >= current_timestamp - interval '${days}' day ORDER BY generated_at DESC LIMIT 500`
|
||||
);
|
||||
function SentimentHeatmap() {
|
||||
const { data: trends, isLoading } = useTrends({ limit: 500 });
|
||||
const { data: companies } = useCompanies();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
|
||||
if (!trends?.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
ticker: String(r[0]),
|
||||
window: String(r[1]),
|
||||
strength: Number(r[3]) || 0,
|
||||
direction: String(r[2]),
|
||||
}));
|
||||
// Build sector lookup from companies
|
||||
const sectorMap = new Map((companies ?? []).map((c) => [c.ticker, c.sector ?? 'Unknown']));
|
||||
|
||||
// Aggregate trend_strength by sector
|
||||
const sectorAgg = new Map<string, { total: number; count: number }>();
|
||||
for (const t of trends) {
|
||||
const sector = sectorMap.get(t.entity_id) ?? 'Unknown';
|
||||
const entry = sectorAgg.get(sector) ?? { total: 0, count: 0 };
|
||||
entry.total += t.trend_strength;
|
||||
entry.count += 1;
|
||||
sectorAgg.set(sector, entry);
|
||||
}
|
||||
|
||||
const sectorData = Array.from(sectorAgg.entries())
|
||||
.map(([sector, { total, count }]) => ({
|
||||
sector,
|
||||
avgStrength: Math.round((total / count) * 100) / 100,
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => b.avgStrength - a.avgStrength);
|
||||
|
||||
// Also build per-ticker chart
|
||||
const tickerAgg = new Map<string, { total: number; count: number }>();
|
||||
for (const t of trends) {
|
||||
const entry = tickerAgg.get(t.entity_id) ?? { total: 0, count: 0 };
|
||||
entry.total += t.trend_strength;
|
||||
entry.count += 1;
|
||||
tickerAgg.set(t.entity_id, entry);
|
||||
}
|
||||
|
||||
const tickerData = Array.from(tickerAgg.entries())
|
||||
.map(([ticker, { total, count }]) => ({
|
||||
ticker,
|
||||
strength: Math.round((total / count) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.strength - a.strength)
|
||||
.slice(0, 25);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Sentiment by Symbol</h2>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Average Trend Strength by Sector</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<BarChart data={sectorData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="sector" tick={{ fill: '#6b7280', fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="avgStrength" fill="#3b82f6" name="Avg Trend Strength" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Trend Strength by Ticker (Top 25)</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={tickerData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="ticker" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="strength" fill="#3b82f6" name="Trend Strength" />
|
||||
<Bar dataKey="strength" fill="#8b5cf6" name="Trend Strength" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PredictionAccuracy({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT predicted_confidence, realized_move_pct, ticker, prediction_date FROM prediction_vs_outcome WHERE prediction_date >= current_date - interval '${days}' day ORDER BY prediction_date DESC LIMIT 1000`
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prediction Accuracy — powered by useRecommendations (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PredictionAccuracy() {
|
||||
const { data: recs, isLoading } = useRecommendations({ limit: 500 });
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No prediction data available</p>;
|
||||
if (!recs?.length) return <p className="text-sm text-gray-500">No recommendation data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
confidence: Number(r[0]) || 0,
|
||||
realized: Number(r[1]) || 0,
|
||||
ticker: String(r[2]),
|
||||
// Confidence distribution buckets
|
||||
const buckets = [
|
||||
{ label: '<0.40', min: 0, max: 0.4 },
|
||||
{ label: '0.40–0.55', min: 0.4, max: 0.55 },
|
||||
{ label: '0.55–0.75', min: 0.55, max: 0.75 },
|
||||
{ label: '0.75–1.00', min: 0.75, max: 1.01 },
|
||||
];
|
||||
|
||||
const confidenceData = buckets.map((b) => ({
|
||||
bucket: b.label,
|
||||
count: recs.filter((r) => r.confidence >= b.min && r.confidence < b.max).length,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Predicted Confidence vs Realized Move</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="confidence" name="Confidence" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis dataKey="realized" name="Realized %" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Scatter data={chartData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPnl({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT dt, daily_pnl, cumulative_pnl, win_count, loss_count FROM pnl_daily WHERE dt >= current_date - interval '${days}' day ORDER BY dt ASC LIMIT 365`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No PnL data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
date: String(r[0]),
|
||||
daily: Number(r[1]) || 0,
|
||||
cumulative: Number(r[2]) || 0,
|
||||
wins: Number(r[3]) || 0,
|
||||
losses: Number(r[4]) || 0,
|
||||
}));
|
||||
|
||||
const totalWins = chartData.reduce((s, d) => s + d.wins, 0);
|
||||
const totalLosses = chartData.reduce((s, d) => s + d.losses, 0);
|
||||
const winRate = totalWins + totalLosses > 0 ? ((totalWins / (totalWins + totalLosses)) * 100).toFixed(1) : '—';
|
||||
// Action distribution
|
||||
const actionCounts = new Map<string, number>();
|
||||
for (const r of recs) {
|
||||
actionCounts.set(r.action, (actionCounts.get(r.action) ?? 0) + 1);
|
||||
}
|
||||
const actionData = Array.from(actionCounts.entries())
|
||||
.map(([action, count]) => ({ action, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Confidence Distribution</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={confidenceData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="bucket" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="Recommendations" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Action Distribution</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={actionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="action" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="count" fill="#22c55e" name="Count" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-gray-600">
|
||||
Note: Showing confidence and action distributions from recommendations. Realized price move comparison will be available once outcome tracking is implemented.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paper PnL — powered by trading engine API (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PaperPnl() {
|
||||
const { data: metrics, isLoading: metricsLoading } = useTradingMetrics();
|
||||
const { data: snapshots, isLoading: historyLoading } = useTradingMetricsHistory();
|
||||
|
||||
if (metricsLoading || historyLoading) return <LoadingSpinner />;
|
||||
|
||||
const hasMetrics = !!metrics;
|
||||
const hasSnapshots = snapshots && snapshots.length > 0;
|
||||
|
||||
if (!hasMetrics && !hasSnapshots) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">Paper trading metrics will appear after the first trading day.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const equityCurve = (snapshots ?? [])
|
||||
.map((s) => ({
|
||||
date: s.snapshot_date,
|
||||
value: s.portfolio_value,
|
||||
dailyReturn: s.daily_return != null ? Math.round(s.daily_return * 10000) / 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const winRate = metrics ? (metrics.win_rate * 100).toFixed(1) : '—';
|
||||
const winCount = metrics?.win_count ?? 0;
|
||||
const lossCount = metrics?.loss_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{winRate}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-green-400">{totalWins}</div>
|
||||
<div className="text-xl font-bold text-green-400">{winCount}</div>
|
||||
<div className="text-xs text-gray-500">Wins</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-red-400">{totalLosses}</div>
|
||||
<div className="text-xl font-bold text-red-400">{lossCount}</div>
|
||||
<div className="text-xs text-gray-500">Losses</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${(metrics?.unrealized_pnl ?? 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
${(metrics?.unrealized_pnl ?? 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Unrealized PnL</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* More metrics */}
|
||||
{hasMetrics && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card className="text-center">
|
||||
<div className="text-lg font-bold text-gray-100">${metrics.total_portfolio_value.toFixed(0)}</div>
|
||||
<div className="text-xs text-gray-500">Portfolio Value</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-lg font-bold text-gray-100">{metrics.profit_factor.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Profit Factor</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-lg font-bold text-gray-100">{metrics.sharpe_ratio.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Sharpe Ratio</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-lg font-bold text-red-400">{(metrics.max_drawdown * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Max Drawdown</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Equity curve */}
|
||||
{hasSnapshots && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Equity Curve</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<LineChart data={equityCurve}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="cumulative" stroke="#3b82f6" dot={false} name="Cumulative PnL" />
|
||||
<Line type="monotone" dataKey="value" stroke="#3b82f6" dot={false} name="Portfolio Value" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Daily returns */}
|
||||
{hasSnapshots && equityCurve.some((d) => d.dailyReturn !== 0) && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily PnL</h2>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily Returns (%)</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<BarChart data={equityCurve}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="daily" name="Daily PnL" fill="#3b82f6" />
|
||||
<Bar dataKey="dailyReturn" name="Daily Return %" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model Quality — powered by ops API (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ModelQuality({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT date_trunc('hour', recorded_at) AS hour, count(*) AS total, count(*) filter (where success = true) AS successes, avg(total_duration_ms) AS avg_latency, avg(retry_count) AS avg_retries FROM model_performance_metrics WHERE recorded_at >= current_timestamp - interval '${days}' day GROUP BY 1 ORDER BY 1 ASC LIMIT 500`
|
||||
);
|
||||
const { data: perf, isLoading: perfLoading } = useModelPerformance(hours);
|
||||
const { data: failures, isLoading: failLoading } = useModelFailures(hours);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No model metrics available</p>;
|
||||
if (perfLoading || failLoading) return <LoadingSpinner />;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
hour: String(r[0]).slice(11, 16),
|
||||
total: Number(r[1]) || 0,
|
||||
successes: Number(r[2]) || 0,
|
||||
rate: Number(r[1]) > 0 ? ((Number(r[2]) / Number(r[1])) * 100) : 0,
|
||||
latency: Math.round(Number(r[3]) || 0),
|
||||
retries: Number(r[4]) || 0,
|
||||
}));
|
||||
const perfData = perf as Record<string, unknown> | undefined;
|
||||
const failureList = (failures ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
const totalCalls = Number(perfData?.total_calls ?? 0);
|
||||
const successCalls = Number(perfData?.successful_calls ?? 0);
|
||||
const successRate = totalCalls > 0 ? ((successCalls / totalCalls) * 100).toFixed(1) : '—';
|
||||
const avgLatency = Number(perfData?.avg_latency_ms ?? 0);
|
||||
const avgRetries = Number(perfData?.avg_retries ?? 0);
|
||||
|
||||
const hasData = totalCalls > 0 || failureList.length > 0;
|
||||
if (!hasData) return <p className="text-sm text-gray-500">No model metrics available</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Success Rate Over Time</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} domain={[0, 100]} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="rate" stroke="#22c55e" dot={false} name="Success %" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{totalCalls}</div>
|
||||
<div className="text-xs text-gray-500">Total Calls</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${Number(successRate) >= 90 ? 'text-green-400' : Number(successRate) >= 70 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||
{successRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Success Rate</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{Math.round(avgLatency)}ms</div>
|
||||
<div className="text-xs text-gray-500">Avg Latency</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{avgRetries.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Avg Retries</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent failures table */}
|
||||
{failureList.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Latency & Retries</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend />
|
||||
<Bar dataKey="latency" fill="#3b82f6" name="Avg Latency (ms)" />
|
||||
<Bar dataKey="retries" fill="#f59e0b" name="Avg Retries" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Recent Failures ({failureList.length})</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-gray-500">
|
||||
<th className="pb-2 pr-4">Time</th>
|
||||
<th className="pb-2 pr-4">Model</th>
|
||||
<th className="pb-2 pr-4">Error</th>
|
||||
<th className="pb-2 pr-4">Retries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failureList.slice(0, 20).map((f, i) => (
|
||||
<tr key={i} className="border-b border-surface-800">
|
||||
<td className="py-1.5 pr-4 text-gray-400">{String(f.recorded_at ?? f.created_at ?? '').slice(0, 19)}</td>
|
||||
<td className="py-1.5 pr-4 text-gray-300">{String(f.model_name ?? f.model ?? '—')}</td>
|
||||
<td className="max-w-xs truncate py-1.5 pr-4 text-red-400">{String(f.error_message ?? f.error ?? '—')}</td>
|
||||
<td className="py-1.5 pr-4 text-gray-400">{String(f.retry_count ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user