348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { getRequestOrigin } from "@/lib/request-origin";
|
||
import { buildNodeHy2Config } from "@/lib/subscription";
|
||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||
|
||
import { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions";
|
||
|
||
function getNodePills(node: {
|
||
enabled: number;
|
||
last_error_message: string | null;
|
||
}) {
|
||
return [
|
||
{
|
||
label: node.enabled ? "已启用" : "已禁用",
|
||
className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`,
|
||
},
|
||
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">
|
||
<section className="hero-panel">
|
||
<div className="hero-copy">
|
||
<span className="eyebrow">NODES</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>
|
||
</div>
|
||
</div>
|
||
<div className="hero-aside">
|
||
<div className="stat-tile">
|
||
<span>节点总数</span>
|
||
<strong>{nodes.length}</strong>
|
||
</div>
|
||
<div className="stat-tile">
|
||
<span>启用中</span>
|
||
<strong>{nodes.filter((node) => node.enabled).length}</strong>
|
||
</div>
|
||
</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>
|
||
|
||
<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,
|
||
);
|
||
|
||
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">
|
||
{getNodePills(node).map((pill) => (
|
||
<span key={pill.label} className={pill.className}>
|
||
{pill.label}
|
||
</span>
|
||
))}
|
||
</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>
|
||
|
||
<div className="instruction-block">
|
||
<strong>节点接入说明</strong>
|
||
<p className="muted">
|
||
hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。
|
||
</p>
|
||
<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>
|
||
|
||
<form action={deleteNodeAction}>
|
||
<input name="id" type="hidden" value={node.id} />
|
||
<button className="danger-button" type="submit">
|
||
删除节点
|
||
</button>
|
||
</form>
|
||
</article>
|
||
);
|
||
})}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|