Files
NexusGate/public/app.js
T
aaaamoooo c6f6b9dbea feat(panel-go): monthly traffic reset for customers
- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
2026-10-21 03:16:30 +08:00

634 lines
66 KiB
JavaScript
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.
'use strict';
const state = {
session: null, page: 'overview', overview: null, servers: [], customers: [],
nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8'
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const esc = (value) => String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]);
const selected = (value, current) => String(value) === String(current) ? ' selected' : '';
const checked = (value, values) => (values || []).includes(value) ? ' checked' : '';
const fmtDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
const fmtBytes = (value) => {
let size = Number(value || 0); const units = ['B','KB','MB','GB','TB','PB']; let index = 0;
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
};
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:'已暂停', removing:'停用中', deleted:'已删除'
};
const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' };
const topologyText = { forward:'转发线路', 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_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) {
const next = theme === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('nexusgate-theme', next); } catch { /* storage may be disabled */ }
document.querySelector('meta[name="theme-color"]').content = next === 'dark' ? '#0e1421' : '#f5f7fc';
document.querySelector('meta[name="color-scheme"]').content = next;
$$('[data-theme-toggle]').forEach((button) => { button.textContent = next === 'dark' ? '☾ 深色' : '☀ 浅色'; button.title = next === 'dark' ? '切换为浅色主题' : '切换为深色主题'; button.setAttribute('aria-pressed', String(next === 'dark')); });
}
function toast(message, error = false) {
const item = document.createElement('div');
item.className = `toast${error ? ' error' : ''}`; item.textContent = message;
$('#toasts').append(item); setTimeout(() => item.remove(), 3800);
}
async function api(path, options = {}) {
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.headers || {}) };
if (state.session && !['GET','HEAD'].includes(options.method || 'GET')) headers['x-csrf-token'] = state.session.csrf;
const response = await fetch(path, { ...options, headers });
const type = response.headers.get('content-type') || '';
const data = type.includes('json') ? await response.json() : await response.text();
if (!response.ok) {
if (response.status === 401 && path !== '/api/auth/login') showLogin();
throw new Error(data.message || `请求失败 (${response.status})`);
}
return data;
}
function showLogin() {
state.session = null; $('#app').classList.add('hidden'); $('#login').classList.remove('hidden');
}
function showApp() {
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
$('#user-chip').textContent = `${state.session.user.username} · 管理员`;
}
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/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') {
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/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 === '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')]);
state.jobs = jobs.jobs; state.servers = servers.servers; state.overview = overview; state.version = overview.version || state.version;
}
render();
} catch (error) { toast(error.message, true); }
}
const PAGES = ['overview', 'servers', 'customers', 'chains', 'commands', 'operations'];
function pageFromHash() { const page = location.hash.replace(/^#/, ''); return PAGES.includes(page) ? page : 'overview'; }
function setPage(page) {
if (!PAGES.includes(page)) page = 'overview';
state.page = page; state.search = ''; closeSidebar();
if (location.hash !== `#${page}`) history.pushState(null, '', `#${page}`);
$$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page));
const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', commands:'安装与命令', operations:'设置与运维' };
$('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`;
load(page);
}
function status(value) { return `<span class="status ${esc(value)}">${esc(statusText[value] || value)}</span>`; }
function tags(values) { return (values || []).map((value) => `<span class="tag">${esc(value)}</span>`).join('') || '—'; }
function empty(title, note) { return `<div class="empty"><b>${esc(title)}</b><span>${esc(note)}</span></div>`; }
function names(ids, collection) { return (ids || []).map((key) => (collection.find((item) => item.id === key) || {}).name || '已删除').join('、'); }
function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; }
function renderOverview() {
const counts = (state.overview && state.overview.counts) || {};
const metrics = [
['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'],
['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'],
['共享节点', 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>` : ''}
<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>`;
}
function renderServers() {
const q = state.search.toLowerCase();
const resources = (system) => {
if (!system || !Number.isFinite(system.memoryTotal)) return '';
const memory = Number(system.memoryAvailable ?? system.memoryFree);
const disk = Number(system.diskAvailable);
const warning = memory < 200 * 1024 * 1024 || (Number.isFinite(disk) && disk < 1024 ** 3);
const text = `内存可用 ${fmtBytes(memory)} / ${fmtBytes(system.memoryTotal)} · 负载 ${esc(system.load1 ?? '—')} / ${esc(system.cpuCount ?? '—')} 核`
+ (Number.isFinite(disk) ? ` · 系统盘可用 ${fmtBytes(disk)} / ${fmtBytes(system.diskTotal)}` : '');
return `<small${warning ? ' class="error-detail"' : ''}>${text}${warning ? ' · 资源余量偏低' : ''}</small>`;
};
const rows = state.servers.filter((item) => [item.name,item.region,item.publicAddress,item.publicAddressV6,...(item.labels || [])].join(' ').toLowerCase().includes(q)).map((server) => `<tr>
<td><strong>${esc(server.name)}</strong><small>${esc(server.publicAddress)}${server.publicAddressV6 ? ` · ${esc(server.publicAddressV6)}` : ''}</small></td><td>${esc(roleText[server.role] || server.role)}</td><td>${esc(server.region || '未分组')}</td>
<td>${tags(server.labels)}</td><td>${status(server.status)}<small>${server.lastSeenAt ? `最后上报 ${fmtDate(server.lastSeenAt)}` : '等待 Agent 注册'} · Agent ${esc(server.agentVersion || '未上报版本')}</small>${resources(server.system)}${server.tlsDomain ? `<small>节点证书 ${esc(server.tlsDomain)} · ${server.engine?.certificates?.includes(server.tlsDomain) ? '已确认' : '待确认'}</small>` : ''}${server.role !== 'exit' && server.engine ? `<small>AnyTLS 引擎 · ${server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)'}</small>` : ''}${oldUsageAgent(server.agentVersion) ? '<small class="error-detail">旧版流量采集有问题,请在该机运行 ng-agent-update</small>' : ''}${server.usage ? `<small>流量统计 ${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项</small>` : '<small>尚无流量统计上报</small>'}${server.usage?.error ? `<small class="error-detail" title="${esc(server.usage.error)}">统计异常:${esc(server.usage.error)}</small>` : ''}${server.engine && server.engine.status === 'error' ? `<small class="error-detail" title="${esc(server.engine.detail)}">引擎故障:${esc(server.engine.detail)}</small>` : ''}${server.pendingCleanup ? `<small>待清理 ${server.pendingCleanup} 项</small>` : ''}</td>
<td>${rowActions(server.id, `<button data-action="edit-server" data-id="${esc(server.id)}">编辑</button><button data-action="enroll-server" data-id="${esc(server.id)}">注册</button>`, [
['agent-uninstall','SSH 卸载'], ...(server.pendingCleanup ? [['retry-cleanup','重试清理']] : []),
server.pendingCleanup && server.status !== 'online' ? ['forget-server','遗忘离线设备'] : ['delete-server','删除设备']
])}</td></tr>`).join('');
return `<div class="page-intro"><p>统一管理入口转发机、出口落地机与单机节点。Agent 主动连接控制面,不保存设备 SSH 密码;重装 Agent 会自动对账并恢复已有资源。</p><button class="primary" data-action="add-server">+ 添加设备</button></div>
<section class="panel"><div class="panel-head"><div class="toolbar"><input class="search" data-search placeholder="搜索名称、地区、IP 或标签" value="${esc(state.search)}"><span class="tag">${state.servers.length} 台</span></div></div>
<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 renderCustomers() {
const q = state.search.toLowerCase();
const rows = state.customers.filter((item) => [item.name,item.group,...(item.tags || [])].join(' ').toLowerCase().includes(q)).map((item) => {
const percent = item.trafficLimitBytes ? Math.min(100, Math.round(item.usedBytes / item.trafficLimitBytes * 100)) : 0;
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>`, [['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>
<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>`;
}
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 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(node.id, primary, more) : `<div class="actions compact-actions">${primary}</div>`;
}
function nodeClientCount(nodeId) {
return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length;
}
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>一个节点是一个共享入口(对标 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() {
const groups = [
['面板机', [
['更新控制面', 'ng update', '只在面板机运行;更新后刷新浏览器。'],
['检查面板 HTTPS', 'ng cert', '只检查面板的 Caddy 证书,不会申请入口节点证书。'],
['备份面板', 'ng backup', '客户凭据会包含在备份中,请妥善保管。']
]],
['入口机 / 中转机', [
['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'],
['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'],
['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'],
['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。']
]],
['出口机 / 落地机', [
['更新 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('');
return `<div class="page-intro"><p>在对应服务器的 SSH 终端以 root 运行。首次注册请在“服务器”选择目标设备并生成一次性注册命令;证书域名需要先解析到入口机,AnyTLS 须单独安装 sing-box。</p></div><div class="command-grid">${sections}</div>`;
}
function renderOperations() {
const rows = state.jobs.slice(0, 100).map((job) => `<tr><td><strong>${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}</strong><small>${esc(job.id)}</small></td><td>${esc((state.servers.find((item) => item.id === job.serverId) || {}).name || job.serverId)}</td><td>${status(job.status)}</td><td>${job.attempts}</td><td>${fmtDate(job.updatedAt)}</td><td><small>${esc(job.error || '')}</small></td></tr>`).join('');
return `<section class="grid-3">
<article class="panel"><div class="panel-head"><div><h2>账号安全</h2><p>修改后台登录账号或密码</p></div></div><div class="panel-body"><form id="account-form" class="stack"><label>登录账号<input name="username" value="${esc(state.session.user.username)}" required></label><label>当前密码<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>新密码(不修改可留空)<input name="newPassword" type="password" minlength="10" autocomplete="new-password"></label><label>确认新密码<input name="confirmPassword" type="password" minlength="10" autocomplete="new-password"></label><button class="primary" type="submit">保存并重新登录</button></form></div></article>
<article class="panel"><div class="panel-head"><div><h2>备份与恢复</h2><p>浏览器 JSON 与整机迁移包</p></div></div><div class="panel-body stack"><div class="notice">备份含客户凭据,请加密保管。整机迁移推荐 SSH 运行 <b>ng backup</b>,新机安装后把压缩包放进 /root,再运行 <b>ng restore</b>。</div><div class="toolbar"><button class="primary" data-action="download-backup">下载 JSON</button><button data-action="restore-backup">恢复 JSON</button></div></div></article>
<article class="panel"><div class="panel-head"><div><h2>版本与证书</h2><p>轻量单进程控制面</p></div></div><div class="panel-body"><div class="health-row"><div><b>NexusGate</b><span>当前版本</span></div><span class="tag">v${esc(state.version)}</span></div><div class="health-row"><div><b>Caddy HTTPS</b><span>证书自动申请与续签</span></div>${status('active')}</div><div class="codebox">ng update\nng domain new.example.com\nng cert</div></div></article>
</section>
<section class="panel" style="margin-top:16px"><div class="panel-head"><div><h2>任务队列</h2><p>任务租约 5 分钟,超时自动重试,最多 3 次</p></div></div><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 render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
}
function modal(kicker, title, body) {
$('#modal-kicker').textContent = kicker; $('#modal-title').textContent = title; $('#modal-body').innerHTML = body;
if (!$('#modal').open) $('#modal').showModal();
}
function serverForm(item = null) {
const server = item || { role:'hybrid', portRangeStart:20000, portRangeEnd:50000, labels:[] };
modal(item ? 'EDIT DEVICE' : 'NEW DEVICE', item ? '编辑设备' : '添加设备', `<form id="server-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label>设备名称<input name="name" value="${esc(server.name || '')}" placeholder="新加坡入口 01" required></label><label>用途<select name="role"><option value="relay"${selected('relay',server.role)}>入口 / 转发</option><option value="exit"${selected('exit',server.role)}>出口 / 落地</option><option value="hybrid"${selected('hybrid',server.role)}>综合节点</option></select></label>
<label>地区 / 分组<input name="region" value="${esc(server.region || '')}" placeholder="新加坡"></label><label>公网 IPv4 / 域名<input name="publicAddress" value="${esc(server.publicAddress || '')}" placeholder="203.0.113.10" required></label>
<label>公网 IPv6(可选)<input name="publicAddressV6" value="${esc(server.publicAddressV6 || '')}" placeholder="2001:db8::10"></label><label>标签(逗号分隔)<input name="labels" value="${esc((server.labels || []).join(', '))}" placeholder="CN2, 高带宽, 主力"></label>
<label class="wide">节点 TLS 域名(AnyTLS / Hysteria 2 / VLESS WS TLS)<input name="tlsDomain" value="${esc(server.tlsDomain || '')}" placeholder="node.example.com"><small>在入口机运行 ng-agent update && ng-agent cert,按提示输入域名;DNS 验证需一次 CF API Token,HTTP 验证需公网 80/TCP。证书签发后会自动同步。仅作出口时无需填写。</small></label>
<label>可用端口起点<input name="portRangeStart" type="number" value="${Number(server.portRangeStart || 20000)}" min="1024" max="65535" required></label><label>可用端口终点<input name="portRangeEnd" type="number" value="${Number(server.portRangeEnd || 50000)}" min="1024" max="65535" required></label>
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '添加设备'}</button></div></form>`);
}
function localDateParts(value) {
if (!value) return { date:'', time:'23:59' };
const date = new Date(value); const pad = (number) => String(number).padStart(2, '0');
return { date:`${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}`, time:`${pad(date.getHours())}:${pad(date.getMinutes())}` };
}
function timeOptions(current = '23:59') {
const values = ['00:00','06:00','12:00','18:00','23:59'];
if (current && !values.includes(current)) values.push(current);
return values.sort().map((value) => `<option value="${value}"${selected(value,current)}>${value}</option>`).join('');
}
function customerForm(item = null) {
const customer = item || { trafficLimitBytes:0, ipLimit:2, deviceLimit:3, tags:[] };
const expiry = localDateParts(customer.expiresAt);
modal(item ? 'EDIT CUSTOMER' : 'NEW CUSTOMER', item ? '编辑客户' : '添加客户', `<form id="customer-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label>客户名称<input name="name" value="${esc(customer.name || '')}" required></label><label>分组<input name="group" value="${esc(customer.group || '')}" placeholder="华南团队"></label>
<label>流量上限(GB,0 不限)<input name="trafficGb" type="number" min="0" step="0.01" value="${Number(customer.trafficLimitBytes || 0) / 1024 ** 3}"></label><label>快速到期<select data-expiry-quick><option value="">自定义 / 不限</option><option value="7">7 天后</option><option value="30">30 天后</option><option value="90">90 天后</option><option value="365">1 年后</option></select></label>
<label>到期日期<input name="expiryDate" type="date" value="${esc(expiry.date)}"><small>留空表示不限期,不再需要手工输入日期格式。</small></label><label>到期时间<select name="expiryTime">${timeOptions(expiry.time)}</select></label>
<label>滚动节点 IP 上限<input name="ipLimit" type="number" min="0" step="1" value="${Number(customer.ipLimit || 0)}"><small>0 不限;Agent 每分钟上报,超限后暂停客户和节点。</small></label><label>订阅客户端估计上限<input name="deviceLimit" type="number" min="0" step="1" value="${Number(customer.deviceLimit || 0)}"><small>0 不限;24 小时内按客户端标识或 User-Agent 计数,只拦截新订阅请求,无法限制已导入的节点。</small></label>
<label>每月流量重置日<input name="trafficResetDay" type="number" min="0" max="31" step="1" value="${Number(customer.trafficResetDay || 0)}"><small>0 不重置;1–31 为每月重置日(超过当月天数按月末),到日自动清零已用流量。${customer.trafficResetAt ? `上次重置 ${fmtDate(customer.trafficResetAt)}。` : ''}</small></label>
<label class="wide">标签(逗号分隔)<input name="tags" value="${esc((customer.tags || []).join(', '))}"></label><label class="wide">备注<textarea name="notes">${esc(customer.notes || '')}</textarea></label>
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建客户'}</button></div></form>`);
}
function protocolOptions(role, current) {
return state.protocols.filter((item) => item.role === role && item.deployable).map((item) => `<option value="${esc(item.id)}"${selected(item.id,current)}>${esc(item.name)}${item.status === 'beta' ? ' · 测试' : ''}</option>`).join('');
}
function multiPicker(name, items, values, subtitle, emptyLabel) {
return `<details class="multi-picker" data-picker><summary><span data-picker-count>已选择 ${(values || []).length} 项</span><span>▾</span></summary>
<div class="picker-content"><input type="search" data-picker-search placeholder="搜索名称、地区、地址或分组" aria-label="搜索${esc(emptyLabel)}">
<div class="picker-options">${items.map((item) => `<label class="picker-option" data-picker-option data-query="${esc([item.name,subtitle(item)].join(' ').toLowerCase())}"><input type="checkbox" name="${esc(name)}" value="${esc(item.id)}"${checked(item.id,values)}><span><b>${esc(item.name)}</b><small>${esc(subtitle(item))}</small></span></label>`).join('') || `<span class="muted">没有可用${esc(emptyLabel)}</span>`}</div>
<small>支持搜索后连续多选,已选项在折叠后保留。</small></div></details>`;
}
function subscriptionModal(customer) {
if (!customer || !customer.subscriptionToken) return toast('客户订阅令牌尚未生成,请刷新页面', true);
const formats = [
['auto','自动识别客户端'], ['v2ray','V2Ray / v2rayNG'], ['shadowrocket','Shadowrocket 小火箭'],
['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"><div><b>📱 订阅信息页</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>`);
}
async function customerAccessModal(customer) {
const data = await api(`/api/customers/${customer.id}/access`);
const ips = data.observedIps.map((item) => `<tr><td><code>${esc(item.ip)}</code></td><td>${fmtDate(item.firstSeenAt)}</td><td>${fmtDate(item.lastSeenAt)}</td></tr>`).join('');
const logs = data.events.map((item) => `<tr><td>${fmtDate(item.at)}</td><td><span class="status ${item.status === 200 ? 'active' : 'failed'}">${item.status === 200 ? '已返回' : `${item.status} ${esc(item.reason)}`}</span></td><td>${esc(item.ip)}<small title="${esc(item.userAgent)}">${esc(item.userAgent || '未提供 User-Agent')}</small></td><td>${esc(item.format)}</td><td>${fmtBytes(item.bytes)}</td></tr>`).join('');
modal('ACCESS ACTIVITY', `${customer.name} · 访问记录`, `<div class="stack"><div class="notice">节点 IP:${data.observedIps.length} / ${data.limits.ip || '不限'}(滚动观察窗口);订阅客户端约:${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}(最近 24 小时)。订阅统计不能识别物理设备;同一 User-Agent 可能来自多台设备,客户端升级也可能改变 User-Agent。</div>
<div><b>节点观测 IP</b><div class="table-wrap access-table"><table><thead><tr><th>IP</th><th>首次</th><th>最后</th></tr></thead><tbody>${ips || '<tr><td colspan="3">当前窗口没有 Agent 上报</td></tr>'}</tbody></table></div></div>
<div><b>最近订阅请求(最多 100 条)</b><div class="table-wrap access-table"><table><thead><tr><th>时间</th><th>结果</th><th>来源 / 客户端</th><th>格式</th><th>返回</th></tr></thead><tbody>${logs || '<tr><td colspan="5">暂无访问记录</td></tr>'}</tbody></table></div></div>
<div class="form-actions"><button class="danger" data-action="reset-access" data-id="${esc(customer.id)}">清空订阅访问窗口</button><button data-close>关闭</button></div></div>`);
}
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(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,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 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.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.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 [['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';
const result = new Date(`${date}T${time}:00`);
if (!Number.isFinite(result.getTime())) throw new Error('请选择有效的到期日期');
return result.toISOString();
}
document.addEventListener('submit', async (event) => {
event.preventDefault(); const form = event.target; const data = new FormData(form);
try {
if (form.id === 'login-form') {
$('#login-error').textContent = '';
state.session = await api('/api/auth/login', { method:'POST', body:JSON.stringify({ username:data.get('username'), password:data.get('password') }) });
showApp(); setPage(pageFromHash()); return;
}
if (form.id === 'server-form') {
const resourceId = data.get('resourceId');
const payload = { name:data.get('name'), role:data.get('role'), region:data.get('region'), publicAddress:data.get('publicAddress'), publicAddressV6:data.get('publicAddressV6'), tlsDomain:data.get('tlsDomain'), portRangeStart:Number(data.get('portRangeStart')), portRangeEnd:Number(data.get('portRangeEnd')), labels:splitList(data.get('labels')) };
await api(resourceId ? `/api/servers/${resourceId}` : '/api/servers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(resourceId ? '设备信息已更新' : '设备已添加'); await load('servers');
} else if (form.id === 'customer-form') {
const resourceId = data.get('resourceId');
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), trafficResetDay:Number(data.get('trafficResetDay') || 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 === 'node-form') {
const resourceId = data.get('resourceId');
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') }) });
toast('账号已更新,请重新登录'); $('#login-form [name="username"]').value = data.get('username'); showLogin();
}
} catch (error) { if (form.id === 'login-form') $('#login-error').textContent = error.message; else toast(error.message, true); }
});
document.addEventListener('click', async (event) => {
const theme = event.target.closest('[data-theme-toggle]'); if (theme) { setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); return; }
const nav = event.target.closest('[data-page]'); if (nav) { setPage(nav.dataset.page); return; }
const jump = event.target.closest('[data-page-jump]'); if (jump) { setPage(jump.dataset.pageJump); return; }
if (event.target.closest('[data-close]')) { $('#modal').close(); return; }
const button = event.target.closest('[data-action]'); if (!button) return;
const action = button.dataset.action; const itemId = button.dataset.id;
try {
if (action === 'add-server') serverForm();
else if (action === 'edit-server') serverForm(state.servers.find((item) => item.id === itemId));
else if (action === 'add-customer') customerForm();
else if (action === 'edit-customer') customerForm(state.customers.find((item) => item.id === itemId));
else if (action === 'customer-subscription') subscriptionModal(state.customers.find((item) => item.id === itemId));
else if (action === 'customer-access') await customerAccessModal(state.customers.find((item) => item.id === itemId));
else if (action === 'reset-access' && confirm('清空此客户的订阅访问日志和最近 24 小时的客户端估计数?节点 IP 观察记录不受影响。')) {
await api(`/api/customers/${itemId}/access`, { method:'DELETE' });
toast('订阅访问窗口已清空'); await load('customers'); await customerAccessModal(state.customers.find((item) => item.id === itemId));
}
else if (action === 'rotate-subscription' && confirm('旧订阅地址会立刻失效。已导入的节点凭据不会随订阅令牌重置,确认继续?')) {
const result = await api(`/api/customers/${itemId}/rotate-subscription`, { method:'POST', body:'{}' });
state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item);
subscriptionModal(result.customer); toast('订阅地址已重置');
}
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 || []; }
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://git.881218.xyz/rogue/NexusGate/raw/branch/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 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; }
const qr = qrcode(0, 'M'); qr.addData(button.dataset.value, 'Byte'); qr.make();
target.innerHTML = qr.createSvgTag(4, 4); target.hidden = false;
}
else if (action === 'agent-uninstall') {
const command = 'ng-agent uninstall';
const fallback = 'curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/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>`);
}
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') {
const item = state.servers.find((entry) => entry.id === itemId);
if (!item) return;
const name = prompt(`仅在服务器永久下线、销毁,或已手工清除其代理配置时使用。强制遗忘会放弃 ${item.pendingCleanup} 项远程清理;旧节点若仍运行,配置可能继续监听。\n\n请输入设备名称「${item.name}」确认:`);
if (name !== item.name) return;
await api(`/api/servers/${itemId}/forget`, { method:'POST', body:JSON.stringify({ confirm:'FORGET', name }) });
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 === '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-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 () => {
try {
if (!input.files[0] || !confirm('恢复会覆盖当前全部控制面数据,确认继续?')) return;
const backupData = JSON.parse(await input.files[0].text()); await api('/api/restore', { method:'POST', body:JSON.stringify({ confirm:'RESTORE', data:backupData }) }); location.reload();
} catch (error) { toast(error.message, true); }
}; input.click();
}
} catch (error) { toast(error.message, true); }
});
document.addEventListener('change', (event) => {
if (event.target.matches('[data-row-actions]') && event.target.value) {
const button = document.createElement('button'); button.dataset.action = event.target.value; button.dataset.id = event.target.dataset.id;
event.target.value = ''; button.hidden = true; document.body.append(button); button.click(); button.remove(); return;
}
if (event.target.closest('[data-picker-option]')) {
const picker = event.target.closest('[data-picker]');
$('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`;
}
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; }
}
if (event.target.matches('[data-expiry-quick]') && event.target.value) {
const target = new Date(); target.setDate(target.getDate() + Number(event.target.value));
const pad = (number) => String(number).padStart(2, '0');
event.target.form.elements.expiryDate.value = `${target.getFullYear()}-${pad(target.getMonth()+1)}-${pad(target.getDate())}`;
event.target.form.elements.expiryTime.value = '23:59';
}
});
document.addEventListener('input', (event) => {
if (event.target.matches('[data-picker-search]')) {
const picker = event.target.closest('[data-picker]'); const query = event.target.value.trim().toLowerCase();
$$('[data-picker-option]', picker).forEach((option) => { option.hidden = !option.dataset.query.includes(query); });
return;
}
if (event.target.matches('[data-search]')) { state.search = event.target.value; render(); const next = $('[data-search]'); next.focus(); next.setSelectionRange(next.value.length,next.value.length); }
});
function closeSidebar() { $('#app').classList.remove('sidebar-open'); }
$('#menu-toggle').addEventListener('click', () => $('#app').classList.add('sidebar-open'));
$('#sidebar-scrim').addEventListener('click', closeSidebar);
$('#refresh').addEventListener('click', () => load());
$('#logout').addEventListener('click', async () => { try { await api('/api/auth/logout', { method:'POST', body:'{}' }); } finally { showLogin(); } });
window.addEventListener('popstate', () => { const page = pageFromHash(); if (state.session && page !== state.page) setPage(page); });
window.addEventListener('hashchange', () => { const page = pageFromHash(); if (state.session && page !== state.page) setPage(page); });
(async function boot() {
let preferred = 'light'; try { preferred = localStorage.getItem('nexusgate-theme') || 'light'; } catch { /* storage may be disabled */ }
setTheme(preferred);
try {
const session = await api('/api/session');
if (!session.authenticated) return showLogin();
state.session = session; showApp(); setPage(pageFromHash());
} catch { showLogin(); }
setInterval(() => { if (state.session && document.visibilityState === 'visible' && !$('#modal').open) load(); }, 45000);
})();