Spaces:
Sleeping
Sleeping
fix: resolve AI assistant page client crash by converting to HTTP streaming fallback, passing env vars to supervisord, and add payments feature
d581b14 | "use client"; | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { motion } from "framer-motion"; | |
| import { | |
| Activity, Database, Cpu, Wifi, Shield, RefreshCw, | |
| TrendingUp, AlertTriangle, CheckCircle, Clock | |
| } from "lucide-react"; | |
| interface Metrics { | |
| uptime_seconds: number; | |
| requests: { total: number; errors: number; auth_failures: number; error_rate_pct: number }; | |
| websocket: { total_connects: number; reconnects: number }; | |
| ai: { | |
| fallbacks: number; | |
| by_provider: Record<string, { calls: number; errors: number; avg_latency_ms: number; p95_latency_ms: number | null }>; | |
| }; | |
| cache: { hits: number; misses: number; hit_ratio_pct: number }; | |
| route_timings: Record<string, { calls: number; avg_ms: number; max_ms: number }>; | |
| recent_errors: Array<{ ts: string; path: string; status: number; detail: string }>; | |
| } | |
| interface ApiStatus { | |
| ai_backend: string; | |
| ai_available: boolean; | |
| db_type: string; | |
| cache_type: string; | |
| version: string; | |
| } | |
| const API = process.env.NEXT_PUBLIC_API_URL || ""; | |
| function formatUptime(s: number) { | |
| if (s < 60) return `${s}s`; | |
| if (s < 3600) return `${Math.floor(s / 60)}m ${Math.floor(s % 60)}s`; | |
| return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; | |
| } | |
| function StatusDot({ ok }: { ok: boolean }) { | |
| return ( | |
| <span className={`inline-block h-2 w-2 rounded-full ${ok ? "bg-emerald-400" : "bg-red-400"} ${ok ? "animate-pulse" : ""}`} /> | |
| ); | |
| } | |
| function MetricCard({ label, value, sub, icon: Icon, color = "text-zinc-400" }: { | |
| label: string; value: string | number; sub?: string; | |
| icon: React.ElementType; color?: string; | |
| }) { | |
| return ( | |
| <motion.div | |
| whileHover={{ scale: 1.02 }} | |
| className="glass-card p-5" | |
| > | |
| <div className="flex items-start justify-between mb-3"> | |
| <p className="text-xs text-zinc-500 uppercase tracking-wider">{label}</p> | |
| <Icon className={`h-4 w-4 ${color}`} /> | |
| </div> | |
| <p className="text-2xl font-bold text-white">{value}</p> | |
| {sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>} | |
| </motion.div> | |
| ); | |
| } | |
| const containerVariants = { | |
| hidden: { opacity: 0 }, | |
| visible: { opacity: 1, transition: { staggerChildren: 0.06 } }, | |
| }; | |
| const itemVariants = { | |
| hidden: { opacity: 0, y: 14 }, | |
| visible: { opacity: 1, y: 0, transition: { duration: 0.4 } }, | |
| }; | |
| export default function StatusPage() { | |
| const [metrics, setMetrics] = useState<Metrics | null>(null); | |
| const [status, setStatus] = useState<ApiStatus | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [lastRefresh, setLastRefresh] = useState<Date | null>(null); | |
| const [backendOnline, setBackendOnline] = useState(false); | |
| const load = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const [m, s] = await Promise.all([ | |
| fetch(`${API}/api/metrics`).then((r) => r.json()), | |
| fetch(`${API}/api/status`).then((r) => r.json()), | |
| ]); | |
| setMetrics(m); | |
| setStatus(s); | |
| setBackendOnline(true); | |
| setLastRefresh(new Date()); | |
| } catch { | |
| setBackendOnline(false); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| load(); | |
| const interval = setInterval(load, 15000); // auto-refresh every 15s | |
| return () => clearInterval(interval); | |
| }, [load]); | |
| const aiProviders = metrics ? Object.entries(metrics.ai.by_provider) : []; | |
| const topRoutes = metrics | |
| ? Object.entries(metrics.route_timings).sort((a, b) => b[1].avg_ms - a[1].avg_ms).slice(0, 6) | |
| : []; | |
| return ( | |
| <motion.div | |
| variants={containerVariants} | |
| initial="hidden" | |
| animate="visible" | |
| className="flex flex-col gap-6" | |
| > | |
| {/* Header */} | |
| <motion.div variants={itemVariants} className="flex items-center justify-between"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight text-white">System Status</h1> | |
| <p className="text-zinc-400 mt-1 text-sm"> | |
| Live observability — auto-refreshes every 15s | |
| {lastRefresh && ( | |
| <span className="ml-2 text-zinc-600"> | |
| · Last updated {lastRefresh.toLocaleTimeString()} | |
| </span> | |
| )} | |
| </p> | |
| </div> | |
| <button | |
| onClick={load} | |
| className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white transition-colors" | |
| > | |
| <RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} /> | |
| Refresh | |
| </button> | |
| </motion.div> | |
| {/* Overall health banner */} | |
| <motion.div | |
| variants={itemVariants} | |
| className={`flex items-center gap-3 rounded-2xl border px-5 py-3 ${ | |
| backendOnline | |
| ? "border-emerald-500/20 bg-emerald-500/5" | |
| : "border-red-500/20 bg-red-500/5" | |
| }`} | |
| > | |
| {backendOnline ? ( | |
| <CheckCircle className="h-4 w-4 text-emerald-400 flex-shrink-0" /> | |
| ) : ( | |
| <AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" /> | |
| )} | |
| <div> | |
| <p className={`text-sm font-semibold ${backendOnline ? "text-emerald-400" : "text-red-400"}`}> | |
| {backendOnline ? "All Systems Operational" : "Backend Offline"} | |
| </p> | |
| <p className="text-xs text-zinc-500 mt-0.5"> | |
| {backendOnline | |
| ? `API v${status?.version || "2.0.0"} · ${status?.db_type?.toUpperCase()} · ${status?.cache_type?.toUpperCase()} cache · Uptime ${formatUptime(metrics?.uptime_seconds || 0)}` | |
| : "Cannot reach backend. Start with: uvicorn app.main:app --port 8000"} | |
| </p> | |
| </div> | |
| </motion.div> | |
| {/* System info cards */} | |
| {status && ( | |
| <motion.div variants={itemVariants} className="grid grid-cols-2 sm:grid-cols-4 gap-4"> | |
| {[ | |
| { | |
| label: "AI Backend", | |
| value: status.ai_backend.toUpperCase(), | |
| sub: status.ai_available ? "Available" : "Unavailable", | |
| icon: Cpu, | |
| color: status.ai_available ? "text-emerald-400" : "text-red-400", | |
| }, | |
| { | |
| label: "Database", | |
| value: status.db_type.toUpperCase(), | |
| sub: "Connected", | |
| icon: Database, | |
| color: "text-blue-400", | |
| }, | |
| { | |
| label: "Cache", | |
| value: status.cache_type.toUpperCase(), | |
| sub: metrics ? `${metrics.cache.hit_ratio_pct}% hit rate` : "—", | |
| icon: Activity, | |
| color: "text-purple-400", | |
| }, | |
| { | |
| label: "Uptime", | |
| value: formatUptime(metrics?.uptime_seconds || 0), | |
| sub: "Since last restart", | |
| icon: Clock, | |
| color: "text-amber-400", | |
| }, | |
| ].map((c) => ( | |
| <MetricCard key={c.label} {...c} /> | |
| ))} | |
| </motion.div> | |
| )} | |
| {/* Request metrics */} | |
| {metrics && ( | |
| <motion.div variants={itemVariants} className="grid grid-cols-2 sm:grid-cols-4 gap-4"> | |
| <MetricCard | |
| label="Total Requests" | |
| value={metrics.requests.total.toLocaleString()} | |
| sub="Since startup" | |
| icon={TrendingUp} | |
| color="text-emerald-400" | |
| /> | |
| <MetricCard | |
| label="Error Rate" | |
| value={`${metrics.requests.error_rate_pct}%`} | |
| sub={`${metrics.requests.errors} errors`} | |
| icon={AlertTriangle} | |
| color={metrics.requests.error_rate_pct > 5 ? "text-red-400" : "text-emerald-400"} | |
| /> | |
| <MetricCard | |
| label="Auth Failures" | |
| value={metrics.requests.auth_failures} | |
| sub="401 responses" | |
| icon={Shield} | |
| color={metrics.requests.auth_failures > 10 ? "text-amber-400" : "text-zinc-400"} | |
| /> | |
| <MetricCard | |
| label="WebSocket Connects" | |
| value={metrics.websocket.total_connects} | |
| sub={`${metrics.websocket.reconnects} reconnects`} | |
| icon={Wifi} | |
| color="text-cyan-400" | |
| /> | |
| </motion.div> | |
| )} | |
| <div className="grid gap-6 lg:grid-cols-2"> | |
| {/* AI Provider Health */} | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-5"> | |
| <Cpu className="h-4 w-4 text-emerald-400" /> | |
| <h2 className="text-sm font-semibold text-white">AI Provider Health</h2> | |
| </div> | |
| {aiProviders.length === 0 ? ( | |
| <div className="flex flex-col items-center gap-2 py-8 text-center"> | |
| <Cpu className="h-8 w-8 text-zinc-700" /> | |
| <p className="text-sm text-zinc-500">No AI calls recorded yet</p> | |
| <p className="text-xs text-zinc-600">Use the chat or briefing endpoints to generate AI calls</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {aiProviders.map(([provider, data]) => { | |
| const errorRate = data.calls > 0 ? (data.errors / data.calls) * 100 : 0; | |
| return ( | |
| <div key={provider} className="space-y-2"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <StatusDot ok={errorRate < 10} /> | |
| <span className="text-sm font-medium text-white capitalize">{provider}</span> | |
| </div> | |
| <div className="flex items-center gap-4 text-xs text-zinc-400"> | |
| <span>{data.calls} calls</span> | |
| <span className={errorRate > 10 ? "text-red-400" : "text-emerald-400"}> | |
| {errorRate.toFixed(1)}% errors | |
| </span> | |
| <span>{data.avg_latency_ms}ms avg</span> | |
| </div> | |
| </div> | |
| <div className="h-1.5 w-full rounded-full bg-white/5 overflow-hidden"> | |
| <div | |
| className="h-full rounded-full bg-emerald-400 transition-all duration-500" | |
| style={{ width: `${Math.min(100 - errorRate, 100)}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| <div className="pt-2 border-t border-white/8"> | |
| <p className="text-xs text-zinc-500"> | |
| AI fallbacks: <span className="text-white font-medium">{metrics?.ai.fallbacks || 0}</span> | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| </motion.div> | |
| {/* Cache Performance */} | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-5"> | |
| <Activity className="h-4 w-4 text-purple-400" /> | |
| <h2 className="text-sm font-semibold text-white">Cache Performance</h2> | |
| </div> | |
| {metrics && ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-zinc-400">Hit Ratio</span> | |
| <span className={`text-sm font-bold ${metrics.cache.hit_ratio_pct > 60 ? "text-emerald-400" : "text-amber-400"}`}> | |
| {metrics.cache.hit_ratio_pct}% | |
| </span> | |
| </div> | |
| <div className="h-3 w-full rounded-full bg-white/5 overflow-hidden"> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: `${metrics.cache.hit_ratio_pct}%` }} | |
| transition={{ duration: 0.8, ease: "easeOut" }} | |
| className="h-full rounded-full bg-gradient-to-r from-purple-500 to-blue-500" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4 pt-2"> | |
| <div className="rounded-xl bg-white/5 p-3 text-center"> | |
| <p className="text-lg font-bold text-emerald-400">{metrics.cache.hits}</p> | |
| <p className="text-xs text-zinc-500 mt-0.5">Cache Hits</p> | |
| </div> | |
| <div className="rounded-xl bg-white/5 p-3 text-center"> | |
| <p className="text-lg font-bold text-zinc-400">{metrics.cache.misses}</p> | |
| <p className="text-xs text-zinc-500 mt-0.5">Cache Misses</p> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </motion.div> | |
| </div> | |
| {/* Route Timings */} | |
| {topRoutes.length > 0 && ( | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-5"> | |
| <Clock className="h-4 w-4 text-amber-400" /> | |
| <h2 className="text-sm font-semibold text-white">Route Performance</h2> | |
| <span className="text-xs text-zinc-500 ml-auto">Sorted by avg response time</span> | |
| </div> | |
| <div className="space-y-2"> | |
| {topRoutes.map(([path, data]) => { | |
| const isSlow = data.avg_ms > 500; | |
| const barWidth = Math.min((data.avg_ms / 2000) * 100, 100); | |
| return ( | |
| <div key={path} className="flex items-center gap-4"> | |
| <code className="text-xs text-zinc-400 w-56 truncate flex-shrink-0">{path}</code> | |
| <div className="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full transition-all duration-500 ${isSlow ? "bg-amber-400" : "bg-emerald-400"}`} | |
| style={{ width: `${barWidth}%` }} | |
| /> | |
| </div> | |
| <div className="flex items-center gap-3 text-xs text-zinc-400 w-40 flex-shrink-0 text-right"> | |
| <span className={`font-medium ${isSlow ? "text-amber-400" : "text-emerald-400"}`}> | |
| {data.avg_ms}ms avg | |
| </span> | |
| <span className="text-zinc-600">{data.max_ms}ms max</span> | |
| <span className="text-zinc-600">{data.calls} calls</span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Recent Errors */} | |
| {metrics && metrics.recent_errors.length > 0 && ( | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-5"> | |
| <AlertTriangle className="h-4 w-4 text-red-400" /> | |
| <h2 className="text-sm font-semibold text-white">Recent Errors</h2> | |
| <span className="text-xs text-zinc-500 ml-auto">Last 10</span> | |
| </div> | |
| <div className="space-y-2"> | |
| {metrics.recent_errors.map((err, i) => ( | |
| <div key={i} className="flex items-center gap-3 rounded-xl bg-red-500/5 border border-red-500/10 px-3 py-2"> | |
| <span className={`text-xs font-bold px-1.5 py-0.5 rounded ${ | |
| err.status >= 500 ? "bg-red-500/20 text-red-400" : "bg-amber-500/20 text-amber-400" | |
| }`}> | |
| {err.status} | |
| </span> | |
| <code className="text-xs text-zinc-400 flex-1 truncate">{err.path}</code> | |
| <span className="text-xs text-zinc-600 flex-shrink-0"> | |
| {new Date(err.ts).toLocaleTimeString()} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </motion.div> | |
| ); | |
| } | |