/* Copyright (C) 2025 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { API, isAdmin, showError } from '../../helpers'; const PAGE_SIZE = 200; const isSuccess = (l) => l?.type === 0 || l?.status === 0 || l?.type === 'success'; const sum = (arr, key) => arr.reduce((s, l) => s + (Number(l[key]) || 0), 0); export const useDashboardData = (userState) => { const initialized = useRef(false); const [loading, setLoading] = useState(false); const [recentLogs, setRecentLogs] = useState([]); const [uptimeData, setUptimeData] = useState([]); const [uptimeLoading, setUptimeLoading] = useState(false); const isAdminUser = isAdmin(); const user = userState?.user; const remaining = Number(user?.quota || 0); const used = Number(user?.used_quota || 0); const total = remaining + used; const loadRecentLogs = useCallback(async () => { setLoading(true); try { const now = Math.floor(Date.now() / 1000); const start = now - 30 * 86400; const url = isAdminUser ? `/api/log/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}` : `/api/log/self/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}`; const res = await API.get(url); const { success, message, data } = res.data; if (success) { setRecentLogs(data?.items || []); } else { showError(message); } } catch (err) { console.error(err); } finally { setLoading(false); } }, [isAdminUser]); const loadUptimeData = useCallback(async () => { setUptimeLoading(true); try { const res = await API.get('/api/uptime/status'); const { success, message, data } = res.data; if (success) { setUptimeData(data || []); } else { showError(message); } } catch (err) { console.error(err); } finally { setUptimeLoading(false); } }, []); useEffect(() => { if (!initialized.current) { loadRecentLogs(); loadUptimeData(); initialized.current = true; } }, [loadRecentLogs, loadUptimeData]); // ========== Today / month metrics ========== const metrics = useMemo(() => { const now = new Date(); const startOfDay = new Date( now.getFullYear(), now.getMonth(), now.getDate(), ).getTime() / 1000; const startOfMonth = new Date( now.getFullYear(), now.getMonth(), 1, ).getTime() / 1000; const todayLogs = recentLogs.filter( (l) => (l?.created_at || 0) >= startOfDay && isSuccess(l), ); const monthLogs = recentLogs.filter( (l) => (l?.created_at || 0) >= startOfMonth && isSuccess(l), ); const todayLatencies = todayLogs .map((l) => Number(l?.use_time) || 0) .filter((v) => v > 0); return { today: { requests: todayLogs.length, tokens: sum(todayLogs, 'token_used'), cost: sum(todayLogs, 'quota'), avgLatency: todayLatencies.length > 0 ? Math.round( todayLatencies.reduce((s, v) => s + v, 0) / todayLatencies.length, ) : null, }, month: { requests: monthLogs.length, tokens: sum(monthLogs, 'token_used'), cost: sum(monthLogs, 'quota'), }, }; }, [recentLogs]); // ========== N-day daily stats (for sparkline) ========== const dailyStats = useMemo(() => { const now = new Date(); const days = []; const n = 7; for (let i = n - 1; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); const dayStart = Math.floor(d.getTime() / 1000); const dayEnd = dayStart + 86400; const dayLogs = recentLogs.filter( (l) => (l?.created_at || 0) >= dayStart && (l?.created_at || 0) < dayEnd && isSuccess(l), ); days.push({ date: d, count: dayLogs.length, cost: sum(dayLogs, 'quota'), tokens: sum(dayLogs, 'token_used'), }); } return days; }, [recentLogs]); // ========== MoM delta (this month cost vs last month) ========== const monthDelta = useMemo(() => { const now = new Date(); const startOfLastMonth = new Date( now.getFullYear(), now.getMonth() - 1, 1, ).getTime() / 1000; const startOfThisMonth = new Date( now.getFullYear(), now.getMonth(), 1, ).getTime() / 1000; const lastMonthLogs = recentLogs.filter( (l) => (l?.created_at || 0) >= startOfLastMonth && (l?.created_at || 0) < startOfThisMonth && isSuccess(l), ); const lastCost = sum(lastMonthLogs, 'quota'); if (lastCost === 0) return null; return ((metrics.month.cost - lastCost) / lastCost) * 100; }, [recentLogs, metrics.month.cost]); // ========== Monthly forecast (projected end-of-month cost) ========== const monthlyForecast = useMemo(() => { const now = new Date(); const daysInMonth = new Date( now.getFullYear(), now.getMonth() + 1, 0, ).getDate(); const daysElapsed = now.getDate(); const spent = metrics.month.cost; const dailyAvg = daysElapsed > 0 ? spent / daysElapsed : 0; const projected = Math.round(dailyAvg * daysInMonth); return { spent, balance: remaining, dailyAvg: Math.round(dailyAvg), daysInMonth, daysElapsed, projected, monthDelta, }; }, [metrics.month.cost, remaining, monthDelta]); // ========== Days remaining estimate ========== const daysRemaining = useMemo(() => { const now = new Date(); const daysElapsed = now.getDate(); if (metrics.month.cost === 0 || daysElapsed === 0) return null; const dailyAvg = metrics.month.cost / daysElapsed; if (dailyAvg === 0) return null; const days = Math.floor(remaining / dailyAvg); return days > 0 ? days : 0; }, [metrics.month.cost, remaining]); // ========== SLA overview (uptime / error rate / p95 latency) ========== const sla = useMemo(() => { const totalCalls = recentLogs.length; const successCalls = recentLogs.filter((l) => isSuccess(l)).length; const errorRate = totalCalls > 0 ? ((totalCalls - successCalls) / totalCalls) * 100 : null; const latencies = recentLogs .map((l) => Number(l?.use_time) || 0) .filter((v) => v > 0) .sort((a, b) => a - b); const p95Latency = latencies.length > 0 ? latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95))] : null; const healthyChannels = uptimeData.filter((c) => c?.status === 1).length; const uptime = uptimeData.length > 0 ? uptimeData.reduce((s, c) => s + (Number(c?.uptime) || 0), 0) / uptimeData.length : null; const uptimePct = uptime != null ? uptime * 100 : healthyChannels / Math.max(uptimeData.length, 1) * 100; return { uptime: uptime != null ? uptimePct : null, errorRate, p95Latency, totalCalls, channelCount: uptimeData.length, healthyChannels, }; }, [recentLogs, uptimeData]); // ========== Top models ========== const topModels = useMemo(() => { const m = new Map(); recentLogs.forEach((l) => { const name = l?.model_name || '未知模型'; m.set(name, (m.get(name) || 0) + 1); }); return Array.from(m.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count) .slice(0, 5); }, [recentLogs]); const refresh = useCallback(async () => { await Promise.all([loadRecentLogs(), loadUptimeData()]); }, [loadRecentLogs, loadUptimeData]); return { loading, recentLogs, uptimeData, uptimeLoading, metrics, dailyStats, monthDelta, daysRemaining, topModels, sla, monthlyForecast, refresh, quotaTotal: total, }; };