- Complete UI refactor with charcoal/bone color palette - Add Space Grotesk font for headlines, Inter for body - Update all 24+ pages with new design system - Add LinearPrimitives reusable components - Improve dark mode support throughout - Add subtle micro-animations and transitions
300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Brain, TrendingUp, AlertTriangle, Zap, Target,
|
|
CheckCircle, BarChart3, RefreshCw, Loader2
|
|
} from 'lucide-react';
|
|
import api from '../lib/api';
|
|
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
|
|
|
interface Prediction {
|
|
batchId: string;
|
|
accuracy: string;
|
|
predictedAt: string;
|
|
}
|
|
|
|
interface Anomaly {
|
|
id: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
anomalyType: string;
|
|
severity: string;
|
|
description: string;
|
|
isResolved: boolean;
|
|
detectedAt: string;
|
|
}
|
|
|
|
interface InsightsDashboardData {
|
|
predictions: {
|
|
count: number;
|
|
avgAccuracy: string;
|
|
recentAccuracy: Prediction[];
|
|
};
|
|
anomalies: {
|
|
unresolved: number;
|
|
recent: Anomaly[];
|
|
};
|
|
performance: {
|
|
topBatches: {
|
|
batchId: string;
|
|
costPerGram: string;
|
|
totalCost: number;
|
|
yieldGrams: number;
|
|
}[];
|
|
};
|
|
}
|
|
|
|
export default function InsightsDashboard() {
|
|
const [dashboard, setDashboard] = useState<InsightsDashboardData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const res = await api.get('/api/insights/dashboard');
|
|
setDashboard(res.data);
|
|
} catch (error) {
|
|
console.error('Failed to load insights:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadData();
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const resolveAnomaly = async (id: string) => {
|
|
try {
|
|
await api.post(`/api/insights/anomalies/${id}/resolve`);
|
|
loadData();
|
|
} catch (error) {
|
|
console.error('Failed to resolve anomaly:', error);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-6 animate-in">
|
|
<PageHeader title="AI Insights" subtitle="Loading..." />
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const anomalyCount = dashboard?.anomalies.unresolved || 0;
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 animate-in">
|
|
<PageHeader
|
|
title="AI Insights"
|
|
subtitle="Predictions, anomalies, and analytics"
|
|
actions={
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="btn btn-ghost p-2"
|
|
>
|
|
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<MetricCard
|
|
icon={Target}
|
|
label="Model Accuracy"
|
|
value={dashboard?.predictions.avgAccuracy || 'N/A'}
|
|
subtitle={`${dashboard?.predictions.count || 0} predictions`}
|
|
accent="accent"
|
|
/>
|
|
|
|
<div className={`
|
|
card p-4
|
|
${anomalyCount > 0 ? 'border-warning/30 bg-warning-muted' : 'border-success/30 bg-success-muted'}
|
|
`}>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<AlertTriangle className={anomalyCount > 0 ? 'text-warning' : 'text-success'} size={16} />
|
|
<span className="text-xs text-tertiary uppercase tracking-wider">Active Anomalies</span>
|
|
</div>
|
|
<div className={`text-2xl font-semibold ${anomalyCount > 0 ? 'text-warning' : 'text-success'}`}>
|
|
{anomalyCount}
|
|
</div>
|
|
<p className="text-xs text-tertiary mt-1">
|
|
{anomalyCount === 0 ? 'All systems normal' : 'Requires attention'}
|
|
</p>
|
|
</div>
|
|
|
|
<MetricCard
|
|
icon={BarChart3}
|
|
label="Top Performers"
|
|
value={dashboard?.performance.topBatches.length || 0}
|
|
subtitle="Batches with cost data"
|
|
accent="accent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Anomalies Section */}
|
|
<div className="card overflow-hidden">
|
|
<div className="p-4 border-b border-subtle">
|
|
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
|
<AlertTriangle className="text-warning" size={16} />
|
|
Detected Anomalies
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="divide-y divide-subtle">
|
|
{dashboard?.anomalies.recent.map(anomaly => (
|
|
<div
|
|
key={anomaly.id}
|
|
className={`p-4 flex items-start gap-3 ${anomaly.isResolved ? 'bg-tertiary' : ''}`}
|
|
>
|
|
<div className={`
|
|
w-8 h-8 rounded-md flex items-center justify-center
|
|
${anomaly.isResolved ? 'bg-tertiary text-tertiary' :
|
|
anomaly.severity === 'CRITICAL' ? 'bg-destructive-muted text-destructive' :
|
|
'bg-warning-muted text-warning'}
|
|
`}>
|
|
{anomaly.isResolved ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-medium text-sm ${anomaly.isResolved ? 'text-tertiary' : 'text-primary'}`}>
|
|
{anomaly.description}
|
|
</span>
|
|
<span className={`
|
|
text-[10px] font-medium px-1.5 py-0.5 rounded
|
|
${anomaly.severity === 'CRITICAL' ? 'badge-destructive' : 'badge-warning'}
|
|
`}>
|
|
{anomaly.severity}
|
|
</span>
|
|
</div>
|
|
<div className="text-[10px] text-tertiary mt-1">
|
|
{anomaly.entityType} · {anomaly.anomalyType.replace('_', ' ')} ·{' '}
|
|
{new Date(anomaly.detectedAt).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
{!anomaly.isResolved && (
|
|
<button
|
|
onClick={() => resolveAnomaly(anomaly.id)}
|
|
className="text-xs text-accent hover:underline"
|
|
>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(!dashboard?.anomalies.recent || dashboard.anomalies.recent.length === 0) && (
|
|
<div className="p-8 text-center">
|
|
<CheckCircle className="mx-auto text-success mb-2" size={24} />
|
|
<p className="text-tertiary text-sm">No anomalies detected</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Performing Batches */}
|
|
<div className="card overflow-hidden">
|
|
<div className="p-4 border-b border-subtle">
|
|
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
|
<TrendingUp className="text-success" size={16} />
|
|
Best Cost Efficiency
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="p-4">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="text-left text-[10px] text-tertiary uppercase tracking-wider">
|
|
<th className="pb-3 font-medium">Batch</th>
|
|
<th className="pb-3 font-medium">Cost/Gram</th>
|
|
<th className="pb-3 font-medium">Total Cost</th>
|
|
<th className="pb-3 font-medium">Yield</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-sm">
|
|
{dashboard?.performance.topBatches.map((batch, idx) => (
|
|
<tr key={batch.batchId} className="border-t border-subtle">
|
|
<td className="py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`
|
|
w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold
|
|
${idx === 0 ? 'bg-warning-muted text-warning' :
|
|
idx === 1 ? 'bg-tertiary text-secondary' :
|
|
idx === 2 ? 'bg-accent-muted text-accent' :
|
|
'bg-tertiary text-tertiary'}
|
|
`}>
|
|
{idx + 1}
|
|
</span>
|
|
<span className="font-medium text-primary">
|
|
{batch.batchId.substring(0, 8)}...
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 font-semibold text-success">${batch.costPerGram}/g</td>
|
|
<td className="py-3 text-secondary">${batch.totalCost.toLocaleString()}</td>
|
|
<td className="py-3 text-secondary">{batch.yieldGrams?.toLocaleString() || 'N/A'}g</td>
|
|
</tr>
|
|
))}
|
|
{(!dashboard?.performance.topBatches || dashboard.performance.topBatches.length === 0) && (
|
|
<tr>
|
|
<td colSpan={4} className="py-8 text-center text-tertiary text-sm">
|
|
No batch performance data available
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Predictions */}
|
|
<div className="card overflow-hidden">
|
|
<div className="p-4 border-b border-subtle">
|
|
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
|
<Zap className="text-accent" size={16} />
|
|
Recent Predictions
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-2">
|
|
{dashboard?.predictions.recentAccuracy.map(pred => (
|
|
<div
|
|
key={pred.batchId + pred.predictedAt}
|
|
className="flex items-center justify-between p-3 bg-tertiary rounded-md"
|
|
>
|
|
<div>
|
|
<span className="font-medium text-primary text-sm">
|
|
Batch {pred.batchId.substring(0, 8)}
|
|
</span>
|
|
<p className="text-[10px] text-tertiary">
|
|
{new Date(pred.predictedAt).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className={`
|
|
text-base font-semibold
|
|
${parseFloat(pred.accuracy) >= 80 ? 'text-success' :
|
|
parseFloat(pred.accuracy) >= 60 ? 'text-warning' : 'text-destructive'}
|
|
`}>
|
|
{pred.accuracy}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{(!dashboard?.predictions.recentAccuracy || dashboard.predictions.recentAccuracy.length === 0) && (
|
|
<p className="text-center text-tertiary py-4 text-sm">
|
|
No predictions with verified accuracy yet
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|