194 lines
7.1 KiB
TypeScript
194 lines
7.1 KiB
TypeScript
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||
|
||
function getNodePill(node: {
|
||
enabled: number;
|
||
last_error_message: string | null;
|
||
}) {
|
||
if (!node.enabled) {
|
||
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||
}
|
||
|
||
if (node.last_error_message) {
|
||
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||
}
|
||
|
||
return { label: "运行健康", className: "status-pill status-pill--ok" };
|
||
}
|
||
|
||
export default async function DashboardPage() {
|
||
const data = await getDashboardMetrics();
|
||
|
||
return (
|
||
<main className="page-stack">
|
||
<section className="hero-panel">
|
||
<div className="hero-copy">
|
||
<span className="eyebrow">OVERVIEW</span>
|
||
<h2>仪表盘</h2>
|
||
<p className="page-lede">
|
||
汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。
|
||
</p>
|
||
<div className="badge-row">
|
||
<span className="status-pill status-pill--ok">HTTP 鉴权在线</span>
|
||
<span className="status-pill status-pill--neutral">轮询聚合已启用</span>
|
||
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
||
</div>
|
||
</div>
|
||
<div className="hero-aside">
|
||
<div className="stat-tile">
|
||
<span>活动连接</span>
|
||
<strong>{data.totals.total_online_connections}</strong>
|
||
</div>
|
||
<div className="stat-tile">
|
||
<span>累计流量</span>
|
||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="metrics-grid">
|
||
<article className="metric-card metric-card--warm">
|
||
<span>总用户</span>
|
||
<strong>{data.totals.total_users}</strong>
|
||
<small>启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}</small>
|
||
</article>
|
||
<article className="metric-card metric-card--ink">
|
||
<span>活跃用户</span>
|
||
<strong>{data.totals.active_presence_rows}</strong>
|
||
<small>在线连接 {data.totals.total_online_connections}</small>
|
||
</article>
|
||
<article className="metric-card metric-card--sage">
|
||
<span>启用节点</span>
|
||
<strong>{data.totals.enabled_nodes}</strong>
|
||
<small>轮询与 kick 生效范围</small>
|
||
</article>
|
||
<article className="metric-card metric-card--paper">
|
||
<span>累计流量</span>
|
||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||
<small>已聚合到 SQLite</small>
|
||
</article>
|
||
</section>
|
||
|
||
<section className="panel-grid">
|
||
<article className="panel-card">
|
||
<div className="section-title">
|
||
<h3>节点健康</h3>
|
||
</div>
|
||
<div className="stack-md">
|
||
{data.nodeHealth.map((node) => (
|
||
<div key={node.id} className="list-row list-row--card">
|
||
<div>
|
||
<strong>{node.name}</strong>
|
||
<p className="muted mono">/{node.slug}</p>
|
||
</div>
|
||
<div className="align-right">
|
||
<p>
|
||
<span className={getNodePill(node).className}>
|
||
{getNodePill(node).label}
|
||
</span>
|
||
</p>
|
||
<p className="muted">
|
||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||
</p>
|
||
<p className="muted">
|
||
最近成功 {node.last_sync_ok_at ?? "无"}
|
||
</p>
|
||
{node.last_error_message ? (
|
||
<p className="error-text">{node.last_error_message}</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="panel-card">
|
||
<div className="section-title">
|
||
<h3>用量排行</h3>
|
||
</div>
|
||
<div className="stack-md">
|
||
{data.topUsers.map((user) => (
|
||
<div key={user.auth_id} className="list-row list-row--card">
|
||
<div>
|
||
<strong>{user.username}</strong>
|
||
<p className="muted mono">{user.auth_id}</p>
|
||
</div>
|
||
<div className="align-right">
|
||
<p>{formatBytes(user.total_bytes)}</p>
|
||
<p className="muted">
|
||
TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="panel-card">
|
||
<div className="section-title">
|
||
<h3>最近鉴权失败</h3>
|
||
</div>
|
||
<div className="stack-md">
|
||
{data.recentFailures.map((item, index) => (
|
||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||
<div>
|
||
<strong>{item.reason}</strong>
|
||
<p className="muted">
|
||
{item.node_name ?? "未知节点"} / {item.username ?? "未知用户"}
|
||
</p>
|
||
</div>
|
||
<div className="align-right">
|
||
<p className="muted mono">{item.addr ?? "-"}</p>
|
||
<p className="muted">{item.created_at}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="panel-card">
|
||
<div className="section-title">
|
||
<h3>最近流量入账</h3>
|
||
</div>
|
||
<div className="stack-md">
|
||
{data.recentUsage.map((item, index) => (
|
||
<div key={`${item.created_at}-${item.auth_id}-${index}`} className="list-row list-row--card">
|
||
<div>
|
||
<strong>{item.username ?? item.auth_id}</strong>
|
||
<p className="muted">{item.node_name ?? "未知节点"}</p>
|
||
</div>
|
||
<div className="align-right">
|
||
<p>
|
||
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
||
</p>
|
||
<p className="muted">{item.created_at}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="panel-card full-span">
|
||
<div className="section-title">
|
||
<h3>节点同步错误</h3>
|
||
</div>
|
||
<div className="stack-md">
|
||
{data.syncErrors.length === 0 ? (
|
||
<p className="muted">暂无同步错误。</p>
|
||
) : (
|
||
data.syncErrors.map((item, index) => (
|
||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
||
<div>
|
||
<strong>{item.node_name}</strong>
|
||
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
||
</div>
|
||
<p className="muted">{item.created_at}</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|