GitHub accounts suspended; all first-party distribution now served from git.881218.xyz/rogue/NexusGate (Gitea): - raw: /rogue/NexusGate/raw/branch/<branch>/<path> - release assets and source archives under the same repo path - scripts gain a GIT_BASE variable built from NG_REPO (default rogue/NexusGate); NG_REPO/NG_BRANCH/--repo/--branch semantics unchanged - panel enrollment/uninstall commands in public/app.js point at Gitea raw - install.sh/nexusgate.sh/agent-singbox.sh stats source previously defaulted to the upstream repo (a2899882); defaults now this repo Upstream dependency references (sagernet/sing-box Go module) untouched.
633 lines
65 KiB
JavaScript
633 lines
65 KiB
JavaScript
'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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[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 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), 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);
|
||
})();
|