553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|