3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据

- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
This commit is contained in:
Muse
2026-10-19 22:23:40 +08:00
parent 1678f766ac
commit 084afeb8f4
35 changed files with 2142 additions and 1749 deletions
+199 -111
View File
@@ -2,7 +2,7 @@
const state = {
session: null, page: 'overview', overview: null, servers: [], customers: [],
chains: [], deployments: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8'
nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8'
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -18,20 +18,43 @@ const fmtBytes = (value) => {
};
const oldUsageAgent = (version) => { const parts = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/); return parts &&
Number(parts[1]) === 0 && (Number(parts[2]) < 6 || (Number(parts[2]) === 6 && Number(parts[3]) < 3)); };
async function copyText(text) {
text = String(text ?? '');
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
// HTTP 下 clipboard API 不可用,降级用 execCommand。
// 注意:弹窗是 <dialog> 模态的,此时 document.body 处于 inert 状态,
// textarea 必须插进弹窗内部,否则 select/execCommand 会复制走弹窗内的当前选区。
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;';
const dialog = $('#modal');
const host = (dialog && dialog.open) ? dialog : document.body;
host.appendChild(ta);
ta.focus();
ta.select();
try { ta.setSelectionRange(0, ta.value.length); } catch (e) {}
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
ta.remove();
if (!ok) throw new Error('复制失败,请手动长按复制');
}
const splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
const statusText = {
online:'在线', offline:'离线', pending:'待注册', active:'运行中', draft:'草稿', deploying:'部署中', queued:'排队中',
running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', suspending:'暂停中', partially_suspended:'部分暂停', removing:'停用中', deleted:'已删除',
redeploying:'重建中', redeploy_pending:'等待重建', changes_pending:'待重新部署'
running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', removing:'停用中', deleted:'已删除'
};
const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' };
const topologyText = { forward:'转发线路', direct:'单机直连' };
const deploymentRoleText = { relay:'客户端入口', exit:'出口传输', direct:'单机节点' };
const auditText = {
create_server:'添加设备', update_server:'编辑设备', delete_server:'删除设备', create_enrollment:'生成注册令牌', enroll_agent:'Agent 注册',
reconcile_server:'设备配置对账', set_tls_domain:'节点证书同步', create_customer:'添加客户', update_customer:'编辑客户', reset_customer_usage:'重置客户流量', delete_customer:'删除客户',
create_chain:'创建线路', update_route:'编辑线路', deploy_route:'部署线路', redeploy_route:'重新部署线路', repair_route:'修复线路', remove_route:'停用线路',
delete_chain:'删除线路', retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接'
create_node:'创建节点', update_node:'编辑节点', deploy_node:'部署节点', repair_node:'修复节点', remove_node:'停用节点',
delete_node:'删除节点', assign_customer:'分配客户', unassign_customer:'移除分配', suspend_customer_resources:'暂停客户资源', resume_customer_resources:'恢复客户资源',
retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接'
};
function setTheme(theme) {
@@ -73,17 +96,17 @@ function showApp() {
async function load(page = state.page) {
try {
if (page === 'overview') {
const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/chains')]);
state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'servers') state.servers = (await api('/api/servers')).servers;
else if (page === 'customers') state.customers = (await api('/api/customers')).customers;
else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
}
else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
} else if (page === 'deployments') {
const [servers, customers, routes] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains')]);
state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
} else if (page === 'commands') { /* Command reference needs no API request. */ }
else if (page === 'operations') {
const [jobs, servers, overview] = await Promise.all([api('/api/jobs'), api('/api/servers'), api('/api/overview')]);
@@ -96,7 +119,7 @@ async function load(page = state.page) {
function setPage(page) {
state.page = page; state.search = ''; closeSidebar();
$$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page));
const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', deployments:'部署与链接', commands:'安装与命令', operations:'设置与运维' };
const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', commands:'安装与命令', operations:'设置与运维' };
$('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`;
load(page);
}
@@ -112,15 +135,15 @@ function renderOverview() {
const metrics = [
['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'],
['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'],
['编排线路', counts.chains || 0, '转发与单机节点', '#9667e8'],
['活动部署', counts.activeDeployments || 0, '实际运行资源', '#e8a63c'],
['共享节点', counts.nodes || 0, '多客户共用入口', '#9667e8'],
['运行中节点', counts.activeNodes || 0, '实际运行资源', '#e8a63c'],
['待办任务', counts.queuedJobs || 0, 'Agent 自动领取', '#e5667c']
];
const serverRows = state.servers.slice(0, 7).map((server) => `<div class="health-row"><div><b>${esc(server.name)}</b><span>${esc(roleText[server.role] || server.role)} · ${esc(server.region || server.publicAddress)}</span></div>${status(server.status)}</div>`).join('');
const events = ((state.overview && state.overview.activity) || []).map((event) => `<div class="event"><i class="event-dot"></i><div><b>${esc(auditText[event.action] || event.action)}</b><small>${esc(event.actor)} · ${esc(event.target)}</small></div><time>${fmtDate(event.at)}</time></div>`).join('');
const failures = ((state.overview && state.overview.degraded) || []).length;
return `<section class="metrics">${metrics.map(([label,value,note,color]) => `<article class="metric" style="--accent:${color}"><span>${label}</span><strong>${value}</strong><small>${note}</small></article>`).join('')}</section>
${failures ? `<div class="notice warning" style="margin-bottom:16px">检测到 ${failures} 个失败部署。请到“转发与节点”查看错误后使用“修复失败项”。更新 Agent 后无需重建节点。</div>` : ''}
${failures ? `<div class="notice warning" style="margin-bottom:16px">检测到 ${failures} 个异常节点。请到“转发与节点”查看错误后使用“修复”。更新 Agent 后无需重建节点。</div>` : ''}
<section class="grid-2"><article class="panel"><div class="panel-head"><div><h2>设备健康</h2><p>超过三分钟未上报将标记离线</p></div><button class="ghost" data-page-jump="servers">查看全部</button></div><div class="panel-body health-list">${serverRows || empty('还没有设备','先添加入口、出口或综合节点')}</div></article>
<article class="panel"><div class="panel-head"><div><h2>最近活动</h2><p>重要操作审计记录</p></div></div><div class="panel-body timeline">${events || empty('暂无活动','操作记录会显示在这里')}</div></article></section>`;
}
@@ -155,67 +178,84 @@ function renderCustomers() {
return `<tr><td><strong>${esc(item.name)}</strong><small>${esc(item.group || '未分组')}</small></td><td>${status(item.status)}${item.status === 'suspended' ? `<small>${esc(({ traffic_limit:'额度耗尽', expired:'已到期', ip_limit:'节点 IP 超限', manual:'手动停用' })[item.suspendReason] || '待恢复')}</small>` : ''}</td>
<td><strong>${fmtBytes(item.usedBytes)} / ${item.trafficLimitBytes ? fmtBytes(item.trafficLimitBytes) : '不限'}</strong><small>上行 ${fmtBytes(item.usedUplinkBytes)} · 下行 ${fmtBytes(item.usedDownlinkBytes)}</small><div class="progress"><i style="width:${percent}%"></i></div><small>${item.lastUsageAt ? `最后计量 ${fmtDate(item.lastUsageAt)}` : '尚未收到入口流量统计'}</small></td>
<td>${item.expiresAt ? fmtDate(item.expiresAt) : '不限期'}</td><td>节点 IP ${item.observedIpCount || 0} / ${item.ipLimit || '不限'}<small>订阅客户端约 ${item.subscriptionClientCount || 0} / ${item.deviceLimit || '不限'}</small></td><td>${tags(item.tags)}</td>
<td>${rowActions(item.id, `<button data-action="edit-customer" data-id="${esc(item.id)}">编辑</button><button data-action="customer-subscription" data-id="${esc(item.id)}">订阅</button><button data-action="customer-access" data-id="${esc(item.id)}">访问</button>`, [['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}${item.pendingCleanup ? `<small>待清理 ${item.pendingCleanup} 项</small>` : ''}</td></tr>`;
<td>${rowActions(item.id, `<button data-action="edit-customer" data-id="${esc(item.id)}">编辑</button><button data-action="customer-subscription" data-id="${esc(item.id)}">订阅</button><button data-action="customer-access" data-id="${esc(item.id)}">访问</button>`, [['assign-nodes','分配节点'],['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}</td></tr>`;
}).join('');
return `<div class="page-intro"><p>流量按入口上行+下行双向累计,Agent 约每分钟上报。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复原节点;待 Agent 完成恢复任务,订阅才可更新。旧版已经变为草稿的线路可点“恢复原节点”。</p><button class="primary" data-action="add-customer">+ 添加客户</button></div>
return `<div class="page-intro"><p>流量按入口上行+下行双向累计,Agent 约每分钟上报,按客户跨节点汇总。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复;待 Agent 完成恢复任务,订阅才可更新。</p><button class="primary" data-action="add-customer">+ 添加客户</button></div>
<section class="panel"><div class="panel-head"><div class="toolbar"><input class="search" data-search placeholder="搜索客户、分组或标签" value="${esc(state.search)}"><span class="tag">${state.customers.length} 位</span></div></div>
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>流量</th><th>到期</th><th>使用限制</th><th>标签</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有客户','先创建客户,再编排线路')}</td></tr>`}</tbody></table></div></section>`;
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>流量</th><th>到期</th><th>使用限制</th><th>标签</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有客户','先创建客户,再到“转发与节点”分配')}</td></tr>`}</tbody></table></div></section>`;
}
function chainActions(chain) {
const edit = `<button data-action="edit-chain" data-id="${esc(chain.id)}">编辑</button>`;
const remove = `<button class="danger" data-action="delete-chain" data-id="${esc(chain.id)}">删除</button>`;
if (chain.status === 'draft') return `${edit}${chain.generation > 0 ? `<button class="primary" data-action="restore-chain" data-id="${esc(chain.id)}">恢复原节点</button><button data-action="deploy-chain" data-id="${esc(chain.id)}">全新部署</button>` : `<button class="primary" data-action="deploy-chain" data-id="${esc(chain.id)}">部署</button>`}${remove}`;
if (chain.status === 'suspended') return `${edit}${state.customers.some((item) => chain.customerIds.includes(item.id) && item.status === 'active') ? `<button class="primary" data-action="restore-chain" data-id="${esc(chain.id)}">恢复原节点</button>` : ''}<button data-action="remove-chain" data-id="${esc(chain.id)}">停用线路</button>${remove}`;
if (chain.status === 'changes_pending') return `${edit}<button class="primary" data-action="redeploy-chain" data-id="${esc(chain.id)}">应用修改</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>`;
if (chain.status === 'degraded') return `${edit}<button class="primary" data-action="repair-chain" data-id="${esc(chain.id)}">修复失败项</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>${remove}`;
if (['active','deploying','suspending','partially_suspended'].includes(chain.status)) return `${edit}<button data-action="redeploy-chain" data-id="${esc(chain.id)}">重新部署</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>`;
return `${edit}${remove}`;
function nodeActions(node) {
const edit = `<button data-action="edit-node" data-id="${esc(node.id)}">编辑</button>`;
const clients = `<button data-action="node-clients" data-id="${esc(node.id)}">客户端</button>`;
const remove = `<button class="danger" data-action="delete-node" data-id="${esc(node.id)}">删除</button>`;
if (node.status === 'draft') return `${edit}${clients}<button class="primary" data-action="deploy-node" data-id="${esc(node.id)}">部署</button>${remove}`;
if (['failed', 'degraded'].includes(node.status)) return `${edit}${clients}<button class="primary" data-action="repair-node" data-id="${esc(node.id)}">修复</button><button data-action="remove-node" data-id="${esc(node.id)}">停用</button>${remove}`;
if (['active', 'deploying'].includes(node.status)) return `${edit}${clients}<button data-action="remove-node" data-id="${esc(node.id)}">停用</button>${remove}`;
return `${edit}${clients}${remove}`;
}
function rowActions(id, primary, more) {
return `<div class="actions compact-actions">${primary}<select data-row-actions data-id="${esc(id)}" aria-label="更多操作"><option value="">更多操作</option>${more.map(([action,label]) => `<option value="${esc(action)}">${esc(label)}</option>`).join('')}</select></div>`;
}
function compactChainActions(chain) {
const actions = chainActions(chain).match(/<button\b[^>]*>[^<]*<\/button>/g) || [];
function compactNodeActions(node) {
const actions = nodeActions(node).match(/<button\b[^>]*>[^<]*<\/button>/g) || [];
const primary = actions.slice(0, 2).join('');
const more = actions.slice(2).map((html) => [(html.match(/data-action="([^"]+)"/) || [,''])[1], html.replace(/<[^>]+>/g, '')]);
return more.length ? rowActions(chain.id, primary, more) : `<div class="actions compact-actions">${primary}</div>`;
return more.length ? rowActions(node.id, primary, more) : `<div class="actions compact-actions">${primary}</div>`;
}
function renderChains() {
const rows = state.chains.map((chain) => {
const direct = (chain.topology || 'forward') === 'direct';
const path = direct ? protocolName(chain.relayProtocol, 'relay-ingress') : `${protocolName(chain.relayProtocol, 'relay-ingress')} → ${protocolName(chain.exitProtocol, 'exit-transport')}`;
const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === chain.exitServerId) || {}).name || '已删除');
const applied = state.deployments.filter((item) => item.chainId === chain.id && ['active','queued','deploying'].includes(item.status) && item.role !== 'exit');
const drift = applied.some((item) => item.protocol !== chain.relayProtocol);
return `<tr><td><strong>${esc(chain.name)}</strong><small>${esc(topologyText[chain.topology || 'forward'])} · ${esc(path)}</small>${drift ? '<small class="error-detail">编辑的协议尚未应用,当前节点见“部署与链接”</small>' : ''}</td>
<td>${esc(names(chain.relayServerIds, state.servers))}<small>${esc(chain.networkMode || 'ipv4').toUpperCase()}</small></td><td>${esc(exit)}</td>
<td>${esc(names(chain.customerIds, state.customers))}</td><td>${status(chain.status)}${chain.lastError ? `<small class="error-detail" title="${esc(chain.lastError)}">${esc(chain.lastError)}</small>` : ''}</td><td>${compactChainActions(chain)}</td></tr>`;
}).join('');
return `<div class="page-intro"><p>“转发线路”把多个客户端入口汇聚到一个出口;“单机直连”无需出口机,直接在任意设备创建节点。编辑运行中线路后,点击“应用修改”安全重建。</p><button class="primary" data-action="add-chain">+ 新建线路</button></div>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>线路</th><th>入口 / 节点设备</th><th>出口</th><th>客户</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="6">${empty('还没有线路','准备好设备和客户后创建第一条线路')}</td></tr>`}</tbody></table></div></section>`;
function nodeClientCount(nodeId) {
return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length;
}
function renderDeployments() {
const active = state.deployments.filter((item) => item.status !== 'deleted');
const rows = active.map((item) => {
const server = state.servers.find((entry) => entry.id === item.serverId);
const customer = state.customers.find((entry) => entry.id === item.customerId);
const chain = state.chains.find((entry) => entry.id === item.chainId);
const canCopy = item.status === 'active' && item.clientUri && customer?.status === 'active' &&
(!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) &&
(!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes) &&
(item.role === 'direct' || state.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active'));
return `<tr><td><strong>${esc(chain ? chain.name : '已删除线路')}</strong><small>${esc(deploymentRoleText[item.role] || item.role)}</small></td><td>${esc(customer ? customer.name : '已删除')}</td>
<td>${esc(server ? server.name : '已删除')}<small>${esc(server ? (server.publicAddressV6 && chain && chain.networkMode === 'ipv6' ? server.publicAddressV6 : server.publicAddress) : '')}:${item.port}</small></td>
<td>${esc(protocolName(item.protocol, item.role === 'exit' ? 'exit-transport' : 'relay-ingress'))}${chain && item.role !== 'exit' && item.protocol !== chain.relayProtocol ? '<small class="error-detail">编辑中的协议尚未部署</small>' : ''}</td><td>${status(item.status)}${item.error ? `<small>${esc(item.error)}</small>` : ''}${item.role !== 'exit' ? `<small>${item.meteredTraffic ? `本部署计量:上行 ${fmtBytes(item.meteredTraffic.uplink)} · 下行 ${fmtBytes(item.meteredTraffic.downlink)}` : '等待入口计量上报'}</small>${item.lastUsageAt ? `<small>最后上报 ${fmtDate(item.lastUsageAt)}</small>` : ''}` : ''}</td>
<td><div class="actions">${canCopy ? `<button data-action="copy-uri" data-value="${esc(item.clientUri)}">复制链接</button>` : ''}${chain ? `<button data-action="edit-chain" data-id="${esc(chain.id)}">编辑线路</button>` : ''}</div></td></tr>`;
function renderNodes() {
const rows = state.nodes.map((node) => {
const direct = (node.topology || 'forward') === 'direct';
const path = direct ? protocolName(node.protocol, 'relay-ingress') : `${protocolName(node.protocol, 'relay-ingress')} → ${protocolName(node.exitProtocol, 'exit-transport')}`;
const server = state.servers.find((item) => item.id === node.serverId);
const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === node.exitServerId) || {}).name || '已删除');
const traffic = node.meteredTraffic ? `上行 ${fmtBytes(node.meteredTraffic.uplink)} · 下行 ${fmtBytes(node.meteredTraffic.downlink)}` : '等待入口计量上报';
return `<tr><td><strong>${esc(node.name)}</strong><small>${esc(topologyText[node.topology || 'forward'])} · ${esc(path)}</small></td>
<td>${esc(server ? server.name : '已删除')}<small>${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}</small></td><td>${esc(exit)}</td>
<td>${nodeClientCount(node.id)} 位</td><td><small>${traffic}</small>${node.lastUsageAt ? `<small>最后上报 ${fmtDate(node.lastUsageAt)}</small>` : ''}</td>
<td>${status(node.status)}${node.error ? `<small class="error-detail" title="${esc(node.error)}">${esc(node.error)}</small>` : ''}</td><td>${compactNodeActions(node)}</td></tr>`;
}).join('');
return `<div class="page-intro"><p>显示线路生成的实际入口、出口和单机节点。客户端链接只在入口资源部署成功后生成;编辑请从对应线路统一完成。</p></div>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>线路 / 角色</th><th>客户</th><th>设备</th><th>协议</th><th>状态 / 错误</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="6">${empty('暂无部署','从“线路编排”页面发起部署')}</td></tr>`}</tbody></table></div></section>`;
return `<div class="page-intro"><p>一个节点是一个共享入口(对标 3x-ui 的 inbound):多位客户共用同一端口,各自使用独立凭据。分配 / 移除客户后节点自动重建下发;编辑运行中的节点会立即重建应用。</p><button class="primary" data-action="add-node">+ 新建节点</button></div>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>节点</th><th>服务器 / 入口</th><th>出口</th><th>客户端</th><th>流量</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有节点','准备好设备和客户后创建第一个共享节点')}</td></tr>`}</tbody></table></div></section>`;
}
function renderNodeLinks() {
// 按节点分组:一个节点一行,客户收纳为可展开列表
const byNode = new Map();
for (const nc of state.nodeClients) {
if (!byNode.has(nc.nodeId)) byNode.set(nc.nodeId, []);
byNode.get(nc.nodeId).push(nc);
}
const rows = [...byNode].map(([nodeId, ncs]) => {
const node = state.nodes.find((item) => item.id === nodeId);
const server = node && state.servers.find((item) => item.id === node.serverId);
const nodeActive = node && node.status === 'active';
const customerRows = ncs.map((nc) => {
const customer = state.customers.find((item) => item.id === nc.customerId);
const canCopy = nc.status === 'active' && nodeActive && nc.clientUri && customer && customer.status === 'active';
const name = esc(customer ? customer.name : '已删除客户');
const st = status(nc.status);
const link = canCopy ? `<button data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制链接</button>` : '<small>未运行或已停用</small>';
return `<div class="node-link-customer"><span><strong>${name}</strong> ${st}</span><span class="actions">${link}</span></div>`;
}).join('');
const names = ncs.map((nc) => {
const c = state.customers.find((item) => item.id === nc.customerId);
return esc(c ? c.name : '?');
}).join('、');
return `<tr><td><strong>${esc(node ? node.name : '已删除节点')}</strong><small>${esc(server ? server.name : '')}${node ? ` :${node.port}` : ''}</small></td>
<td><details><summary>${names}(${ncs.length})</summary><div class="node-link-list">${customerRows}</div></details></td>
<td>${node ? status(node.status) : status('draft')}</td>
<td><small>${ncs.length} 个分配</small></td></tr>`;
}).join('');
return `<div class="page-intro"><p>每个客户在每个已分配节点上拥有独立凭据。客户端链接只在节点运行中且客户正常时生成;日常分发请使用客户页的订阅链接。</p></div>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>节点</th><th>客户</th><th>状态</th><th>链接</th></tr></thead><tbody>${rows || `<tr><td colspan="4">${empty('暂无分配','先在“转发与节点”给客户分配节点')}</td></tr>`}</tbody></table></div></section>`;
}
function renderCommands() {
@@ -228,12 +268,12 @@ function renderCommands() {
['入口机 / 中转机', [
['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'],
['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'],
['安装 AnyTLS 引擎', 'ng-agent engine install', '仅在承载 AnyTLS 的入口执行。首次构建可能耗时,临时 Go 缓存完成后会清理。HY2 与 VLESS WS TLS 无需该引擎。'],
['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、Xray、sing-box 与入口统计。']
['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'],
['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。']
]],
['出口机 / 落地机', [
['更新轻量 Agent', 'ng-agent update', '只使用出口传输时无需安装 sing-box,也无需申请入口证书。'],
['检查运行状态', 'ng-agent doctor', '核对心跳和 Xray 配置。']
['更新 Agent', 'ng-agent update', '入口/出口机通用;出口机无需申请入口证书。'],
['检查运行状态', 'ng-agent doctor', '核对心跳和 sing-box 配置。']
]]
];
const sections = groups.map(([title, commands]) => `<section class="panel"><div class="panel-head"><h2>${esc(title)}</h2></div><div class="panel-body stack">${commands.map(([label, command, hint]) => `<div class="command-item"><div><b>${esc(label)}</b><small>${esc(hint)}</small><code>${esc(command)}</code></div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button></div>`).join('')}</div></section>`).join('');
@@ -251,7 +291,7 @@ function renderOperations() {
}
function render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderChains, deployments: renderDeployments, commands: renderCommands, operations: renderOperations };
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
}
@@ -313,8 +353,19 @@ function subscriptionModal(customer) {
['base64','通用 Base64'], ['clash','Mihomo / Clash 简洁版'], ['clash-smart','Mihomo / Clash 智能分流'],
['singbox','sing-box JSON'], ['surge','Surge(仅兼容节点)'], ['raw','原始节点链接']
];
// 该用户的各节点入站链接
const infoUrl = `${location.origin}/s/${customer.subscriptionToken}/info`;
const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => {
const node = state.nodes.find((n) => n.id === nc.nodeId);
if (!node || node.status !== 'active' || !nc.clientUri) return '';
const server = state.servers.find((s) => s.id === node.serverId);
const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`;
return `<div class="subscription-row"><div><b>${esc(label)}</b><code title="${esc(nc.clientUri)}">${esc(nc.clientUri.slice(0, 60))}...</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制</button><button type="button" data-action="qr-uri" data-value="${esc(nc.clientUri)}">二维码</button></div><div class="subscription-qr" hidden></div></div>`;
}).join('');
modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `<div class="stack"><div class="notice">仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。</div>
<div class="subscription-row" style="background:#1a2e1a"><div><b>📱 订阅信息页(3x-ui 风格)</b><code title="${esc(infoUrl)}">${esc(infoUrl)}</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(infoUrl)}">复制</button><a href="${esc(infoUrl)}" target="_blank"><button type="button">打开</button></a></div></div>
${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `<div class="subscription-row"><div><b>${esc(label)}</b><code title="${esc(url)}">${esc(url)}</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(url)}">复制</button><button type="button" data-action="qr-uri" data-value="${esc(url)}" aria-label="显示${esc(label)}二维码">二维码</button></div><div class="subscription-qr" hidden></div></div>`; }).join('')}
${nodeLinks ? `<h4 style="margin:16px 0 8px">各节点入站链接</h4>${nodeLinks}` : ''}
<div class="field-note">二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。</div>
<div class="notice warning">订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。</div>
<div class="form-actions"><button class="danger" data-action="rotate-subscription" data-id="${esc(customer.id)}">重置订阅地址</button><button data-close>关闭</button></div></div>`);
@@ -330,49 +381,73 @@ async function customerAccessModal(customer) {
<div class="form-actions"><button class="danger" data-action="reset-access" data-id="${esc(customer.id)}">清空订阅访问窗口</button><button data-close>关闭</button></div></div>`);
}
function chainForm(item = null) {
const chain = item || { topology:'forward', relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', relayPortMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443, relayServerIds:[], customerIds:[] };
const preset = state.realityPresets.find((entry) => entry.serverName === chain.realityServerName) || { id:'custom' };
modal(item ? 'EDIT ROUTE' : 'NEW ROUTE', item ? '编辑线路' : '新建线路', `<form id="chain-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label class="wide">线路名称<input name="name" value="${esc(chain.name || '')}" placeholder="新加坡入口 → 日本出口" required></label>
<label>线路类型<select name="topology" data-chain-sync><option value="forward"${selected('forward',chain.topology || 'forward')}>转发线路(入口 → 出口)</option><option value="direct"${selected('direct',chain.topology)}>单机直连(无需出口)</option></select></label>
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4',chain.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6',chain.networkMode)}>IPv6</option><option value="dual"${selected('dual',chain.networkMode)}>双栈监听</option></select><small>IPv6 模式要求设备已填写公网 IPv6。</small></label>
<div class="section-title">入口与客户端节点</div>
<div class="wide picker-field"><span>入口 / 节点设备</span>${multiPicker('relayServerIds',state.servers,chain.relayServerIds,(server) => `${roleText[server.role]} · ${server.region || server.publicAddress} · ${server.publicAddress}`,'设备')}</div>
<label>客户端入口协议<select name="relayProtocol" data-chain-sync>${protocolOptions('relay-ingress', chain.relayProtocol)}</select></label>
<label>入口端口<div class="inline-fields"><select name="relayPortMode" data-chain-sync><option value="random"${selected('random',chain.relayPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',chain.relayPortMode)}>固定端口</option></select><input name="relayPort" type="number" value="${esc(chain.relayPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
function nodeForm(item = null) {
const node = item || { topology:'forward', protocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', portMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443 };
const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id:'custom' };
modal(item ? 'EDIT NODE' : 'NEW NODE', item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" required></label>
<label>服务器<select name="serverId" required>${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,node.serverId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4',node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6',node.networkMode)}>IPv6</option><option value="dual"${selected('dual',node.networkMode)}>双栈监听</option></select><small>IPv6 模式要求设备已填写公网 IPv6。</small></label>
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random',node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed',node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<div class="notice wide" data-protocol-note></div>
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id,preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom',preset.id)}>自定义</option></select></label>
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(chain.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(chain.realityDestPort || 443)}" min="1" max="65535"></div><small>默认 Tesla。预设仅提供填写便利,不是防盗用功能;目标站点须支持所选 SNI。</small></label>
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div><small>默认 Tesla。预设仅提供填写便利;目标站点须支持所选 SNI。</small></label>
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward',node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct',node.topology)}>单机直连(无需出口)</option></select></label>
<div class="section-title" data-forward-only>出口与转发传输</div>
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,chain.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-chain-sync>${protocolOptions('exit-transport', chain.exitProtocol)}</select><small>出口可独立选 SS / SS2022 / VLESS TCP / SOCKS5,不要求与入口同协议。</small></label>
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-chain-sync><option value="random"${selected('random',chain.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',chain.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(chain.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<div class="section-title">客户分配</div>
<div class="wide picker-field"><span>客户</span>${multiPicker('customerIds',state.customers.filter((customer) => customer.status === 'active' || (chain.customerIds || []).includes(customer.id)),chain.customerIds,(customer) => `${customer.group || '未分组'}${customer.status !== 'active' ? ' · 已停用' : ''}`,'客户')}<small>批量选择多个客户时请使用随机端口。</small></div>
<div class="notice wide">VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书。到入口机运行 ng-agent cert,按提示申请并同步域名;Hysteria 2 使用 UDP,其余使用 TCP。AnyTLS 由 sing-box 承载,缺少引擎时运行 ng-agent engine install。</div>
${item && !['draft'].includes(item.status) ? '<div class="notice warning wide">保存运行中线路只会标记“待重新部署”,不会立即中断服务。确认后再点击“应用修改”。</div>' : ''}
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建线路'}</button></div></form>`);
syncChainForm();
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select><small>出口可独立选 SS / SS2022 / VLESS TCP / SOCKS5,不要求与入口同协议。</small></label>
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random',node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<div class="notice wide">保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。</div>
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`);
syncNodeForm();
}
function syncChainForm() {
const form = $('#chain-form'); if (!form) return;
function syncNodeForm() {
const form = $('#node-form'); if (!form) return;
const direct = form.elements.topology.value === 'direct';
$$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct));
form.elements.exitServerId.required = !direct; form.elements.exitProtocol.required = !direct;
const reality = ['vless-reality-vision','vless-reality'].includes(form.elements.relayProtocol.value);
const reality = ['vless-reality-vision','vless-reality'].includes(form.elements.protocol.value);
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
form.elements.realityServerName.required = reality;
const protocol = state.protocols.find((item) => item.id === form.elements.relayProtocol.value && item.role === 'relay-ingress');
const protocol = state.protocols.find((item) => item.id === form.elements.protocol.value && item.role === 'relay-ingress');
$('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。';
for (const [mode, port] of [['relayPortMode','relayPort'],['exitPortMode','exitPort']]) {
const fixed = form.elements[mode].value === 'fixed' && (mode === 'relayPortMode' || !direct);
for (const [mode, port] of [['portMode','port'],['exitPortMode','exitPort']]) {
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
form.elements[port].disabled = !fixed;
form.elements[port].required = fixed;
}
}
function nodeClientsModal(node) {
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
const available = state.customers.filter((customer) => customer.status === 'active' && !assigned.some((nc) => nc.customerId === customer.id));
const rows = assigned.map((nc) => {
const customer = state.customers.find((item) => item.id === nc.customerId);
return `<tr><td><strong>${esc(customer ? customer.name : '已删除客户')}</strong><small>${esc(customer ? (customer.group || '未分组') : nc.customerId)}</small></td>
<td>${status(nc.status)}</td>
<td><div class="actions">${nc.clientUri ? `<button data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制链接</button>` : ''}<button class="danger" data-action="unassign-customer" data-id="${esc(node.id)}" data-customer="${esc(nc.customerId)}">移除</button></div></td></tr>`;
}).join('');
modal('NODE CLIENTS', `${node.name} · 客户端分配`, `<div class="stack">
<div class="notice">多位客户共用同一入口端口,各自使用独立凭据。分配 / 移除后节点自动重建下发(sing-box 重启一次)。</div>
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="3">${empty('暂无客户端','从下方选择客户分配')}</td></tr>`}</tbody></table></div>
${available.length ? `<form id="assign-form" class="inline-fields"><input type="hidden" name="nodeId" value="${esc(node.id)}"><select name="customerId" required>${available.map((customer) => `<option value="${esc(customer.id)}">${esc(customer.name)}${customer.group ? ` · ${esc(customer.group)}` : ''}</option>`).join('')}</select><button class="primary" type="submit">分配客户</button></form>` : '<small>没有可分配的活跃客户。</small>'}
<div class="form-actions"><button data-close>关闭</button></div></div>`);
}
function assignNodesModal(customer) {
const assigned = new Set(state.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => nc.nodeId));
const rows = state.nodes.map((node) => {
const server = state.servers.find((item) => item.id === node.serverId);
return `<label class="picker-option"><input type="checkbox" name="nodeIds" value="${esc(node.id)}"${checked(node.id, [...assigned])}><span><b>${esc(node.name)}</b><small>${esc(server ? server.name : '已删除')} · ${esc(protocolName(node.protocol, 'relay-ingress'))} :${node.port} · ${esc(statusText[node.status] || node.status)}</small></span></label>`;
}).join('');
modal('ASSIGN NODES', `${customer.name} · 分配节点`, `<form id="assign-nodes-form" class="stack"><input type="hidden" name="customerId" value="${esc(customer.id)}">
<div class="notice">勾选即分配,取消即移除。分配到运行中的节点会立即重建下发。</div>
<div class="picker-options">${rows || '<span class="muted">还没有节点,先去“转发与节点”创建</span>'}</div>
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">保存分配</button></div></form>`);
}
function expiryIso(formData) {
const date = formData.get('expiryDate'); if (!date) return null;
const time = formData.get('expiryTime') || '23:59';
@@ -399,11 +474,21 @@ document.addEventListener('submit', async (event) => {
const payload = { name:data.get('name'), group:data.get('group'), trafficLimitBytes:Number(data.get('trafficGb') || 0) * 1024 ** 3, expiresAt:expiryIso(data), ipLimit:Number(data.get('ipLimit') || 0), deviceLimit:Number(data.get('deviceLimit') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') };
await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
} else if (form.id === 'chain-form') {
} else if (form.id === 'node-form') {
const resourceId = data.get('resourceId');
const payload = { name:data.get('name'), topology:data.get('topology'), networkMode:data.get('networkMode'), relayServerIds:data.getAll('relayServerIds'), exitServerId:data.get('exitServerId') || null, customerIds:data.getAll('customerIds'), relayProtocol:data.get('relayProtocol'), exitProtocol:data.get('exitProtocol') || null, relayPortMode:data.get('relayPortMode'), relayPort:data.get('relayPort') || null, exitPortMode:data.get('exitPortMode') || null, exitPort:data.get('exitPort') || null, realityServerName:data.get('realityServerName'), realityDestPort:Number(data.get('realityDestPort') || 443) };
const result = await api(resourceId ? `/api/chains/${resourceId}` : '/api/chains', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(result.requiresRedeploy ? '修改已保存;原节点仍按旧协议运行,请点击“应用修改”' : (resourceId ? '线路已更新' : '线路草稿已创建')); await load('chains');
const payload = { name:data.get('name'), serverId:data.get('serverId'), protocol:data.get('protocol'), portMode:data.get('portMode'), port:data.get('port') || null, networkMode:data.get('networkMode'), topology:data.get('topology'), exitServerId:data.get('exitServerId') || null, exitProtocol:data.get('exitProtocol') || null, exitPortMode:data.get('exitPortMode') || null, exitPort:data.get('exitPort') || null, realityServerName:data.get('realityServerName'), realityDestPort:Number(data.get('realityDestPort') || 443) };
const result = await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
} else if (form.id === 'assign-form') {
await api(`/api/nodes/${data.get('nodeId')}/assign`, { method:'POST', body:JSON.stringify({ customerId:data.get('customerId') }) });
toast('客户已分配'); const node = state.nodes.find((item) => item.id === data.get('nodeId')); await load('chains'); if (node) nodeClientsModal(state.nodes.find((item) => item.id === node.id));
} else if (form.id === 'assign-nodes-form') {
const customerId = data.get('customerId');
const wanted = new Set(data.getAll('nodeIds'));
const current = state.nodeClients.filter((nc) => nc.customerId === customerId).map((nc) => nc.nodeId);
for (const nodeId of current.filter((id) => !wanted.has(id))) await api(`/api/nodes/${nodeId}/unassign`, { method:'POST', body:JSON.stringify({ customerId }) });
for (const nodeId of [...wanted].filter((id) => !current.includes(id))) await api(`/api/nodes/${nodeId}/assign`, { method:'POST', body:JSON.stringify({ customerId }) });
$('#modal').close(); toast('节点分配已更新'); await load('customers');
} else if (form.id === 'account-form') {
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致');
await api('/api/account', { method:'PATCH', body:JSON.stringify({ username:data.get('username'), currentPassword:data.get('currentPassword'), newPassword:data.get('newPassword') }) });
@@ -435,15 +520,20 @@ document.addEventListener('click', async (event) => {
state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item);
subscriptionModal(result.customer); toast('订阅地址已重置');
}
else if (action === 'add-chain') chainForm();
else if (action === 'edit-chain') {
else if (action === 'add-node') nodeForm();
else if (action === 'edit-node') {
if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; }
chainForm(state.chains.find((item) => item.id === itemId));
nodeForm(state.nodes.find((item) => item.id === itemId));
} else if (action === 'node-clients') nodeClientsModal(state.nodes.find((item) => item.id === itemId));
else if (action === 'assign-nodes') assignNodesModal(state.customers.find((item) => item.id === itemId));
else if (action === 'unassign-customer' && confirm('移除该客户在此节点的分配?节点会自动重建。')) {
await api(`/api/nodes/${itemId}/unassign`, { method:'POST', body:JSON.stringify({ customerId:button.dataset.customer }) });
toast('分配已移除'); const node = state.nodes.find((item) => item.id === itemId); await load('chains'); nodeClientsModal(state.nodes.find((item) => item.id === node.id));
} else if (action === 'enroll-server') {
const result = await api(`/api/servers/${itemId}/enrollment-token`, { method:'POST', body:'{}' });
const command = `curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-install.sh | bash -s -- --server ${location.origin} --token ${result.token}`;
modal('ONE-TIME ENROLLMENT', 'Agent 注册 / 重装命令', `<div class="stack"><div class="notice">令牌 30 分钟内有效且只能使用一次。重装会保留本机资源文件,并在 Agent 重启后自动与控制面对账。</div><div class="codebox">${esc(command)}</div><button class="primary" data-action="copy-uri" data-value="${esc(command)}">复制命令</button></div>`);
} else if (action === 'copy-uri') { await navigator.clipboard.writeText(button.dataset.value); toast('已复制到剪贴板'); }
} else if (action === 'copy-uri') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
else if (action === 'qr-uri') {
const target = $('.subscription-qr', button.closest('.subscription-row'));
if (!target.hidden) { target.hidden = true; target.replaceChildren(); return; }
@@ -453,7 +543,7 @@ document.addEventListener('click', async (event) => {
else if (action === 'agent-uninstall') {
const command = 'ng-agent uninstall';
const fallback = 'curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-uninstall.sh | bash';
modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `<div class="stack"><div class="notice warning">先停用并删除关联线路,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。</div><div class="codebox">${esc(command)}</div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button><small>旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:</small><div class="codebox">${esc(fallback)}</div><button data-action="copy-uri" data-value="${esc(fallback)}">复制兼容命令</button></div>`);
modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `<div class="stack"><div class="notice warning">先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。</div><div class="codebox">${esc(command)}</div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button><small>旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:</small><div class="codebox">${esc(fallback)}</div><button data-action="copy-uri" data-value="${esc(fallback)}">复制兼容命令</button></div>`);
}
else if (action === 'retry-cleanup') { const result = await api(`/api/servers/${itemId}/cleanup`, { method:'POST', body:'{}' }); toast(result.queued ? `已重新排队 ${result.queued} 个清理任务` : '清理任务已在队列中,请等待设备上线'); await load(); }
else if (action === 'forget-server') {
@@ -465,17 +555,15 @@ document.addEventListener('click', async (event) => {
toast('已强制遗忘设备;请确保旧机器已销毁或手工清理'); await load();
}
else if (action === 'delete-server' && confirm('删除控制台登记并吊销 Agent 密钥?如需要彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。')) { await api(`/api/servers/${itemId}`, { method:'DELETE' }); toast('设备登记已删除'); await load(); }
else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先删除关联线路;归档清理任务会在后台继续执行。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); }
else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先移除全部节点分配。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); }
else if (action === 'reset-usage' && confirm('确认把该客户已用流量清零?')) { await api(`/api/customers/${itemId}/reset-usage`, { method:'POST', body:'{}' }); toast('客户流量已清零'); await load(); }
else if (action === 'toggle-customer') {
const item = state.customers.find((entry) => entry.id === itemId);
await api(`/api/customers/${itemId}`, { method:'PATCH', body:JSON.stringify({ status:item.status === 'active' ? 'suspended' : 'active' }) }); toast('客户状态已更新'); await load();
} else if (action === 'deploy-chain' && confirm('现在向所选设备下发这条线路?')) { await api(`/api/chains/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); }
else if (action === 'restore-chain' && confirm('使用原端口和节点凭据恢复这条线路?如端口已被其他线路占用,请改用全新部署。')) { const result = await api(`/api/chains/${itemId}/restore`, { method:'POST', body:'{}' }); toast(`已排队恢复 ${result.jobs} 项原资源`); await load(); }
else if (action === 'redeploy-chain' && confirm('重新部署会先移除旧资源,再自动创建新资源。确认继续?')) { await api(`/api/chains/${itemId}/redeploy`, { method:'POST', body:'{}' }); toast('线路已进入安全重建流程'); await load(); }
else if (action === 'repair-chain' && confirm('仅重试当前失败的部署项?')) { await api(`/api/chains/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); }
else if (action === 'remove-chain' && confirm('停用会从相关设备移除配置,确认继续?')) { await api(`/api/chains/${itemId}/remove`, { method:'POST', body:'{}' }); toast('移除任务已进入队列'); await load(); }
else if (action === 'delete-chain' && confirm('确定删除这条线路?未完成的部署会取消;系统会给相关设备排队清理遗留资源。运行中的资源须先停用。')) { const result = await api(`/api/chains/${itemId}`, { method:'DELETE' }); toast(result.cleanupPending ? `线路已移除,${result.cleanupPending} 个设备资源待 Agent 确认清理` : '线路已删除'); await load(); }
} else if (action === 'deploy-node' && confirm('现在向所选设备下发这个节点?')) { await api(`/api/nodes/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); }
else if (action === 'repair-node' && confirm('重新下发该节点配置?')) { await api(`/api/nodes/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); }
else if (action === 'remove-node' && confirm('停用会从相关设备移除配置,节点保留为草稿,确认继续?')) { await api(`/api/nodes/${itemId}/remove`, { method:'POST', body:'{}' }); toast('停用任务已进入队列'); await load(); }
else if (action === 'delete-node' && confirm('确定删除这个节点?会移除全部分配并从设备清理资源。')) { await api(`/api/nodes/${itemId}`, { method:'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); }
else if (action === 'download-backup') location.href = '/api/backup';
else if (action === 'restore-backup') {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = async () => {
@@ -497,7 +585,7 @@ document.addEventListener('change', (event) => {
const picker = event.target.closest('[data-picker]');
$('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`;
}
if (event.target.matches('[data-chain-sync]')) syncChainForm();
if (event.target.matches('[data-node-sync]')) syncNodeForm();
if (event.target.matches('[data-reality-preset]')) {
const option = event.target.selectedOptions[0]; const form = event.target.form;
if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }