Files
hy2-panel/app/admin/nodes/page.tsx
T

553 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CustomNodeForm } from "./custom-node-form";
import { ActionForm } from "@/components/action-form";
import { buttonVariants } from "@/components/ui/button";
import { CopyButton } from "@/components/copy-button";
import { FilterList } from "@/components/filter-list";
import { Input } from "@/components/ui/input";
import { SubmitButton } from "@/components/submit-button";
import { getRequestOrigin } from "@/lib/request-origin";
import { formatLocalDateTime } from "@/lib/datetime";
import { buildNodeHy2Config } from "@/lib/subscription";
import {
buildNodeAuthUrl,
buildTrafficStatsUrl,
getNodeProxyName,
listNodes,
} from "@/lib/store";
import {
createNodeAction,
updateAnyTLSNodeAction,
deleteNodeAction,
updateNodeAction,
} from "./actions";
function getNodePills(node: {
enabled: number;
node_type: "hy2" | "custom" | "anytls";
last_error_message: string | null;
last_sync_ok_at: string | null;
}) {
return [
{
label: node.enabled ? "已启用" : "已禁用",
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
},
node.node_type === "custom"
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
: node.node_type === "anytls" &&
(!node.last_sync_ok_at ||
Date.parse(node.last_sync_ok_at) < new Date().getTime() - 90_000)
? { label: "心跳超时", className: "status-pill status-pill--danger" }
: node.last_error_message
? { label: "同步异常", className: "status-pill status-pill--danger" }
: { label: "同步正常", className: "status-pill status-pill--sage" },
];
}
export default async function NodesPage() {
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
return (
<main className="page-stack management-page">
<section className="hero-panel">
<div className="hero-copy">
<span className="eyebrow">节点</span>
<h2>节点管理</h2>
<a
href="/admin/certificates"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
证书托管 / 自动安装节点
</a>
<div className="badge-row">
<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>
</section>
<section className="panel-card compact-create-panel">
<details>
<summary className="compact-create-summary">
<div>
<h3>新增 HY2 节点</h3>
</div>
<span className="status-pill status-pill--warm">展开</span>
</summary>
<ActionForm
action={createNodeAction}
className="dense-form-grid dense-form-grid--nodes"
>
<input type="hidden" name="node_type" value="hy2" />
<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>
<SubmitButton variant="default">创建节点</SubmitButton>
</ActionForm>
</details>
</section>
<section className="panel-card compact-create-panel">
<details>
<summary className="compact-create-summary">
<h3>新增自定义订阅节点</h3>
</summary>
<CustomNodeForm />
</details>
</section>
<section className="panel-card dense-panel">
<div className="section-title">
<h3>节点列表</h3>
<span className="status-pill status-pill--neutral">
{nodes.length} 个节点
</span>
</div>
<FilterList
className="entity-table entity-table--nodes"
placeholder="搜索节点、地址、Slug…"
statuses={[
{ value: "enabled", label: "已启用" },
{ value: "disabled", label: "已禁用" },
]}
header={
<div className="entity-table__head node-row-grid">
<span>节点</span>
<span>状态</span>
<span>对外地址</span>
<span>trafficStats</span>
<span>在线/流</span>
<span>最近同步</span>
<span>操作</span>
</div>
}
items={nodes.map((node) => {
const isCustom = node.node_type === "custom";
const isAnyTLS = node.node_type === "anytls";
const proxyName = getNodeProxyName(node);
const authUrl = buildNodeAuthUrl(
node.slug,
node.auth_token,
origin,
);
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
node.server_host,
node.traffic_stats_listen,
);
return {
id: node.id,
text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : isAnyTLS ? "AnyTLS sing-box TCP" : "HY2"}`,
status: node.enabled ? "enabled" : "disabled",
content: (
<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">
{isCustom
? proxyName
: `${node.server_host}:${node.server_port}`}
<small>
{isCustom
? "自定义订阅节点"
: `${isAnyTLS ? "AnyTLS TCP" : "HY2 UDP"} ${node.hy2_listen}`}
</small>
</span>
<span className="entity-code-cell break-all">
{isCustom
? "不轮询"
: isAnyTLS
? "节点主动上报"
: node.traffic_stats_url}
<small>
{isCustom
? "仅订阅"
: isAnyTLS
? "管理接口仅本机"
: node.traffic_stats_listen}
</small>
</span>
<span className="entity-number-cell">
<strong>
{isCustom
? "—"
: `${isAnyTLS ? "—" : node.last_online_users} / ${node.last_stream_count}`}
</strong>
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
</span>
<span className="entity-muted-cell">
{isCustom
? "—"
: formatLocalDateTime(node.last_sync_ok_at)}
{!isCustom && node.last_error_at ? (
<small>
错误 {formatLocalDateTime(node.last_error_at)}
</small>
) : null}
</span>
<span className="entity-expand-label">
<span className="when-closed">编辑</span>
<span className="when-open">收起</span>
</span>
</summary>
<div className="entity-table__details">
{isCustom ? (
<CustomNodeForm node={node} />
) : isAnyTLS ? (
<>
<p className="muted">
AnyTLS / sing-box · 每用户独立凭据,约 15
秒同步。用户增删、密码、到期或额度变更会更新配置并重启
sing-box,现有连接会断开。连续失联约 90
秒后停止代理。在线用户数暂不提供,流数量来自本机接口。
</p>
<p className="muted">
{!node.last_sync_ok_at ||
Date.parse(node.last_sync_ok_at) <
new Date().getTime() - 90_000
? "节点未连接或心跳已超时"
: "节点心跳正常"}{" "}
· SNI:{node.sni}
</p>
<ActionForm
action={updateAnyTLSNodeAction}
className="dense-form-grid dense-form-grid--nodes"
>
<input type="hidden" name="id" value={node.id} />
<label className="field">
<span>节点名称 / 订阅名</span>
<Input
name="name"
defaultValue={node.name}
required
maxLength={100}
/>
</label>
<label className="checkbox">
<input
name="enabled"
type="checkbox"
defaultChecked={Boolean(node.enabled)}
/>
<span>启用节点</span>
</label>
<SubmitButton>保存节点</SubmitButton>
</ActionForm>
</>
) : (
<>
<div className="dense-code-grid">
<div className="dense-code-line">
<span>鉴权 URL</span>
<div className="copy-line">
<code>{authUrl}</code>
<CopyButton value={authUrl} />
</div>
</div>
<div className="dense-code-line">
<span>自动推导 trafficStats</span>
<code>{suggestedTrafficStatsUrl}</code>
</div>
</div>
<ActionForm
action={updateNodeAction}
className="dense-form-grid dense-form-grid--nodes"
>
<input name="id" type="hidden" value={node.id} />
<input name="node_type" type="hidden" value="hy2" />
<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>
<SubmitButton variant="outline">
保存节点
</SubmitButton>
</ActionForm>
<details className="config-collapse">
<summary>查看 hy2 config.yaml</summary>
<pre>{buildNodeHy2Config(node, origin)}</pre>
<a
className={buttonVariants({
variant: "outline",
size: "sm",
})}
href={`/api/admin/nodes/${node.slug}/config`}
>
下载 hy2 config.yaml
</a>
</details>
{node.last_error_message ? (
<p className="error-text">
{node.last_error_message}
</p>
) : null}
</>
)}
<div className="entity-action-strip">
<ActionForm action={deleteNodeAction}>
<input name="id" type="hidden" value={node.id} />
<SubmitButton
variant="destructive"
confirm={`将永久删除节点「${node.name}」及其面板配置。`}
>
删除节点
</SubmitButton>
</ActionForm>
</div>
</div>
</details>
),
};
})}
/>
</section>
</main>
);
}