286 lines
8.5 KiB
JavaScript
286 lines
8.5 KiB
JavaScript
/*
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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,
|
|
};
|
|
};
|