2026-05-06 00:10:45 +08:00

296 lines
13 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 { getRequestOrigin } from "@/lib/request-origin";
import { formatLocalDateTime } from "@/lib/datetime";
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 management-page">
<section className="hero-panel">
<div className="hero-copy">
<span className="eyebrow"></span>
<h2></h2>
<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>
<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 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="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 (
<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>
<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>
<details className="config-collapse">
<summary> hy2 config.yaml</summary>
<pre>
{buildNodeHy2Config(node, origin)}
</pre>
<a className="ghost-button inline-button" 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">
<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>
);
}