优化ui
This commit is contained in:
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
@@ -24,7 +23,6 @@ export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
className={`nav-link ${active ? "is-active" : ""}`}
|
||||
>
|
||||
<span className="nav-link__label">{item.label}</span>
|
||||
<span className="nav-link__meta">{item.meta}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
+31
-26
@@ -1,15 +1,15 @@
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { formatBytes, listAudits } from "@/lib/store";
|
||||
|
||||
export default async function AuditsPage() {
|
||||
const audits = await listAudits();
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<main className="page-stack management-page">
|
||||
<section className="hero-panel hero-panel--compact">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">AUDIT</span>
|
||||
<span className="eyebrow">审计</span>
|
||||
<h2>鉴权审计</h2>
|
||||
<p className="page-lede">记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。</p>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
<div className="stat-tile">
|
||||
@@ -19,33 +19,38 @@ export default async function AuditsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="stack-md">
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="entity-table entity-table--audits">
|
||||
<div className="entity-table__head audit-row-grid">
|
||||
<span>结果</span>
|
||||
<span>节点 / 用户</span>
|
||||
<span>来源</span>
|
||||
<span>请求速率</span>
|
||||
<span>原因</span>
|
||||
<span>时间</span>
|
||||
</div>
|
||||
{audits.map((audit) => (
|
||||
<div key={audit.id} className="list-row list-row--card">
|
||||
<div>
|
||||
<div className="badge-row">
|
||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||
{audit.ok ? "允许" : "拒绝"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"}
|
||||
</p>
|
||||
<p className="muted mono">{audit.addr ?? "-"}</p>
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p>{audit.reason}</p>
|
||||
<p className="muted">
|
||||
请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}
|
||||
/s
|
||||
</p>
|
||||
<p className="muted">{audit.created_at}</p>
|
||||
</div>
|
||||
<div key={audit.id} className="entity-table__summary audit-row-grid">
|
||||
<span>
|
||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||
{audit.ok ? "允许" : "拒绝"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="entity-main-cell">
|
||||
<strong>{audit.node_name ?? "未知节点"}</strong>
|
||||
<span className="muted mono break-all">{audit.username ?? audit.auth_id ?? "-"}</span>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">{audit.addr ?? "-"}</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}</strong>
|
||||
<span>/s</span>
|
||||
</span>
|
||||
<span>{audit.reason}</span>
|
||||
<span className="entity-muted-cell">{formatLocalDateTime(audit.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useId, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
type NodeHealth = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
enabled: number;
|
||||
last_error_message: string | null;
|
||||
};
|
||||
|
||||
type TrafficTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
auth_id: string;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type TrafficUserOption = {
|
||||
auth_id: string;
|
||||
username: string;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type LoadTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
online_users: number;
|
||||
stream_count: number;
|
||||
};
|
||||
|
||||
type FailureTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
failed_count: number;
|
||||
};
|
||||
|
||||
type TopConsumerRow = {
|
||||
node_id: number;
|
||||
auth_id: string;
|
||||
username: string;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type DashboardChartsProps = {
|
||||
nodeHealth: NodeHealth[];
|
||||
trafficTrend: TrafficTrendRow[];
|
||||
trafficBuckets: string[];
|
||||
trafficUserOptions: TrafficUserOption[];
|
||||
loadTrend: LoadTrendRow[];
|
||||
failureTrend: FailureTrendRow[];
|
||||
topConsumerRows: TopConsumerRow[];
|
||||
};
|
||||
|
||||
type TrafficPoint = {
|
||||
bucketStart: string;
|
||||
label: string;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
const nodePalette = [
|
||||
"#c96233",
|
||||
"#2b6c80",
|
||||
"#5d7b63",
|
||||
"#7a3116",
|
||||
"#6f5aa8",
|
||||
"#9c6b12",
|
||||
"#2f5d52",
|
||||
"#8a3f55",
|
||||
];
|
||||
|
||||
const axisTick = {
|
||||
fill: "#3f4b57",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const tooltipStyle = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(24, 34, 44, 0.12)",
|
||||
backgroundColor: "rgba(255, 251, 245, 0.96)",
|
||||
boxShadow: "0 12px 34px rgba(51, 35, 18, 0.1)",
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = units[0];
|
||||
|
||||
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||
value /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatCompactBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = units[0];
|
||||
|
||||
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||
value /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}${unit}`;
|
||||
}
|
||||
|
||||
function formatBucketLabel(value: string, mode: "hour" | "day") {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
...(mode === "hour"
|
||||
? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
|
||||
: {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function truncateLabel(value: string, maxLength = 12) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function getNodeColor(index: number) {
|
||||
return nodePalette[index % nodePalette.length];
|
||||
}
|
||||
|
||||
function getNodeDataKey(nodeId: number) {
|
||||
return `node_${nodeId}`;
|
||||
}
|
||||
|
||||
function matchesUser(row: { auth_id: string }, selectedAuthId: string) {
|
||||
return selectedAuthId === "all" || row.auth_id === selectedAuthId;
|
||||
}
|
||||
|
||||
function buildNodeTrafficSeries(
|
||||
rows: TrafficTrendRow[],
|
||||
buckets: string[],
|
||||
nodes: NodeHealth[],
|
||||
selectedAuthId: string,
|
||||
) {
|
||||
const series = buckets.map((bucketStart) => {
|
||||
const point: TrafficPoint = {
|
||||
bucketStart,
|
||||
label: formatBucketLabel(bucketStart, "hour"),
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
point[getNodeDataKey(node.id)] = 0;
|
||||
}
|
||||
|
||||
return point;
|
||||
});
|
||||
const pointMap = new Map(series.map((point) => [point.bucketStart, point]));
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = pointMap.get(row.bucket_start);
|
||||
|
||||
if (!point) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = getNodeDataKey(row.node_id);
|
||||
point[key] = Number(point[key] ?? 0) + row.total_bytes;
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
function aggregateTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||
return rows.reduce(
|
||||
(accumulator, row) => {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
return {
|
||||
txBytes: accumulator.txBytes + row.tx_bytes,
|
||||
rxBytes: accumulator.rxBytes + row.rx_bytes,
|
||||
totalBytes: accumulator.totalBytes + row.total_bytes,
|
||||
};
|
||||
},
|
||||
{ txBytes: 0, rxBytes: 0, totalBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateNodeTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||
const totals = new Map<number, number>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totals.set(row.node_id, (totals.get(row.node_id) ?? 0) + row.total_bytes);
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
function aggregateLoadSeries(rows: LoadTrendRow[]) {
|
||||
const map = new Map<string, {
|
||||
bucketStart: string;
|
||||
onlineUsers: number;
|
||||
streamCount: number;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.bucket_start) ?? {
|
||||
bucketStart: row.bucket_start,
|
||||
onlineUsers: 0,
|
||||
streamCount: 0,
|
||||
};
|
||||
|
||||
existing.onlineUsers += row.online_users;
|
||||
existing.streamCount += row.stream_count;
|
||||
map.set(row.bucket_start, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: formatBucketLabel(item.bucketStart, "hour"),
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateFailureSeries(rows: FailureTrendRow[]) {
|
||||
const map = new Map<string, { bucketStart: string; failedCount: number }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.bucket_start) ?? {
|
||||
bucketStart: row.bucket_start,
|
||||
failedCount: 0,
|
||||
};
|
||||
|
||||
existing.failedCount += row.failed_count;
|
||||
map.set(row.bucket_start, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: formatBucketLabel(item.bucketStart, "day"),
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateTopConsumers(rows: TopConsumerRow[], selectedAuthId: string) {
|
||||
const map = new Map<string, {
|
||||
authId: string;
|
||||
username: string;
|
||||
txBytes: number;
|
||||
rxBytes: number;
|
||||
totalBytes: number;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = map.get(row.auth_id) ?? {
|
||||
authId: row.auth_id,
|
||||
username: row.username,
|
||||
txBytes: 0,
|
||||
rxBytes: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
existing.txBytes += row.tx_bytes;
|
||||
existing.rxBytes += row.rx_bytes;
|
||||
existing.totalBytes += row.total_bytes;
|
||||
map.set(row.auth_id, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => right.totalBytes - left.totalBytes)
|
||||
.slice(0, 6)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: truncateLabel(item.username),
|
||||
}));
|
||||
}
|
||||
|
||||
export function DashboardCharts({
|
||||
nodeHealth,
|
||||
trafficTrend,
|
||||
trafficBuckets,
|
||||
trafficUserOptions,
|
||||
loadTrend,
|
||||
failureTrend,
|
||||
topConsumerRows,
|
||||
}: DashboardChartsProps) {
|
||||
const userSelectId = useId();
|
||||
const [selectedAuthId, setSelectedAuthId] = useState("all");
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<number>>(() => new Set());
|
||||
const selectedUser = selectedAuthId === "all"
|
||||
? null
|
||||
: trafficUserOptions.find((user) => user.auth_id === selectedAuthId) ?? null;
|
||||
const selectedUserLabel = selectedUser?.username ?? "全部用户";
|
||||
|
||||
const trafficSeries = buildNodeTrafficSeries(
|
||||
trafficTrend,
|
||||
trafficBuckets,
|
||||
nodeHealth,
|
||||
selectedAuthId,
|
||||
);
|
||||
const trafficTotals = aggregateTrafficTotals(trafficTrend, selectedAuthId);
|
||||
const nodeTrafficTotals = aggregateNodeTrafficTotals(trafficTrend, selectedAuthId);
|
||||
const loadSeries = aggregateLoadSeries(loadTrend);
|
||||
const failureSeries = aggregateFailureSeries(failureTrend);
|
||||
const consumerSeries = aggregateTopConsumers(topConsumerRows, selectedAuthId);
|
||||
const visibleNodes = nodeHealth.filter((node) => !hiddenNodeIds.has(node.id));
|
||||
|
||||
const peakLoad = loadSeries.reduce(
|
||||
(accumulator, item) => ({
|
||||
onlineUsers: Math.max(accumulator.onlineUsers, item.onlineUsers),
|
||||
streamCount: Math.max(accumulator.streamCount, item.streamCount),
|
||||
}),
|
||||
{ onlineUsers: 0, streamCount: 0 },
|
||||
);
|
||||
const totalFailures = failureSeries.reduce(
|
||||
(accumulator, item) => accumulator + item.failedCount,
|
||||
0,
|
||||
);
|
||||
const healthyNodes = nodeHealth.filter(
|
||||
(node) => node.enabled && !node.last_error_message,
|
||||
).length;
|
||||
const disabledNodes = nodeHealth.filter((node) => !node.enabled).length;
|
||||
const degradedNodes = nodeHealth.length - healthyNodes - disabledNodes;
|
||||
const statusTone = degradedNodes > 0 ? "danger" : disabledNodes > 0 ? "warm" : "ok";
|
||||
const statusLabel = `${healthyNodes}/${nodeHealth.length} 节点健康`;
|
||||
const hasTrafficData = trafficTotals.totalBytes > 0;
|
||||
const hasVisibleTrafficData = visibleNodes.some((node) => {
|
||||
const key = getNodeDataKey(node.id);
|
||||
return trafficSeries.some((point) => Number(point[key] ?? 0) > 0);
|
||||
});
|
||||
const hasLoadData = loadSeries.some(
|
||||
(item) => item.onlineUsers > 0 || item.streamCount > 0,
|
||||
);
|
||||
const hasFailureData = failureSeries.some((item) => item.failedCount > 0);
|
||||
const hasConsumerData = consumerSeries.length > 0;
|
||||
|
||||
function toggleNodeLine(nodeId: number) {
|
||||
startTransition(() => {
|
||||
setHiddenNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-card dashboard-analytics">
|
||||
<div className="dashboard-toolbar">
|
||||
<div className="stack-tight">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>流量与节点趋势</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--warm">{selectedUserLabel}</span>
|
||||
<span className={`status-pill status-pill--${statusTone}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-filter">
|
||||
<label htmlFor={userSelectId}>用户</label>
|
||||
<select
|
||||
id={userSelectId}
|
||||
value={selectedAuthId}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
startTransition(() => {
|
||||
setSelectedAuthId(nextValue);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="all">全部用户</option>
|
||||
{trafficUserOptions.map((user) => (
|
||||
<option key={user.auth_id} value={user.auth_id}>
|
||||
{user.username} / {formatCompactBytes(user.total_bytes)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-glance-grid">
|
||||
<article className="dashboard-glance-card dashboard-glance-card--warm">
|
||||
<span>24h 总流量</span>
|
||||
<strong>{formatBytes(trafficTotals.totalBytes)}</strong>
|
||||
<small>TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)}</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--ink">
|
||||
<span>24h 峰值在线</span>
|
||||
<strong>{peakLoad.onlineUsers}</strong>
|
||||
<small>峰值流数 {peakLoad.streamCount}</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--sage">
|
||||
<span>14d 鉴权失败</span>
|
||||
<strong>{totalFailures}</strong>
|
||||
<small>失败请求数</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--paper">
|
||||
<span>节点状态</span>
|
||||
<strong>{statusLabel}</strong>
|
||||
<small>异常 {degradedNodes} / 已禁用 {disabledNodes}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-chart-grid">
|
||||
<article className="dashboard-chart-card dashboard-chart-card--wide">
|
||||
<div className="dashboard-chart-card__head">
|
||||
<div className="section-title">
|
||||
<h4>节点流量</h4>
|
||||
<span className="status-pill status-pill--warm">最近 24 小时</span>
|
||||
</div>
|
||||
<div className="node-line-toggles">
|
||||
{nodeHealth.map((node, index) => {
|
||||
const hidden = hiddenNodeIds.has(node.id);
|
||||
const color = getNodeColor(index);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={`node-line-toggle ${hidden ? "is-hidden" : ""}`}
|
||||
aria-pressed={!hidden}
|
||||
onClick={() => toggleNodeLine(node.id)}
|
||||
>
|
||||
<span
|
||||
className="node-line-toggle__swatch"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span>{node.name}</span>
|
||||
<small>{formatCompactBytes(nodeTrafficTotals.get(node.id) ?? 0)}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{hasTrafficData && hasVisibleTrafficData ? (
|
||||
<div className="chart-shell chart-shell--wide">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trafficSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
minTickGap={22}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCompactBytes}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={58}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(Number(value ?? 0)),
|
||||
String(name),
|
||||
]}
|
||||
labelFormatter={(label) => `时间 ${label}`}
|
||||
/>
|
||||
{visibleNodes.map((node, index) => (
|
||||
<Line
|
||||
key={node.id}
|
||||
type="monotone"
|
||||
dataKey={getNodeDataKey(node.id)}
|
||||
name={node.name}
|
||||
stroke={getNodeColor(index)}
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无可见节点流量。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>在线负载</h4>
|
||||
<span className="status-pill status-pill--ink">24h</span>
|
||||
</div>
|
||||
{hasLoadData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={loadSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
minTickGap={22}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
`${Number(value ?? 0)}`,
|
||||
String(name) === "onlineUsers" ? "在线用户" : "流数量",
|
||||
]}
|
||||
labelFormatter={(label) => `时间 ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="onlineUsers"
|
||||
name="在线用户"
|
||||
stroke="#18222c"
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="streamCount"
|
||||
name="流数量"
|
||||
stroke="#5d7b63"
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无同步快照。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>鉴权失败</h4>
|
||||
<span className="status-pill status-pill--danger">14d</span>
|
||||
</div>
|
||||
{hasFailureData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={failureSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={axisTick} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value) => [`${Number(value ?? 0)} 次`, "失败次数"]}
|
||||
labelFormatter={(label) => `日期 ${label}`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="failedCount"
|
||||
name="失败次数"
|
||||
fill="#af2d21"
|
||||
radius={[6, 6, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无鉴权失败。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>用户流量排行</h4>
|
||||
<span className="status-pill status-pill--sage">7d</span>
|
||||
</div>
|
||||
{hasConsumerData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={consumerSeries}
|
||||
layout="vertical"
|
||||
margin={{ top: 6, right: 14, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={formatCompactBytes}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={86}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name, item) => {
|
||||
const payload = item?.payload as { username?: string } | undefined;
|
||||
|
||||
return [
|
||||
formatBytes(Number(value ?? 0)),
|
||||
String(name) === "totalBytes"
|
||||
? payload?.username ?? "累计流量"
|
||||
: String(name),
|
||||
];
|
||||
}}
|
||||
labelFormatter={() => "用户"}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="totalBytes"
|
||||
name="totalBytes"
|
||||
fill="#7a3116"
|
||||
radius={[0, 6, 6, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无用户流量。</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||
|
||||
import { DashboardCharts } from "./dashboard-charts";
|
||||
|
||||
function getNodePill(node: {
|
||||
enabled: number;
|
||||
last_error_message: string | null;
|
||||
@@ -19,17 +22,13 @@ export default async function DashboardPage() {
|
||||
const data = await getDashboardMetrics();
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<main className="page-stack dashboard-page">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">OVERVIEW</span>
|
||||
<span className="eyebrow">概览</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--ok">在线 {data.totals.total_online_connections}</span>
|
||||
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,15 +58,25 @@ export default async function DashboardPage() {
|
||||
<article className="metric-card metric-card--sage">
|
||||
<span>启用节点</span>
|
||||
<strong>{data.totals.enabled_nodes}</strong>
|
||||
<small>轮询与 kick 生效范围</small>
|
||||
<small>当前可用</small>
|
||||
</article>
|
||||
<article className="metric-card metric-card--paper">
|
||||
<span>累计流量</span>
|
||||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||
<small>已聚合到 SQLite</small>
|
||||
<small>全量累计</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<DashboardCharts
|
||||
nodeHealth={data.nodeHealth}
|
||||
trafficTrend={data.trafficTrend}
|
||||
trafficBuckets={data.trafficBuckets}
|
||||
trafficUserOptions={data.trafficUserOptions}
|
||||
loadTrend={data.loadTrend}
|
||||
failureTrend={data.failureTrend}
|
||||
topConsumerRows={data.topConsumerRows}
|
||||
/>
|
||||
|
||||
<section className="panel-grid">
|
||||
<article className="panel-card">
|
||||
<div className="section-title">
|
||||
@@ -90,7 +99,7 @@ export default async function DashboardPage() {
|
||||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||||
</p>
|
||||
<p className="muted">
|
||||
最近成功 {node.last_sync_ok_at ?? "无"}
|
||||
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||
</p>
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
@@ -138,7 +147,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
<div className="align-right">
|
||||
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -160,7 +169,7 @@ export default async function DashboardPage() {
|
||||
<p>
|
||||
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
||||
</p>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,7 +190,7 @@ export default async function DashboardPage() {
|
||||
<strong>{item.node_name}</strong>
|
||||
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
||||
</div>
|
||||
<p className="muted">{item.created_at}</p>
|
||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { requireAdminAccess } from "@/lib/session";
|
||||
import { AdminNav } from "./admin-nav";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" },
|
||||
{ href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" },
|
||||
{ href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" },
|
||||
{ href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" },
|
||||
{ href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" },
|
||||
{ href: "/admin/dashboard", label: "仪表盘" },
|
||||
{ href: "/admin/users", label: "用户管理" },
|
||||
{ href: "/admin/nodes", label: "节点管理" },
|
||||
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||
{ href: "/admin/audits", label: "审计日志" },
|
||||
];
|
||||
|
||||
export default async function AdminLayout({
|
||||
@@ -23,9 +23,9 @@ export default async function AdminLayout({
|
||||
<div className="brand-block">
|
||||
<span className="eyebrow">HY2 PANEL</span>
|
||||
<h1>鹅梯控制台</h1>
|
||||
<p className="muted">
|
||||
管理员 {session.username} 已登录
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ink">{session.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminNav items={navItems} />
|
||||
<form action="/logout" method="post">
|
||||
|
||||
+237
-288
@@ -1,4 +1,5 @@
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||
|
||||
@@ -23,17 +24,14 @@ export default async function NodesPage() {
|
||||
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<main className="page-stack management-page">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">NODES</span>
|
||||
<span className="eyebrow">节点</span>
|
||||
<h2>节点管理</h2>
|
||||
<p className="page-lede">
|
||||
统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--neutral">URL 自动推导</span>
|
||||
<span className="status-pill status-pill--warm">Secret 自动生成</span>
|
||||
<span className="status-pill status-pill--ok">启用 {nodes.filter((node) => node.enabled).length}</span>
|
||||
<span className="status-pill status-pill--neutral">总数 {nodes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
@@ -48,300 +46,251 @@ export default async function NodesPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>新增节点</h3>
|
||||
</div>
|
||||
<form action={createNodeAction} className="form-grid">
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" placeholder="tokyo-01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" placeholder="Tokyo 01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" placeholder="jp1.example.com" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<input name="hy2_listen" defaultValue=":443" placeholder=":443 或 0.0.0.0:443" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<input
|
||||
name="server_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
defaultValue={443}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" placeholder="留空使用默认" />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="skip_cert_verify" type="checkbox" />
|
||||
<span>客户端跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input
|
||||
name="traffic_stats_url"
|
||||
placeholder="留空自动按客户端地址 + 监听端口生成"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<input
|
||||
name="poll_interval_seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
max="30"
|
||||
defaultValue={15}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建节点
|
||||
</button>
|
||||
</form>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details>
|
||||
<summary className="compact-create-summary">
|
||||
<div>
|
||||
<h3>新增节点</h3>
|
||||
</div>
|
||||
<span className="status-pill status-pill--warm">展开</span>
|
||||
</summary>
|
||||
<form action={createNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" placeholder="tokyo-01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" placeholder="Tokyo 01" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" placeholder="jp1.example.com" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<input name="hy2_listen" defaultValue=":443" placeholder=":443 或 0.0.0.0:443" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<input name="server_port" type="number" min="1" max="65535" defaultValue={443} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={30} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" placeholder="留空使用默认" />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="skip_cert_verify" type="checkbox" />
|
||||
<span>跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input name="traffic_stats_url" placeholder="留空自动推导" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={15} required />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建节点
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="stack-lg">
|
||||
{nodes.map((node) => {
|
||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||
node.server_host,
|
||||
node.traffic_stats_listen,
|
||||
);
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>节点列表</h3>
|
||||
<span className="status-pill status-pill--neutral">{nodes.length} 个节点</span>
|
||||
</div>
|
||||
<div className="entity-table entity-table--nodes">
|
||||
<div className="entity-table__head node-row-grid">
|
||||
<span>节点</span>
|
||||
<span>状态</span>
|
||||
<span>对外地址</span>
|
||||
<span>trafficStats</span>
|
||||
<span>在线/流</span>
|
||||
<span>最近同步</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{nodes.map((node) => {
|
||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||
node.server_host,
|
||||
node.traffic_stats_listen,
|
||||
);
|
||||
|
||||
return (
|
||||
<article key={node.id} className="panel-card entity-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>{node.name}</h3>
|
||||
<p className="muted mono">/{node.slug}</p>
|
||||
</div>
|
||||
<div className="align-right stack-tight">
|
||||
<div className="badge-row badge-row--end">
|
||||
return (
|
||||
<details key={node.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary node-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{node.name}</strong>
|
||||
<span className="muted mono">/{node.slug}</span>
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getNodePills(node).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-code-cell">
|
||||
{node.server_host}:{node.server_port}
|
||||
<small>hy2 {node.hy2_listen}</small>
|
||||
</span>
|
||||
<span className="entity-code-cell break-all">
|
||||
{node.traffic_stats_url}
|
||||
<small>{node.traffic_stats_listen}</small>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{node.last_online_users} / {node.last_stream_count}</strong>
|
||||
<span>用户 / 流</span>
|
||||
</span>
|
||||
<span className="entity-muted-cell">
|
||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||
{node.last_error_at ? <small>错误 {formatLocalDateTime(node.last_error_at)}</small> : null}
|
||||
</span>
|
||||
<span className="entity-expand-label">编辑</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-grid">
|
||||
<div className="dense-code-line">
|
||||
<span>鉴权 URL</span>
|
||||
<code>{authUrl}</code>
|
||||
</div>
|
||||
<div className="dense-code-line">
|
||||
<span>自动推导 trafficStats</span>
|
||||
<code>{suggestedTrafficStatsUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "}
|
||||
{node.last_error_at ?? "无"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="metric-inline">
|
||||
<span>hy2 监听</span>
|
||||
<strong className="mono">{node.hy2_listen}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>对外地址</span>
|
||||
<strong className="mono">{node.server_host}:{node.server_port}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>trafficStats</span>
|
||||
<strong className="mono break-all">{node.traffic_stats_url}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={updateNodeAction} className="form-grid compact">
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" defaultValue={node.slug} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" defaultValue={node.name} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" defaultValue={node.server_host} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<input
|
||||
name="hy2_listen"
|
||||
defaultValue={node.hy2_listen}
|
||||
placeholder=":443 或 0.0.0.0:443"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<input
|
||||
name="server_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
defaultValue={node.server_port}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<input name="client_name" defaultValue={node.client_name} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" defaultValue={node.auth_token} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input
|
||||
name="bandwidth_up_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_up_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<input
|
||||
name="bandwidth_down_mbps"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={node.bandwidth_down_mbps}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" defaultValue={node.sni ?? ""} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="skip_cert_verify"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||
/>
|
||||
<span>客户端跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input
|
||||
name="traffic_stats_listen"
|
||||
defaultValue={node.traffic_stats_listen}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input
|
||||
name="traffic_stats_url"
|
||||
defaultValue={node.traffic_stats_url}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input
|
||||
name="traffic_stats_secret"
|
||||
defaultValue={node.traffic_stats_secret}
|
||||
placeholder="留空保持当前值"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<input
|
||||
name="poll_interval_seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
max="30"
|
||||
defaultValue={node.poll_interval_seconds}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(node.enabled)}
|
||||
/>
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存节点
|
||||
</button>
|
||||
</form>
|
||||
<form action={updateNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<label className="field">
|
||||
<span>Slug</span>
|
||||
<input name="slug" defaultValue={node.slug} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>显示名</span>
|
||||
<input name="name" defaultValue={node.name} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>客户端地址</span>
|
||||
<input name="server_host" defaultValue={node.server_host} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>hy2 监听地址</span>
|
||||
<input name="hy2_listen" defaultValue={node.hy2_listen} placeholder=":443 或 0.0.0.0:443" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>对外端口</span>
|
||||
<input name="server_port" type="number" min="1" max="65535" defaultValue={node.server_port} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Clash 节点名</span>
|
||||
<input name="client_name" defaultValue={node.client_name} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>鉴权 Token</span>
|
||||
<input name="auth_token" defaultValue={node.auth_token} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>上行带宽 Mbps</span>
|
||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={node.bandwidth_up_mbps} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>下行带宽 Mbps</span>
|
||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={node.bandwidth_down_mbps} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SNI</span>
|
||||
<input name="sni" defaultValue={node.sni ?? ""} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="skip_cert_verify" type="checkbox" defaultChecked={Boolean(node.skip_cert_verify)} />
|
||||
<span>跳过证书校验</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats 监听</span>
|
||||
<input name="traffic_stats_listen" defaultValue={node.traffic_stats_listen} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats URL</span>
|
||||
<input name="traffic_stats_url" defaultValue={node.traffic_stats_url} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>trafficStats Secret</span>
|
||||
<input name="traffic_stats_secret" defaultValue={node.traffic_stats_secret} placeholder="留空保持当前值" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>轮询间隔(秒)</span>
|
||||
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={node.poll_interval_seconds} required />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked={Boolean(node.enabled)} />
|
||||
<span>启用节点</span>
|
||||
</label>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存节点
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="instruction-block">
|
||||
<strong>节点接入说明</strong>
|
||||
<p className="muted">
|
||||
hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。
|
||||
</p>
|
||||
<pre>
|
||||
<details className="config-collapse">
|
||||
<summary>查看 hy2 config.yaml</summary>
|
||||
<pre>
|
||||
{buildNodeHy2Config(node, origin)}
|
||||
</pre>
|
||||
<p className="muted">
|
||||
面板轮询地址:<span className="mono">{node.traffic_stats_url}</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
自动推导地址:<span className="mono">{suggestedTrafficStatsUrl}</span>
|
||||
</p>
|
||||
<p className="muted">
|
||||
鉴权 URL:<span className="mono">{authUrl}</span>
|
||||
</p>
|
||||
<a
|
||||
className="ghost-button inline-button"
|
||||
href={`/api/admin/nodes/${node.slug}/config`}
|
||||
>
|
||||
下载 hy2 config.yaml
|
||||
</a>
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</pre>
|
||||
<a className="ghost-button inline-button" href={`/api/admin/nodes/${node.slug}/config`}>
|
||||
下载 hy2 config.yaml
|
||||
</a>
|
||||
</details>
|
||||
|
||||
<form action={deleteNodeAction}>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除节点
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{node.last_error_message ? (
|
||||
<p className="error-text">{node.last_error_message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="entity-action-strip">
|
||||
<form action={deleteNodeAction}>
|
||||
<input name="id" type="hidden" value={node.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除节点
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,25 @@ import { createStableId } from "@/lib/password";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
getSubscriptionSettings,
|
||||
saveMainProxyGroupMemberSlugs,
|
||||
saveProxyGroups,
|
||||
saveSubscriptionSettings,
|
||||
} from "@/lib/store";
|
||||
|
||||
function readUniqueMemberSlugs(formData: FormData) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return formData.getAll("member_slugs").flatMap((value) => {
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(slug);
|
||||
return [slug];
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
@@ -28,16 +43,7 @@ export async function saveProxyGroupAction(formData: FormData) {
|
||||
const settings = await getSubscriptionSettings();
|
||||
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const seen = new Set<string>();
|
||||
const memberSlugs = formData.getAll("member_slugs").flatMap((value) => {
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(slug);
|
||||
return [slug];
|
||||
});
|
||||
const memberSlugs = readUniqueMemberSlugs(formData);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
@@ -62,6 +68,13 @@ export async function saveProxyGroupAction(formData: FormData) {
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function saveMainProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData));
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function deleteProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
|
||||
@@ -29,13 +29,10 @@ export default async function AdminSubscriptionsPage() {
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">SUBSCRIPTIONS</span>
|
||||
<span className="eyebrow">订阅</span>
|
||||
<h2>订阅与模板</h2>
|
||||
<p className="page-lede">
|
||||
管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">动态 proxies</span>
|
||||
<span className="status-pill status-pill--ok">节点 {nodes.length}</span>
|
||||
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,11 +66,20 @@ export default async function AdminSubscriptionsPage() {
|
||||
<section className="panel-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h3>Proxy Groups</h3>
|
||||
<p className="muted">已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。</p>
|
||||
<h3>代理分组</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack-lg">
|
||||
<ProxyGroupEditor
|
||||
mode="main"
|
||||
group={{
|
||||
id: "main",
|
||||
name: settings.profileName,
|
||||
memberSlugs: settings.mainProxyGroupMemberSlugs,
|
||||
}}
|
||||
nodes={proxyGroupNodes}
|
||||
/>
|
||||
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||
))}
|
||||
@@ -84,7 +90,7 @@ export default async function AdminSubscriptionsPage() {
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Clash 模板</h3>
|
||||
<h3>订阅模板</h3>
|
||||
</div>
|
||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||
<label className="field">
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
|
||||
import {
|
||||
deleteProxyGroupAction,
|
||||
saveMainProxyGroupAction,
|
||||
saveProxyGroupAction,
|
||||
} from "./actions";
|
||||
|
||||
type ProxyGroupNodeOption = {
|
||||
slug: string;
|
||||
@@ -12,6 +16,7 @@ type ProxyGroupNodeOption = {
|
||||
};
|
||||
|
||||
type ProxyGroupEditorProps = {
|
||||
mode?: "custom" | "main";
|
||||
group?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,11 +25,14 @@ type ProxyGroupEditorProps = {
|
||||
nodes: ProxyGroupNodeOption[];
|
||||
};
|
||||
|
||||
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
|
||||
function normalizeSelectedSlugs(
|
||||
initialSlugs: string[],
|
||||
nodes: ProxyGroupNodeOption[],
|
||||
includeMissingNodes = false,
|
||||
) {
|
||||
const availableSlugs = new Set(nodes.map((node) => node.slug));
|
||||
const seen = new Set<string>();
|
||||
|
||||
return initialSlugs.filter((slug) => {
|
||||
const selectedSlugs = initialSlugs.filter((slug) => {
|
||||
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
@@ -32,14 +40,33 @@ function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOpt
|
||||
seen.add(slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!includeMissingNodes) {
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!seen.has(node.slug)) {
|
||||
selectedSlugs.push(node.slug);
|
||||
seen.add(node.slug);
|
||||
}
|
||||
}
|
||||
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
function SaveButton({ isEditing }: { isEditing: boolean }) {
|
||||
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
||||
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
||||
|
||||
return (
|
||||
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
|
||||
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
|
||||
<button
|
||||
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? pendingLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -54,12 +81,13 @@ function DeleteButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
const isEditing = Boolean(group);
|
||||
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
||||
const isMainGroup = mode === "main";
|
||||
const isEditing = Boolean(group) && !isMainGroup;
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
);
|
||||
const selectedSlugSet = new Set(selectedSlugs);
|
||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||
@@ -135,96 +163,103 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
|
||||
return (
|
||||
<article className="instruction-block proxy-group-card">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
{group ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
|
||||
{group && !isMainGroup ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
|
||||
<p className="muted proxy-group-caption">右侧顺序就是最终写入 Clash 的节点顺序。</p>
|
||||
<h4 className="proxy-group-title">
|
||||
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="badge-row badge-row--end">
|
||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
{!isMainGroup ? (
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
{!isMainGroup ? (
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="proxy-group-builder">
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
<span className="muted">筛选后点击添加,新节点会追加到分组末尾。</span>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
|
||||
{!isMainGroup ? (
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<button
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>已选顺序</strong>
|
||||
<span className="muted">上移和下移会直接改变最终输出顺序。</span>
|
||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedNodes.length > 0 ? (
|
||||
@@ -255,13 +290,15 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
@@ -269,19 +306,18 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
|
||||
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
|
||||
<SaveButton isEditing={isEditing} />
|
||||
<div className="action-row action-row--end">
|
||||
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{group ? (
|
||||
{group && !isMainGroup ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
|
||||
+171
-156
@@ -1,4 +1,5 @@
|
||||
import { getRequestOrigin } from "@/lib/request-origin";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import {
|
||||
buildSubscriptionUrl,
|
||||
describeRemainingTraffic,
|
||||
@@ -30,7 +31,7 @@ function getUserPills(user: {
|
||||
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||
: null,
|
||||
user.expires_at
|
||||
? { label: user.expires_at, className: "status-pill status-pill--neutral" }
|
||||
? { label: formatLocalDateTime(user.expires_at), className: "status-pill status-pill--neutral" }
|
||||
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
||||
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
||||
}
|
||||
@@ -39,17 +40,14 @@ export default async function UsersPage() {
|
||||
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
<main className="page-stack management-page">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">USERS</span>
|
||||
<span className="eyebrow">用户</span>
|
||||
<h2>用户管理</h2>
|
||||
<p className="page-lede">
|
||||
管理用户名:密码、`auth_id`、到期时间、额度、订阅名和用户自助链接。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">自助登录</span>
|
||||
<span className="status-pill status-pill--neutral">订阅链接按用户独立生成</span>
|
||||
<span className="status-pill status-pill--ok">启用 {users.filter((user) => user.enabled).length}</span>
|
||||
<span className="status-pill status-pill--neutral">总数 {users.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-aside">
|
||||
@@ -64,157 +62,174 @@ export default async function UsersPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>新建用户</h3>
|
||||
</div>
|
||||
<form action={createUserAction} className="form-grid">
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input name="expires_at" type="datetime-local" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
||||
</label>
|
||||
<label className="field full-span">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={3} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>立即启用</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建用户
|
||||
</button>
|
||||
</form>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details>
|
||||
<summary className="compact-create-summary">
|
||||
<div>
|
||||
<h3>新建用户</h3>
|
||||
</div>
|
||||
<span className="status-pill status-pill--warm">展开</span>
|
||||
</summary>
|
||||
<form action={createUserAction} className="dense-form-grid dense-form-grid--users">
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input name="expires_at" type="datetime-local" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
||||
</label>
|
||||
<label className="field dense-field-wide">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={2} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input name="enabled" type="checkbox" defaultChecked />
|
||||
<span>立即启用</span>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
创建用户
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section className="stack-lg">
|
||||
{users.map((user) => (
|
||||
<article key={user.id} className="panel-card entity-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>{user.username}</h3>
|
||||
<p className="muted mono">auth_id: {user.auth_id}</p>
|
||||
</div>
|
||||
<div className="align-right stack-tight">
|
||||
<div className="badge-row badge-row--end">
|
||||
{getUserPills(user).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>用户列表</h3>
|
||||
<span className="status-pill status-pill--neutral">{users.length} 个账户</span>
|
||||
</div>
|
||||
<div className="entity-table entity-table--users">
|
||||
<div className="entity-table__head user-row-grid">
|
||||
<span>用户</span>
|
||||
<span>状态</span>
|
||||
<span>用量</span>
|
||||
<span>剩余额度</span>
|
||||
<span>在线</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{users.map((user) => {
|
||||
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
||||
const subscriptionUrl = user.subscription_token
|
||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||
: "暂不可用";
|
||||
|
||||
return (
|
||||
<details key={user.id} className="entity-table__item">
|
||||
<summary className="entity-table__summary user-row-grid">
|
||||
<span className="entity-main-cell">
|
||||
<strong>{user.username}</strong>
|
||||
<span className="muted mono break-all">{user.auth_id}</span>
|
||||
</span>
|
||||
<span className="badge-row entity-status-cell">
|
||||
{getUserPills(user).map((pill) => (
|
||||
<span key={pill.label} className={pill.className}>
|
||||
{pill.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{formatBytes(usedBytes)}</strong>
|
||||
<span>TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
<span>{user.traffic_limit_bytes ? "有额度" : "不限量"}</span>
|
||||
</span>
|
||||
<span className="entity-number-cell">
|
||||
<strong>{user.online_connections}</strong>
|
||||
<span>连接</span>
|
||||
</span>
|
||||
<span className="entity-expand-label">编辑</span>
|
||||
</summary>
|
||||
<div className="entity-table__details">
|
||||
<div className="dense-code-line">
|
||||
<span>订阅链接</span>
|
||||
<code>{subscriptionUrl}</code>
|
||||
</div>
|
||||
<form action={updateUserAction} className="dense-form-grid dense-form-grid--users">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" defaultValue={user.username} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input
|
||||
name="expires_at"
|
||||
type="datetime-local"
|
||||
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input
|
||||
name="traffic_limit_gib"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue={
|
||||
user.traffic_limit_bytes && !user.is_admin
|
||||
? (user.traffic_limit_bytes / 1024 / 1024 / 1024).toFixed(2)
|
||||
: ""
|
||||
}
|
||||
disabled={Boolean(user.is_admin)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>订阅名称</span>
|
||||
<input
|
||||
name="subscription_label"
|
||||
defaultValue={user.subscription_label || user.username}
|
||||
/>
|
||||
</label>
|
||||
<label className="field dense-field-wide">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={2} defaultValue={user.notes} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(user.enabled)}
|
||||
/>
|
||||
<span>启用用户</span>
|
||||
</label>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存修改
|
||||
</button>
|
||||
</form>
|
||||
<div className="entity-action-strip">
|
||||
<form action={resetUserPasswordAction} className="inline-form inline-form--compact">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<input name="password" type="password" placeholder="新密码" required />
|
||||
<button className="ghost-button" type="submit">
|
||||
重置密码
|
||||
</button>
|
||||
</form>
|
||||
{user.is_admin ? null : (
|
||||
<form action={deleteUserAction}>
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除用户
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
累计 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)} / 在线{" "}
|
||||
{user.online_connections}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<div className="metric-inline">
|
||||
<span>总用量</span>
|
||||
<strong>{formatBytes(user.used_tx_bytes + user.used_rx_bytes)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>剩余额度</span>
|
||||
<strong>{describeRemainingTraffic(user)}</strong>
|
||||
</div>
|
||||
<div className="metric-inline">
|
||||
<span>在线连接</span>
|
||||
<strong>{user.online_connections}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateUserAction} className="form-grid compact">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<label className="field">
|
||||
<span>用户名</span>
|
||||
<input name="username" defaultValue={user.username} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>到期时间</span>
|
||||
<input
|
||||
name="expires_at"
|
||||
type="datetime-local"
|
||||
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>总流量上限 GiB</span>
|
||||
<input
|
||||
name="traffic_limit_gib"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue={
|
||||
user.traffic_limit_bytes && !user.is_admin
|
||||
? (user.traffic_limit_bytes / 1024 / 1024 / 1024).toFixed(2)
|
||||
: ""
|
||||
}
|
||||
disabled={Boolean(user.is_admin)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>订阅名称</span>
|
||||
<input
|
||||
name="subscription_label"
|
||||
defaultValue={user.subscription_label || user.username}
|
||||
/>
|
||||
</label>
|
||||
<label className="field full-span">
|
||||
<span>备注</span>
|
||||
<textarea name="notes" rows={3} defaultValue={user.notes} />
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(user.enabled)}
|
||||
/>
|
||||
<span>启用用户</span>
|
||||
</label>
|
||||
<button className="ghost-button" type="submit">
|
||||
保存修改
|
||||
</button>
|
||||
</form>
|
||||
<div className="instruction-block">
|
||||
<strong>订阅链接</strong>
|
||||
<p className="muted mono break-all">
|
||||
{user.subscription_token
|
||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||
: "暂不可用"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<form action={resetUserPasswordAction} className="inline-form">
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<input name="password" type="password" placeholder="新密码" required />
|
||||
<button className="ghost-button" type="submit">
|
||||
重置密码
|
||||
</button>
|
||||
</form>
|
||||
{user.is_admin ? null : (
|
||||
<form action={deleteUserAction}>
|
||||
<input name="id" type="hidden" value={user.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除用户
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user