This commit is contained in:
2026-05-06 00:10:45 +08:00
parent 2e3eb4b8f8
commit 916a4f5ffc
19 changed files with 2431 additions and 723 deletions
+237 -288
View File
@@ -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>
);
}
}