169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import "../../styles/pages/provider-health.css";
|
|
import {
|
|
CheckCircleOutlined,
|
|
CloseCircleOutlined,
|
|
ExclamationCircleOutlined,
|
|
ReloadOutlined,
|
|
} from "@ant-design/icons";
|
|
import { providerHealthClient, type ProviderHealthResponse } from "../../api/providerHealthClient";
|
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
|
import type { WebUserSession } from "../../types";
|
|
|
|
interface ProviderHealthPageProps {
|
|
session: WebUserSession | null;
|
|
onOpenLogin: () => void;
|
|
}
|
|
|
|
const STATUS_ICON: Record<string, React.ReactNode> = {
|
|
healthy: <CheckCircleOutlined style={{ color: "#10b981" }} />,
|
|
arrears: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
|
|
denied: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
|
|
error: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
|
timeout: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
|
no_key: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
|
unknown: <ExclamationCircleOutlined style={{ color: "#9ca3af" }} />,
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
healthy: "正常",
|
|
arrears: "欠费",
|
|
denied: "权限拒绝",
|
|
error: "异常",
|
|
timeout: "超时",
|
|
no_key: "无密钥",
|
|
unknown: "未知",
|
|
};
|
|
|
|
export default function ProviderHealthPage({ session, onOpenLogin }: ProviderHealthPageProps) {
|
|
const isAdmin = session?.user?.role === "admin";
|
|
const [data, setData] = useState<ProviderHealthResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await providerHealthClient.getStatus();
|
|
setData(result);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "加载失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isAdmin) void load();
|
|
}, [isAdmin, load]);
|
|
|
|
if (!session) {
|
|
return (
|
|
<WorkspacePageShell title="服务商健康" fullWidth className="provider-health-page page-motion">
|
|
<section className="provider-health-access">
|
|
<p>请登录管理员账号后查看。</p>
|
|
<button type="button" onClick={onOpenLogin}>登录</button>
|
|
</section>
|
|
</WorkspacePageShell>
|
|
);
|
|
}
|
|
|
|
if (!isAdmin) {
|
|
return (
|
|
<WorkspacePageShell title="服务商健康" fullWidth className="provider-health-page page-motion">
|
|
<section className="provider-health-access">
|
|
<p>当前账号没有管理员权限。</p>
|
|
</section>
|
|
</WorkspacePageShell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<WorkspacePageShell title="服务商健康" fullWidth className="provider-health-page page-motion">
|
|
<div className="provider-health-page__inner">
|
|
<section className="provider-health-toolbar">
|
|
<div>
|
|
<h2>服务商健康监控</h2>
|
|
<p>各 AI 服务商的健康状态、调用统计与密钥池负载。</p>
|
|
</div>
|
|
<button type="button" disabled={loading} onClick={() => void load()}>
|
|
<ReloadOutlined /> 刷新
|
|
</button>
|
|
</section>
|
|
|
|
{error ? <p className="provider-health-error">{error}</p> : null}
|
|
|
|
{/* Health status cards */}
|
|
<div className="provider-health-grid">
|
|
{data?.health ? Object.entries(data.health).map(([provider, entry]) => (
|
|
<div key={provider} className="provider-health-card">
|
|
<div className="provider-health-card__header">
|
|
{STATUS_ICON[entry.status] || STATUS_ICON.unknown}
|
|
<strong>{provider}</strong>
|
|
<span className={`provider-health-card__status provider-health-card__status--${entry.status}`}>
|
|
{STATUS_LABEL[entry.status] || entry.status}
|
|
</span>
|
|
</div>
|
|
{entry.lastCheck ? (
|
|
<small className="provider-health-card__time">
|
|
上次检查: {new Date(entry.lastCheck).toLocaleString("zh-CN")}
|
|
</small>
|
|
) : null}
|
|
{entry.lastError ? (
|
|
<p className="provider-health-card__error">{entry.lastError}</p>
|
|
) : null}
|
|
</div>
|
|
)) : null}
|
|
</div>
|
|
|
|
{/* Call stats table */}
|
|
{data?.callStats?.length ? (
|
|
<section className="provider-health-section">
|
|
<h3>最近 1h 调用统计</h3>
|
|
<table className="provider-health-table">
|
|
<thead>
|
|
<tr><th>服务商</th><th>模型</th><th>状态</th><th>调用数</th><th>平均耗时</th><th>总成本</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.callStats.map((row, i) => (
|
|
<tr key={i}>
|
|
<td>{row.provider}</td>
|
|
<td>{row.model}</td>
|
|
<td>{row.status}</td>
|
|
<td>{row.count}</td>
|
|
<td>{row.avg_ms ? `${Math.round(Number(row.avg_ms))}ms` : "-"}</td>
|
|
<td>{row.total_cost ? `¥${Number(row.total_cost).toFixed(2)}` : "-"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
) : null}
|
|
|
|
{/* Key pool stats */}
|
|
{data?.keyStats?.length ? (
|
|
<section className="provider-health-section">
|
|
<h3>密钥池负载</h3>
|
|
<table className="provider-health-table">
|
|
<thead>
|
|
<tr><th>服务商</th><th>总密钥</th><th>活跃密钥</th><th>当前负载</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.keyStats.map((row, i) => (
|
|
<tr key={i}>
|
|
<td>{row.provider}</td>
|
|
<td>{row.total_keys}</td>
|
|
<td>{row.active_keys}</td>
|
|
<td>{row.current_load}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</WorkspacePageShell>
|
|
);
|
|
}
|