'use strict';
/* amo 控制台前端。五个页面:系统状态 / 节点管理 / 用户订阅 / 入站&出站 /
设置。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
const state = {
session: null, page: 'overview', overview: null, system: null,
servers: [], customers: [], nodes: [], nodeClients: [], jobs: [],
protocols: [], realityPresets: [], version: '0.6.8',
search: '', groupFilter: '', customerNodeFilter: '',
pages: { servers: 1, customers: 1, nodes: 1 },
addrVisible: false, expandedServers: new Set()
};
const PAGE_SIZE = 50;
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 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 fmtSpeed = (value) => {
const v = Math.round(Number(value || 0));
if (v <= 0) return '0 B/s';
if (v < 1024) return `${v} B/s`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(v >= 10240 ? 0 : 1)} KB/s`;
if (v < 1024 * 1024 * 1024) return `${(v / 1048576).toFixed(2)} MB/s`;
return `${(v / 1073741824).toFixed(2)} GB/s`;
};
const fmtUptime = (seconds) => {
const s = Math.max(0, Math.floor(Number(seconds || 0)));
const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
};
const relTime = (value) => {
if (!value) return '—';
const diff = Date.now() - new Date(value).getTime();
if (!Number.isFinite(diff)) return '—';
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
return fmtDate(value);
};
const versionParts = (v) => (String(v || '').match(/^(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number);
const versionLessThan = (a, b) => {
const pa = versionParts(a); const pb = versionParts(b);
if (pa.length < 3) return false;
for (let i = 0; i < 3; i += 1) { if (pa[i] !== pb[i]) return pa[i] < pb[i]; }
return false;
};
const oldUsageAgent = (version) => { const p = versionParts(version); return p.length === 3 && p[0] === 0 && (p[1] < 6 || (p[1] === 6 && p[2] < 3)); };
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 suspendReasonText = { traffic_limit: '额度耗尽', expired: '已到期', ip_limit: '节点 IP 超限', manual: '手动停用' };
const ICONS = {
qr: ' ',
info: ' ',
reset: ' ',
edit: ' ',
trash: ' ',
copy: ' ',
dots: ' ',
grip: ' ',
users: ' ',
eye: ' ',
eyeOff: ' ',
warn: ' '
};
async function copyText(text) {
text = String(text ?? '');
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
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 overlay = $('#modal-overlay');
const host = (overlay && !overlay.classList.contains('hidden')) ? overlay : document.body;
host.appendChild(ta);
ta.focus(); ta.select();
try { ta.setSelectionRange(0, ta.value.length); } catch (e) { /* noop */ }
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
ta.remove();
if (!ok) throw new Error('复制失败,请手动长按复制');
}
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 */ }
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = next === 'dark' ? '#1a1b1f' : '#f0f2f5';
const scheme = document.querySelector('meta[name="color-scheme"]');
if (scheme) scheme.content = next;
$$('[data-theme-toggle]').forEach((button) => { button.textContent = 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 && 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;
$('#version-label').textContent = `v${state.version}`;
}
/* ---------- 弹窗 ---------- */
function openModal(title, bodyHTML, wide = false) {
$('#modal-title').textContent = title;
$('#modal-body').innerHTML = bodyHTML;
$('.modal', $('#modal-overlay')).classList.toggle('wide', !!wide);
$('#modal-overlay').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeModal() {
$('#modal-overlay').classList.add('hidden');
$('#modal-body').innerHTML = '';
document.body.style.overflow = '';
}
function modalOpen() { return !$('#modal-overlay').classList.contains('hidden'); }
function confirmModal(message, confirmLabel = '确认') {
return new Promise((resolve) => {
openModal('请确认', `
${esc(message)}
取消 ${esc(confirmLabel)}
`);
$('[data-confirm-yes]').addEventListener('click', () => { closeModal(); resolve(true); });
$('[data-confirm-no]').addEventListener('click', () => { closeModal(); resolve(false); });
});
}
function promptModal(title, message, placeholder = '') {
return new Promise((resolve) => {
openModal(title, ``);
const input = $('[name="prompt-value"]'); input.focus();
$('[data-prompt-yes]').addEventListener('click', () => { const v = input.value; closeModal(); resolve(v); });
$('[data-prompt-no]').addEventListener('click', () => { closeModal(); resolve(null); });
});
}
/* ---------- body 浮层 ---------- */
function showPop(anchor, html) {
const pop = $('#float-pop');
pop.innerHTML = html;
pop.classList.remove('hidden');
const rect = anchor.getBoundingClientRect();
const width = pop.offsetWidth;
let left = Math.min(rect.left, window.innerWidth - width - 10);
if (left < 8) left = 8;
pop.style.left = `${left}px`;
pop.style.top = `${rect.bottom + 6}px`;
pop._anchor = anchor;
}
function hidePop() { const pop = $('#float-pop'); pop.classList.add('hidden'); pop.innerHTML = ''; pop._anchor = null; }
/* ---------- 图标悬停提示(data-tip,3x-ui 式气泡,挂 body 防裁剪) ---------- */
let tipBubble = null;
let tipTarget = null;
function showTip(el) {
const text = el.getAttribute('data-tip');
if (!text) return;
if (!tipBubble) {
tipBubble = document.createElement('div');
tipBubble.className = 'tip-bubble';
document.body.appendChild(tipBubble);
}
tipTarget = el;
tipBubble.textContent = text;
tipBubble.classList.add('show');
const rect = el.getBoundingClientRect();
const bw = tipBubble.offsetWidth;
const bh = tipBubble.offsetHeight;
let left = rect.left + rect.width / 2 - bw / 2;
left = Math.max(6, Math.min(left, window.innerWidth - bw - 6));
tipBubble.style.left = `${left}px`;
tipBubble.style.top = `${Math.max(4, rect.top - bh - 9)}px`;
tipBubble.style.setProperty('--tip-arrow-left', `${rect.left + rect.width / 2 - left}px`);
}
function hideTip() { tipTarget = null; if (tipBubble) tipBubble.classList.remove('show'); }
document.addEventListener('mouseover', (event) => {
const el = event.target && event.target.closest ? event.target.closest('[data-tip]') : null;
if (el === tipTarget) return;
if (el) showTip(el); else hideTip();
});
document.addEventListener('mousedown', hideTip);
window.addEventListener('scroll', hideTip, true);
/* ---------- 分页 ---------- */
function pageSlice(items, key) {
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
if (state.pages[key] > totalPages) state.pages[key] = totalPages;
const page = state.pages[key];
return { pageItems: items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), page, totalPages };
}
function pagerHTML(key, total) {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(state.pages[key], totalPages);
const nums = [];
for (let i = 1; i <= totalPages; i += 1) {
if (i === 1 || i === totalPages || Math.abs(i - page) <= 2) nums.push(i);
else if (nums[nums.length - 1] !== '…') nums.push('…');
}
return ``;
}
/* ---------- 数据加载与路由 ---------- */
async function load(page = state.page) {
try {
if (page === 'overview') {
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
state.overview = overview; state.system = system; state.version = overview.version || state.version;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
$('#version-label').textContent = `v${state.version}`;
} else if (page === 'servers') {
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
state.customers = customers.customers; state.customerSummary = customers.summary || null; 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.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
} else if (page === 'operations') {
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
$('#version-label').textContent = `v${state.version}`;
}
render();
} catch (error) { toast(error.message, true); }
}
const PAGES = ['overview', 'servers', 'customers', 'chains', 'operations'];
const TITLES = { overview: '系统状态', servers: '节点管理', customers: '用户订阅', chains: '入站&出站', operations: '设置' };
function pageFromHash() { const page = location.hash.replace(/^#/, '').split('?')[0]; return PAGES.includes(page) ? page : 'overview'; }
function setPage(page) {
if (!PAGES.includes(page)) page = 'overview';
state.page = page; state.search = ''; state.groupFilter = '';
if (page !== 'customers') state.customerNodeFilter = '';
state.pages = { servers: 1, customers: 1, nodes: 1 };
hidePop();
if (location.hash !== `#${page}`) history.pushState(null, '', `#${page}`);
$$('#nav .mi').forEach((item) => item.classList.toggle('active', item.dataset.page === page));
$('#page-title').textContent = TITLES[page];
load(page);
}
function emptyRow(colspan, title) { return `${esc(title)}
`; }
function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; }
function nodeById(id) { return state.nodes.find((item) => item.id === id); }
function customerById(id) { return state.customers.find((item) => item.id === id); }
function serverById(id) { return state.servers.find((item) => item.id === id); }
/* ---------- SVG 走势线 ---------- */
function sparkSVG(values, id, fixedMax = null) {
const pts = (values || []).filter((v) => v != null && Number.isFinite(v));
if (pts.length < 2) return '
';
const max = fixedMax || Math.max(...pts, 1);
const min = fixedMax ? 0 : Math.min(...pts, 0);
const span = (max - min) || 1;
const stepX = 260 / (pts.length - 1);
const coords = pts.map((v, i) => `${(i * stepX).toFixed(1)},${(42 - ((v - min) / span) * 38).toFixed(1)}`);
const line = `M${coords.join(' L')}`;
const mean = pts.reduce((a, b) => a + b, 0) / pts.length;
const meanY = (42 - ((mean - min) / span) * 38).toFixed(1);
return `
`;
}
function dualChartSVG(history) {
const pts = history || [];
if (pts.length < 2) return '
';
const max = Math.max(1, ...pts.map((p) => Math.max(p.upBps || 0, p.downBps || 0)));
const stepX = 880 / (pts.length - 1);
const line = (key) => `M${pts.map((p, i) => `${(i * stepX).toFixed(1)},${(228 - ((p[key] || 0) / max) * 200).toFixed(1)}`).join(' L')}`;
const upLine = line('upBps');
return `
`;
}
function connChartSVG(history) {
const pts = history || [];
if (pts.length < 2) return '
';
const values = pts.map((p) => p.connections || 0);
const max = Math.max(...values, 1);
const stepX = 420 / (pts.length - 1);
const line = `M${values.map((v, i) => `${(i * stepX).toFixed(1)},${(170 - (v / max) * 140).toFixed(1)}`).join(' L')}`;
return `
`;
}
/* ---------- 系统状态 ---------- */
function renderOverview() {
const sys = state.system;
if (!sys) return '暂无数据
';
const host = sys.host || {}; const net = sys.network || {}; const history = sys.history || [];
const cpuHist = history.map((p) => p.cpu); const memHist = history.map((p) => p.mem);
const trafHist = history.map((p) => (p.upBps || 0) + (p.downBps || 0));
const statCard = (icon, label, scope, big, unit, det, mm, spark) => `
${icon}${esc(label)}${esc(scope)}
${big}${esc(unit)}
${det}
${mm}
${spark} `;
const gaugeIcon = ' ';
const memIcon = ' ';
const diskIcon = ' ';
const trafIcon = ' ';
const cpuVals = cpuHist.filter((v) => v != null);
const cpuMm = cpuVals.length ? `均值 ${(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length).toFixed(0)}% 峰值 ${Math.max(...cpuVals).toFixed(0)}% ` : '— ';
const memVals = memHist.filter((v) => v != null);
const memMm = memVals.length ? `均值 ${(memVals.reduce((a, b) => a + b, 0) / memVals.length).toFixed(0)}% 峰值 ${Math.max(...memVals).toFixed(0)}% ` : '— ';
const todayTotal = (sys.today.upBytes || 0) + (sys.today.downBytes || 0);
const peakSpeed = trafHist.length ? Math.max(...trafHist) : 0;
const cards = [
statCard(gaugeIcon, 'CPU', '主控', host.cpuPercent == null ? '—' : host.cpuPercent.toFixed(1), '%', `${host.cpuCount || '—'} 核 · 负载 ${host.load1 != null ? Number(host.load1).toFixed(2) : '—'}`, cpuMm, sparkSVG(cpuHist, 'gCpu', 100)),
statCard(memIcon, '内存', '主控', host.memPercent == null ? '—' : host.memPercent.toFixed(1), '%', `${fmtBytes(host.memUsed)} / ${fmtBytes(host.memTotal)}`, memMm, sparkSVG(memHist, 'gMem', 100)),
statCard(diskIcon, '存储', '主控', host.diskPercent == null ? '—' : host.diskPercent.toFixed(1), '%', `${fmtBytes(host.diskUsed)} / ${fmtBytes(host.diskTotal)}`, `剩余 ${fmtBytes((host.diskTotal || 0) - (host.diskUsed || 0))} `, sparkSVG(history.map(() => host.diskPercent), 'gDisk', 100)),
statCard(trafIcon, '今日流量', '全网汇总', (() => { const [v, u] = fmtBytes(todayTotal).split(' '); return v; })(), (() => { const p = fmtBytes(todayTotal).split(' '); return p[1] || ''; })(), `↑ ${fmtBytes(sys.today.upBytes)} · ↓ ${fmtBytes(sys.today.downBytes)}`, `累计 ${fmtBytes((sys.totals.upBytes || 0) + (sys.totals.downBytes || 0))} 峰值 ${fmtSpeed(peakSpeed)} `, sparkSVG(trafHist, 'gTraf'))
].join('');
const meanUp = history.length ? history.reduce((a, p) => a + (p.upBps || 0), 0) / history.length : 0;
const meanDown = history.length ? history.reduce((a, p) => a + (p.downBps || 0), 0) / history.length : 0;
const degraded = (state.overview && state.overview.degraded) || [];
return `
整体速度
↑ 上传 ${fmtSpeed(net.upBps)}↓ 下载 ${fmtSpeed(net.downBps)}
${dualChartSVG(history)}
已发送 ${fmtBytes(sys.totals.upBytes)}
已接收 ${fmtBytes(sys.totals.downBytes)}
窗口均值 ↑ ${fmtSpeed(meanUp)} ↓ ${fmtSpeed(meanDown)}
${net.connections || 0}在线客户
在线节点${net.onlineNodes || 0} / ${net.nodesTotal || 0}
客户${net.customersTotal || 0}
${connChartSVG(history)}
面板 ${fmtUptime(sys.panelUptimeSec)}
系统 ${fmtUptime(sys.hostUptimeSec)}
内存 ${fmtBytes(sys.panelMemBytes)}
在线节点 ${net.onlineNodes || 0} / ${net.nodesTotal || 0}
客户 ${net.customersTotal || 0}
IPv4 ${esc((sys.ips && sys.ips.ipv4) || '—')}
IPv6 ${esc((sys.ips && sys.ips.ipv6) || '—')}
${degraded.length ? `${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「入站&出站」查看并修复。
` : ''}`;
}
/* ---------- 服务器 ---------- */
function maskAddr(addr) {
const value = String(addr || '');
if (!value) return '—';
if (/^\d+\.\d+\.\d+\.\d+$/.test(value)) { const p = value.split('.'); return `${p[0]}.${p[1]}.*.*`; }
const parts = value.split('.');
if (parts.length >= 3) return `${parts[0]}.****.${parts[parts.length - 1]}`;
if (parts.length === 2) return `${parts[0].slice(0, 2)}****.${parts[1]}`;
return value;
}
function serverClients(serverId) {
const serverNodes = state.nodes.filter((n) => n.serverId === serverId);
const ids = new Set(serverNodes.map((n) => n.id));
const total = state.nodeClients.filter((nc) => ids.has(nc.nodeId)).length;
const online = new Set();
for (const node of serverNodes) for (const id of node.onlineCustomerIds || []) online.add(id);
return { total, online: online.size };
}
function serverDetailRow(server) {
const system = server.system || {};
const memAvail = Number(system.memoryAvailable ?? system.memoryFree);
const resources = Number.isFinite(system.memoryTotal)
? `内存可用 ${fmtBytes(memAvail)} / ${fmtBytes(system.memoryTotal)} · 负载 ${system.load1 ?? '—'} / ${system.cpuCount ?? '—'} 核${Number.isFinite(system.diskAvailable) ? ` · 系统盘可用 ${fmtBytes(system.diskAvailable)} / ${fmtBytes(system.diskTotal)}` : ''}${memAvail < 200 * 1024 * 1024 || (Number.isFinite(system.diskAvailable) && system.diskAvailable < 1024 ** 3) ? ' · 资源余量偏低' : ''}`
: '—';
const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置';
const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报');
const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · 异常:${esc(server.usage.error)} ` : ''}` : '尚无上报';
return `
资源余量 ${resources}
TLS 证书(${esc(server.tlsDomain || '—')}) ${tls}
引擎状态 ${engine}
流量统计 ${usage}
系统 ${esc([system.hostname, system.platform, system.release, system.arch].filter(Boolean).join(' · ') || '—')}
用途 / 地区 / 标签 ${esc(roleText[server.role] || server.role)} · ${esc(server.region || '未分组')} · ${esc((server.labels || []).join('、') || '—')}
${server.engine && server.engine.status === 'error' ? `
引擎故障 ${esc(server.engine.detail || '')}
` : ''}
${oldUsageAgent(server.agentVersion) ? '
Agent 旧版流量采集有问题,请在该机运行 ng-agent update
' : ''}
${server.pendingCleanup ? `重试清理(${server.pendingCleanup}) ` : ''}
${server.pendingCleanup && server.status !== 'online' ? `遗忘离线设备 ` : ''}
SSH 卸载 Agent
`;
}
function renderServers() {
const q = state.search.toLowerCase();
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
const stats = `
离线
${state.servers.length - onlineCount}
`;
const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q));
const { pageItems } = pageSlice(filtered, 'servers');
const rows = pageItems.map((server) => {
const system = server.system || {};
const cpuPct = Number.isFinite(system.load1) && system.cpuCount ? Math.min(100, (system.load1 / system.cpuCount) * 100) : null;
const memPct = Number.isFinite(system.memoryTotal) && system.memoryTotal > 0 ? (1 - Number(system.memoryAvailable ?? system.memoryFree) / system.memoryTotal) * 100 : null;
const clients = serverClients(server.id);
const expanded = state.expandedServers.has(server.id);
const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
const row = `
${expanded ? '−' : '+'}
${ICONS.edit} ${ICONS.copy} ${ICONS.trash}
${esc(server.name)}
${addr}
${server.status === 'online' ? ' 在线 ' : ' 离线 '}${server.pendingCleanup ? ` ${ICONS.warn}` : ''}
${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}
${memPct == null ? '—' : `${memPct.toFixed(1)}%`}
${esc(server.agentVersion || '—')} ${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '有可用更新 ' : ''}
${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')}
${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}
${ICONS.users}${clients.total}${clients.online}
${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'} `;
return expanded ? row + serverDetailRow(server) : row;
}).join('');
return `${stats}
+ 添加设备
${pagerHTML('servers', filtered.length)} `;
}
/* ---------- 用户订阅 ---------- */
function protocolFamily(protocol) {
const id = String(protocol || '');
if (id.startsWith('vless')) return { cls: '', label: 'VLESS' };
if (id.startsWith('shadowsocks')) return { cls: 'ss', label: 'SS' };
if (id.startsWith('socks')) return { cls: 'mixed', label: 'Mixed' };
if (id.startsWith('hysteria2')) return { cls: '', label: 'HY2' };
if (id.startsWith('anytls')) return { cls: '', label: 'AnyTLS' };
return { cls: '', label: id || '—' };
}
function customerBindings(customerId) {
return state.nodeClients.filter((nc) => nc.customerId === customerId)
.slice().sort((a, b) => ((nodeById(a.nodeId) || {}).sortIndex ?? 1e9) - ((nodeById(b.nodeId) || {}).sortIndex ?? 1e9));
}
function trafficPill(item) {
const used = fmtBytes(item.usedBytes);
if (!item.trafficLimitBytes) return `${used} / 不限 `;
const pct = Math.min(100, (item.usedBytes / item.trafficLimitBytes) * 100);
const cls = pct >= 90 ? ' danger' : pct >= 70 ? ' warn' : '';
return `${used} ${fmtBytes(item.trafficLimitBytes)} `;
}
function remainingPill(item) {
if (!item.trafficLimitBytes) return '不限 ';
const left = item.trafficLimitBytes - item.usedBytes;
if (left <= 0) return '0 B ';
return `${fmtBytes(left)} `;
}
function durationPill(item) {
if (!item.expiresAt) return '不限期 ';
const diff = new Date(item.expiresAt).getTime() - Date.now();
if (diff <= 0) return '已过期 ';
const days = Math.floor(diff / 86400000);
if (days >= 1) return `${days}天后 `;
const hours = Math.max(1, Math.floor(diff / 3600000));
return `${hours}小时后 `;
}
function renderCustomers() {
const q = state.search.toLowerCase();
const groups = [...new Set(state.customers.map((c) => c.group).filter(Boolean))];
let filtered = state.customers.filter((item) => {
if (state.groupFilter && item.group !== state.groupFilter) return false;
if (state.customerNodeFilter && !state.nodeClients.some((nc) => nc.customerId === item.id && nc.nodeId === state.customerNodeFilter)) return false;
return [item.name, item.group, item.notes, ...(item.tags || [])].join(' ').toLowerCase().includes(q);
});
const { pageItems } = pageSlice(filtered, 'customers');
const filterNode = state.customerNodeFilter ? nodeById(state.customerNodeFilter) : null;
const rows = pageItems.map((item) => {
const bindings = customerBindings(item.id);
const first = bindings[0];
const firstNode = first ? nodeById(first.nodeId) : null;
const fam = firstNode ? protocolFamily(firstNode.protocol) : null;
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
return `
${ICONS.qr} ${ICONS.info} ${ICONS.reset} ${ICONS.edit} ${ICONS.trash}
${item.online ? '在线' : '离线'} ${item.status === 'suspended' ? `已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')} ` : ''}
${esc(item.name)} ${item.notes ? `${esc(item.notes)} ` : ' '}
${firstNode ? `${esc(firstNode.name)} ` : '— '}${bindings.length > 1 ? `+${bindings.length - 1} ` : ''}
${trafficPill(item)}
${speedTotal > 0 ? `${fmtSpeed(speedTotal)} ` : '— '}
${remainingPill(item)}
${durationPill(item)} `;
}).join('');
const sum = state.customerSummary;
const statCell = (lab, cls, svg, val) => ``;
const custStats = sum ? `
${statCell('客户端', 'ic-dim', ' ', sum.total)}
${statCell('在线', 'ic-green', ' ', sum.online)}
${statCell('耗尽', 'ic-red', ' ', sum.depleted)}
${statCell('即将耗尽', 'ic-bolt', ' ', sum.depletingSoon)}
${statCell('关闭', 'ic-red', ' ', sum.disabled)}
${statCell('启用', 'ic-green', ' ', sum.enabled)}
` : '';
return `${custStats}
操作 已启用 在线 客户端 关联入站 流量⇅ 速度 剩余⇅ 时长⇅
${rows || emptyRow(9, '还没有客户')}
${pagerHTML('customers', filtered.length)} `;
}
function localDateParts(value) {
if (!value) return { date: '', time: '23:59' };
const date = new Date(value); const pad = (n) => String(n).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) => `${value} `).join('');
}
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();
}
/* 客户表单(三页签:基本 / 凭据 / 链接)。关联入站多选、凭据、外部
链接与订阅源都随同一个保存动作提交。 */
const customerFormState = { pickerSelected: new Set(), manualLinks: [], externalSources: [], credentials: null };
function pickerHTML(selectedIds) {
const chips = [...selectedIds].map((id) => {
const node = nodeById(id);
return node ? `${esc(node.name)}× ` : '';
}).join('');
return ``;
}
function renderPickerList(filter = '') {
const list = $('[data-picker-list]');
if (!list) return;
const q = filter.trim().toLowerCase();
list.innerHTML = state.nodes.filter((node) => node.name.toLowerCase().includes(q)).map((node) => {
const server = serverById(node.serverId);
const on = customerFormState.pickerSelected.has(node.id);
return `${esc(node.name)} ${esc(server ? server.name : '')} · ${esc(statusText[node.status] || node.status)} ✓
`;
}).join('') || '无匹配节点
';
}
function refreshPicker() {
const box = $('[data-picker-box]');
if (!box) return;
const chips = [...customerFormState.pickerSelected].map((id) => {
const node = nodeById(id);
return node ? `${esc(node.name)}× ` : '';
}).join('');
box.innerHTML = `${chips || '选择节点 '} `;
renderPickerList($('[data-picker-search]') ? $('[data-picker-search]').value : '');
}
function credRowHTML(label, value) {
return `${esc(label)} ${esc(value || '—')}
${value ? `${ICONS.copy} ` : ''}
`;
}
function renderCredentialsPane(customer) {
const pane = $('#tab-credentials');
if (!pane) return;
if (!customer) { pane.innerHTML = '创建客户后生成凭据
'; return; }
const data = customerFormState.credentials;
if (!data) { pane.innerHTML = '加载中…
'; return; }
const creds = data.credentials || {};
// Round-3 item 15: the per-binding list (node name / status / protocol
// / Reality public key) is gone — the tab shows only the subscription
// token and the one shared credential set. Reality keys live on the
// node edit form now (item 17); the socks username lives on the node
// (item 21). Backend payload unchanged.
pane.innerHTML = `订阅
订阅令牌 ${esc(data.subscriptionToken || '—')}
${ICONS.copy}
${ICONS.reset}
凭据(全部节点共用)${ICONS.reset}
${credRowHTML('UUID', creds.clientId)}
${credRowHTML('密码', creds.password)}
${credRowHTML('Reality Short ID', creds.shortId)}
`;
}
function linkStatusLine(customer, source) {
const status = customer && customer.externalStatus ? customer.externalStatus[source.id] : null;
if (!status) return '尚未拉取';
const parts = [`${status.count || 0} 条`];
if (status.okAt) parts.push(`上次成功 ${relTime(status.okAt)}`);
if (status.droppedCount) parts.push(`已忽略 ${status.droppedCount} 条`);
if (status.lastError) parts.push(`${esc(status.lastError)} `);
return parts.join(' · ');
}
function renderLinksPane(customer) {
const pane = $('#tab-links');
if (!pane) return;
const manual = customerFormState.manualLinks.map((link, index) => ``).join('');
const sources = customerFormState.externalSources.map((source, index) => ``).join('');
pane.innerHTML = `添加第三方分享链接和远程订阅地址,将其包含在该客户的订阅中。
外部链接 + 添加外部链接
外部订阅 + 添加外部订阅
`;
}
function collectLinksFromPane() {
customerFormState.manualLinks = $$('[data-link-item]').map((el) => ({
id: (customerFormState.manualLinks[Number(el.dataset.linkItem)] || {}).id,
uri: $('[data-link-uri]', el).value.trim(),
remark: $('[data-link-remark]', el).value.trim(),
enabled: $('[data-link-enabled]', el).checked
})).filter((link) => link.uri || link.remark);
customerFormState.externalSources = $$('[data-source-item]').map((el) => ({
id: (customerFormState.externalSources[Number(el.dataset.sourceItem)] || {}).id,
url: $('[data-source-url]', el).value.trim(),
remark: $('[data-source-remark]', el).value.trim(),
userAgent: $('[data-source-ua]', el).value.trim(),
enabled: $('[data-source-enabled]', el).checked
})).filter((source) => source.url || source.remark);
}
function customerForm(item = null) {
const customer = item || { trafficLimitBytes: 0, ipLimit: 2, deviceLimit: 3, tags: [] };
const expiry = localDateParts(customer.expiresAt);
customerFormState.pickerSelected = new Set(item ? customerBindings(item.id).map((nc) => nc.nodeId) : []);
customerFormState.manualLinks = (item && item.manualLinks || []).map((l) => ({ ...l }));
customerFormState.externalSources = (item && item.externalSources || []).map((s) => ({ ...s }));
customerFormState.credentials = null;
const manualOff = item && item.status === 'suspended' && item.suspendReason === 'manual';
openModal(item ? '编辑客户' : '添加客户', ``, true);
renderPickerList();
renderCredentialsPane(item);
renderLinksPane(item);
if (item) {
api(`/api/customers/${item.id}/credentials`).then((data) => {
customerFormState.credentials = data;
renderCredentialsPane(item);
}).catch((error) => { const pane = $('#tab-credentials'); if (pane) pane.innerHTML = `${esc(error.message)}
`; });
}
const modeSelect = $('[name="trafficResetMode"]');
const dayInput = $('[name="trafficResetDay"]');
const syncReset = () => { dayInput.disabled = modeSelect.value !== '1'; };
modeSelect.addEventListener('change', syncReset); syncReset();
}
/* ---------- 订阅弹窗 ---------- */
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 autoUrl = `${location.origin}/s/${customer.subscriptionToken}/auto`;
const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => {
const node = nodeById(nc.nodeId);
if (!node || node.status !== 'active' || !nc.clientUri) return '';
const server = serverById(node.serverId);
const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`;
return `${esc(label)} ${esc(nc.clientUri.slice(0, 60))}...
复制 二维码
`;
}).join('');
openModal(`${customer.name} · 订阅`, `
${formats.map(([format, label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)} ${esc(url)}
复制 二维码
`; }).join('')}
${nodeLinks ? `
各节点入站链接 ${nodeLinks}` : ''}
重置订阅地址 关闭
`, true);
const mainQr = $('[data-main-qr]');
if (mainQr && typeof qrcode === 'function') {
const qr = qrcode(0, 'M'); qr.addData(autoUrl, 'Byte'); qr.make();
mainQr.innerHTML = qr.createSvgTag(4, 4);
}
}
/* ---------- 客户详情(访问记录) ---------- */
async function customerAccessModal(customer) {
const data = await api(`/api/customers/${customer.id}/access`);
const ips = data.observedIps.map((entry) => `${esc(entry.ip)}${fmtDate(entry.firstSeenAt)} ${fmtDate(entry.lastSeenAt)} `).join('');
const logs = data.events.map((entry) => `${fmtDate(entry.at)} ${entry.status === 200 ? '已返回 ' : `${entry.status} ${esc(entry.reason || '')} `} ${esc(entry.ip)}${esc(entry.userAgent || '未提供 User-Agent')}
${esc(entry.format)} ${fmtBytes(entry.bytes)} `).join('');
openModal(`${customer.name} · 详情`, `
状态 ${esc(statusText[customer.status] || customer.status)}${customer.status === 'suspended' ? ` · ${esc(suspendReasonText[customer.suspendReason] || '')}` : ''}
分组 / 标签 ${esc(customer.group || '—')} · ${esc((customer.tags || []).join('、') || '—')}
流量 ${fmtBytes(customer.usedBytes)} / ${customer.trafficLimitBytes ? fmtBytes(customer.trafficLimitBytes) : '不限'}(上行 ${fmtBytes(customer.usedUplinkBytes)} · 下行 ${fmtBytes(customer.usedDownlinkBytes)})
到期 ${customer.expiresAt ? fmtDate(customer.expiresAt) : '不限期'}
使用限制 节点 IP ${data.observedIps.length} / ${data.limits.ip || '不限'} · 订阅客户端约 ${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}
最近活跃 ${customer.lastActiveAt ? relTime(customer.lastActiveAt) : '—'}
节点观测 IP IP 首次 最后 ${ips || '当前窗口没有 Agent 上报 '}
最近订阅请求(最多 100 条) 时间 结果 来源 / 客户端 格式 返回 ${logs || '暂无访问记录 '}
清空订阅访问窗口 关闭
`, true);
}
/* ---------- 服务器表单与维护弹窗 ---------- */
function serverForm(item = null) {
const server = item || { role: 'hybrid', portRangeStart: 20000, portRangeEnd: 50000, labels: [] };
openModal(item ? '编辑设备' : '添加设备', `
设备名称
用途入口 / 转发 出口 / 落地 综合节点
地区 / 分组
公网 IPv4 / 域名
公网 IPv6(可选)
标签(逗号分隔)
节点 TLS 域名
可用端口起点
可用端口终点
取消 ${item ? '保存修改' : '添加设备'}
`);
}
function enrollModal(serverId) {
return api(`/api/servers/${serverId}/enrollment-token`, { method: 'POST', body: '{}' }).then((result) => {
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}`;
openModal('Agent 注册 / 重装命令', `
令牌 30 分钟内有效且只能使用一次。
${esc(command)}
复制命令 关闭
`);
});
}
function uninstallModal() {
const command = 'ng-agent uninstall';
const fallback = 'curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/main/scripts/agent-uninstall.sh | bash';
openModal('在目标服务器 SSH 卸载 Agent', `
先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止专属服务并移除密钥与节点配置;之后再删除设备登记。
${esc(command)}
复制命令
${esc(fallback)}
复制兼容命令(旧版 Agent)
关闭
`);
}
/* ---------- 入站&出站 ---------- */
function protoBadges(node) {
const fam = (id) => {
const v = String(id || '');
if (v.startsWith('vless')) return ['vless', 'VLESS'];
if (v.startsWith('shadowsocks')) return ['ss', 'Shadowsocks'];
if (v.startsWith('socks')) return ['mixed', 'Mixed'];
if (v.startsWith('hysteria2')) return ['other', 'Hysteria2'];
if (v.startsWith('anytls')) return ['other', 'AnyTLS'];
return ['other', v || '—'];
};
const [cls, label] = fam(node.protocol);
const trans = node.protocol.includes('ws') ? 'WS' : node.protocol.startsWith('hysteria2') ? 'UDP' : node.protocol.startsWith('shadowsocks') ? 'TCP,UDP' : 'TCP';
const sec = /reality/.test(node.protocol) ? 'Reality ' : /(tls|hysteria2|anytls)/.test(node.protocol) ? 'TLS ' : '';
let html = `${esc(label)} ${trans} ${sec}`;
if ((node.topology || 'forward') === 'forward' && node.exitProtocol) {
const [eCls, eLabel] = fam(node.exitProtocol);
html += `${esc(eLabel)} `;
}
return html;
}
function renderNodes() {
const q = state.search.toLowerCase();
const filtered = state.nodes.filter((node) => [node.name, node.protocol, (serverById(node.serverId) || {}).name].join(' ').toLowerCase().includes(q));
const { pageItems } = pageSlice(filtered, 'nodes');
const active = state.nodes.filter((n) => n.status === 'active').length;
const degraded = state.nodes.filter((n) => ['degraded', 'failed'].includes(n.status)).length;
const rows = pageItems.map((node, index) => {
const server = serverById(node.serverId);
const chipCls = !server || server.status !== 'online' ? 'offline' : (node.status === 'degraded' || (server.engine && server.engine.status === 'error')) ? 'degraded' : 'online';
const total = node.clientCount != null ? node.clientCount : state.nodeClients.filter((nc) => nc.nodeId === node.id).length;
const onlineCount = node.onlineCount || 0;
const onlineNames = (node.onlineCustomerIds || []).map((id) => (customerById(id) || {}).name).filter(Boolean);
const speed = (node.speedUpBps || 0) + (node.speedDownBps || 0);
const metered = node.meteredTraffic ? (node.meteredTraffic.uplink || 0) + (node.meteredTraffic.downlink || 0) : 0;
const enabled = !['draft', 'removing'].includes(node.status);
const busy = ['deploying', 'removing'].includes(node.status);
return `
${ICONS.grip}
${(node.sortIndex != null ? node.sortIndex : index) + 1}
${esc(node.name)} ${node.error ? `${esc(String(node.error).slice(0, 40))}
` : ''}
${esc(server ? server.name : '已删除')}
${node.port || '—'}
${protoBadges(node)}
${ICONS.users}${total} ${onlineCount}
${fmtBytes(metered)} / ∞
${speed > 0 ? `↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)} ` : '— '}
∞ `;
}).join('');
return `
${pagerHTML('nodes', filtered.length)} `;
}
function protocolOptions(role, current) {
return state.protocols.filter((entry) => entry.role === role && entry.deployable).map((entry) => `${esc(entry.name)}${entry.status === 'beta' ? ' · 测试' : ''} `).join('');
}
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' };
openModal(item ? '编辑节点' : '新建节点', `
节点名称
服务器${state.servers.map((server) => `${esc(server.name)} · ${esc(roleText[server.role])} `).join('')}
网络栈IPv4 IPv6 双栈监听
入口协议${protocolOptions('relay-ingress', node.protocol)}
入口端口范围内随机 固定端口
Reality 目标预设${state.realityPresets.map((entry) => `${esc(entry.label)} · ${esc(entry.serverName)} `).join('')}自定义 查找目标
Reality SNI / 目标端口
${item ? '服务器 Reality 密钥对(主控托管)
加载中…
' : ''}
SOCKS5 固定用户名${ICONS.copy}
SOCKS5 固定密码${ICONS.copy}
拓扑转发(入口 → 出口) 单机直连(无需出口)
出口与转发传输
从链接导入
出口 / 落地设备${state.servers.map((server) => `${esc(server.name)} · ${esc(roleText[server.role])} `).join('')}
落地地址
入口到出口协议${protocolOptions('exit-transport', node.exitProtocol)}
出口端口范围内随机 固定端口
VLESS 用户 ID
SOCKS5 用户名
落地密码
取消 ${item ? '保存修改' : '创建节点'}
`, true);
syncNodeForm();
// Round-3 item 17: show the server's panel-hosted Reality keypair on
// existing Reality nodes (display only — no rotation action here).
if (item && ['vless-reality-vision', 'vless-reality'].includes(node.protocol)) {
api(`/api/nodes/${item.id}/reality-keys`).then((data) => {
const box = $('#reality-keys-box'); if (!box) return;
box.innerHTML = `服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)
${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '尚未生成(首次部署时由主控生成)
'}`;
}).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '密钥读取失败
'; });
}
}
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 socks = form.elements.protocol.value === 'socks5-auth';
$$('[data-socks-only]', form).forEach((element) => element.classList.toggle('hidden', !socks));
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;
}
// Manual landing endpoint (round-2 item 8): swap the managed exit-server
// picker for address + credential fields; the port is always explicit.
const exitManual = !direct && (form.dataset.exitMode || 'managed') === 'manual';
$$('[data-managed-only]', form).forEach((element) => element.classList.toggle('hidden', direct || exitManual));
$$('[data-manual-only]', form).forEach((element) => element.classList.toggle('hidden', !exitManual));
$$('#exit-mode-seg button', form).forEach((button) => button.classList.toggle('active', button.dataset.mode === (form.dataset.exitMode || 'managed')));
form.elements.exitServerId.required = !direct && !exitManual;
if (form.elements.exitAddress) form.elements.exitAddress.required = exitManual;
const exitProtocol = form.elements.exitProtocol.value || '';
const credFor = { vless: exitProtocol === 'vless-tcp', socksuser: exitProtocol === 'socks5-auth', password: exitProtocol === 'socks5-auth' || exitProtocol.startsWith('shadowsocks') };
$$('[data-exit-cred]', form).forEach((element) => element.classList.toggle('hidden', !exitManual || !credFor[element.dataset.exitCred]));
if (exitManual) {
form.elements.exitPortMode.disabled = true;
form.elements.exitPort.disabled = false;
form.elements.exitPort.required = true;
}
}
const EXIT_LINK_METHODS = { '2022-blake3-aes-128-gcm': 'shadowsocks-2022-aes128', '2022-blake3-aes-256-gcm': 'shadowsocks-2022-aes256', 'aes-128-gcm': 'shadowsocks-aes128-gcm', 'aes-256-gcm': 'shadowsocks-aes256-gcm' };
function parseExitLink(text) {
const link = String(text || '').trim();
if (!link) return null;
try {
if (link.startsWith('vless://')) {
const url = new URL(link);
if (!url.hostname || !url.port || !url.username) return null;
return { protocol: 'vless-tcp', address: url.hostname, port: Number(url.port), clientId: decodeURIComponent(url.username) };
}
if (link.startsWith('ss://')) {
const rest = link.slice(5).split('#')[0];
const at = rest.lastIndexOf('@');
if (at < 0) return null;
let userinfo = rest.slice(0, at);
const hostport = rest.slice(at + 1).split('?')[0].split('/')[0];
if (!userinfo.includes(':')) {
userinfo = atob(userinfo.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(userinfo.length / 4) * 4, '='));
} else {
userinfo = decodeURIComponent(userinfo);
}
const sep = userinfo.indexOf(':');
if (sep < 0) return null;
const protocol = EXIT_LINK_METHODS[userinfo.slice(0, sep)];
if (!protocol) return null;
const hostMatch = hostport.match(/^\[(.+)\]:(\d+)$/) || hostport.match(/^(.+):(\d+)$/);
if (!hostMatch) return null;
// The password is everything after "method:" — for SS2022 links that
// is the combined serverKey:userKey string, which is exactly what the
// entry outbound sends.
return { protocol, address: hostMatch[1], port: Number(hostMatch[2]), password: userinfo.slice(sep + 1) };
}
if (link.startsWith('socks5://') || link.startsWith('socks://')) {
const url = new URL(link.replace(/^socks:\/\//, 'socks5://'));
if (!url.hostname || !url.port) return null;
return { protocol: 'socks5-auth', address: url.hostname, port: Number(url.port), socksUser: decodeURIComponent(url.username), password: decodeURIComponent(url.password) };
}
} catch { return null; }
return null;
}
function applyExitLink(input) {
const form = $('#node-form'); if (!form) return;
const parsed = parseExitLink(input.value);
if (!parsed) { toast('无法识别该链接', true); return; }
form.dataset.exitMode = 'manual';
form.elements.exitAddress.value = parsed.address;
form.elements.exitPort.value = parsed.port;
form.elements.exitProtocol.value = parsed.protocol;
if (parsed.clientId) form.elements.exitClientId.value = parsed.clientId;
if (parsed.socksUser) form.elements.exitSocksUser.value = parsed.socksUser;
if (parsed.password) form.elements.exitPassword.value = parsed.password;
syncNodeForm();
toast('已从链接填入落地端点');
}
/* Reality 目标扫描器(第 16 条):叠加在节点表单之上的第二层弹窗,
主控侧真实 TLS 握手探测;「使用」把目标回填到表单并关闭。 */
function closeRealityScanner() { const overlay = $('#scan-overlay'); if (overlay) overlay.remove(); }
function openRealityScanner() {
closeRealityScanner();
const overlay = document.createElement('div');
overlay.id = 'scan-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '95';
overlay.innerHTML = `
从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。
扫描
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', async (event) => {
if (event.target === overlay || event.target.closest('[data-scan-close]')) { closeRealityScanner(); return; }
if (event.target.closest('#scan-run')) { await runRealityScan(); return; }
const use = event.target.closest('[data-scan-use]');
if (use) {
const form = $('#node-form');
if (form) {
form.elements.realityServerName.value = use.dataset.host;
form.elements.realityDestPort.value = use.dataset.port;
if (form.elements.realityPreset) form.elements.realityPreset.value = 'custom';
}
closeRealityScanner();
toast('已回填 Reality 目标');
}
});
runRealityScan();
}
async function runRealityScan() {
const box = $('#scan-results'); if (!box) return;
box.innerHTML = '扫描中…
';
try {
const input = $('#scan-input') ? $('#scan-input').value.trim() : '';
const data = await api('/api/reality/scan', { method: 'POST', body: JSON.stringify({ input }) });
const rows = (data.results || []).map((r) => `
${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''}
${r.ok ? '可用 ' : '不可用 '}
${esc(r.tlsVersion || '—')} ${esc(r.alpn || '—')}
${esc(r.keyExchange || '—')} ${esc(r.certName || '—')}
${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')}
${r.ok ? `使用 ` : ''} `).join('');
box.innerHTML = `目标 状态 TLS ALPN 密钥交换 证书域名 延迟 / 原因 ${rows || '无结果 '}
`;
} catch (error) { box.innerHTML = `${esc(error.message)}
`; }
}
function nodeClientsModal(node) {
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
const rows = assigned.map((nc) => {
const customer = customerById(nc.customerId);
return `${esc(customer ? customer.name : '已删除客户')}
${esc(statusText[nc.status] || nc.status)}
${nc.clientUri ? `复制链接 ` : '— '} `;
}).join('');
openModal(`${node.name} · 客户端`, ``, true);
}
function nodeMenuHTML(node) {
const items = [];
if (node.status === 'draft') items.push(``);
if (['failed', 'degraded'].includes(node.status)) items.push(``);
if (!['draft', 'removing'].includes(node.status)) items.push(``);
items.push(``);
items.push(``);
return items.join('');
}
/* ---------- 设置(命令速查已合并至此页) ---------- */
function commandsHTML() {
const groups = [
['面板机', [['更新控制面', 'ng update'], ['检查面板 HTTPS', 'ng cert'], ['备份面板', 'ng backup']]],
['入口机 / 中转机', [['更新 Agent', 'ng-agent update'], ['申请或续签节点证书', 'ng-agent cert'], ['安装 sing-box 引擎', 'ng-agent engine install'], ['检查证书、引擎与双向计数', 'ng-agent doctor']]],
['出口机 / 落地机', [['更新 Agent', 'ng-agent update'], ['检查运行状态', 'ng-agent doctor']]]
];
return `${groups.map(([title, commands]) => `
${esc(title)} ${commands.map(([label, command]) => `
${esc(label)} ${esc(command)}
复制命令 `).join('')}
`).join('')}
`;
}
function renderOperations() {
const rows = state.jobs.slice(0, 100).map((job) => `${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')} ${esc(job.id)}
${esc((serverById(job.serverId) || {}).name || job.serverId)} ${esc(statusText[job.status] || job.status)} ${job.attempts} ${fmtDate(job.updatedAt)} ${esc(job.error || '')} `).join('');
return `
${commandsHTML()}
任务队列 任务 设备 状态 尝试 更新时间 错误 ${rows || emptyRow(6, '任务队列为空')}
`;
}
function render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
}
/* ---------- 表单提交 ---------- */
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) });
closeModal(); toast(resourceId ? '设备信息已更新' : '设备已添加'); await load('servers');
} else if (form.id === 'customer-form') {
const resourceId = form.dataset.customerId;
collectLinksFromPane();
const resetMode = data.get('trafficResetMode');
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: resetMode === '1' ? Number(data.get('trafficResetDay') || 0) : 0,
tags: splitList(data.get('tags')), notes: data.get('notes'),
nodeIds: [...customerFormState.pickerSelected],
manualLinks: customerFormState.manualLinks.map((link) => ({ ...(link.id ? { id: link.id } : {}), uri: link.uri, remark: link.remark, enabled: link.enabled })),
externalSources: customerFormState.externalSources.map((source) => ({ ...(source.id ? { id: source.id } : {}), url: source.url, remark: source.remark, enabled: source.enabled, ...(source.userAgent ? { userAgent: source.userAgent } : {}) }))
};
if (resourceId) {
const current = customerById(resourceId);
const wasManualOff = current && current.status === 'suspended' && current.suspendReason === 'manual';
const wantEnabled = form.elements.enabledToggle.checked;
if (wantEnabled === wasManualOff) payload.status = wantEnabled ? 'active' : 'suspended';
}
await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
closeModal(); 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), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null, socksUser: data.get('socksUser') || null, socksPassword: data.get('socksPassword') || null };
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
const creds = {};
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
if (data.get('exitPassword')) creds.password = data.get('exitPassword');
if (data.get('exitSocksUser')) creds.socksUser = data.get('exitSocksUser');
if (Object.keys(creds).length) payload.exitCredentials = creds;
payload.exitPortMode = 'fixed';
}
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,可直接部署,客户可随时增减绑定'); await load('chains');
} 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('账号已更新,请重新登录');
const loginUser = $('#login-form [name="username"]'); if (loginUser) loginUser.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 themeBtn = event.target.closest('[data-theme-toggle]');
if (themeBtn) { setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); return; }
const nav = event.target.closest('[data-page]');
if (nav) { event.preventDefault(); if (nav.dataset.page !== state.page) setPage(nav.dataset.page); return; }
const pageNav = event.target.closest('[data-page-nav]');
if (pageNav && !pageNav.disabled) {
const [key, value] = pageNav.dataset.pageNav.split(':');
if (value === '-1') state.pages[key] = Math.max(1, state.pages[key] - 1);
else if (value === '1') state.pages[key] += 1;
else state.pages[key] = Number(value);
render(); return;
}
// 关联入站选择器
const pickerRemove = event.target.closest('[data-picker-remove]');
if (pickerRemove) { customerFormState.pickerSelected.delete(pickerRemove.dataset.pickerRemove); refreshPicker(); return; }
const pickerOpt = event.target.closest('[data-picker-opt]');
if (pickerOpt) {
const id = pickerOpt.dataset.pickerOpt;
if (customerFormState.pickerSelected.has(id)) customerFormState.pickerSelected.delete(id);
else customerFormState.pickerSelected.add(id);
refreshPicker(); return;
}
if (event.target.closest('[data-picker-all]')) { state.nodes.forEach((n) => customerFormState.pickerSelected.add(n.id)); refreshPicker(); return; }
if (event.target.closest('[data-picker-clear]')) { customerFormState.pickerSelected.clear(); refreshPicker(); return; }
if (event.target.closest('[data-picker-box]')) {
const panel = $('[data-picker-panel]');
if (panel) { panel.classList.toggle('hidden'); if (!panel.classList.contains('hidden')) renderPickerList(); }
return;
}
const tab = event.target.closest('[data-tab]');
if (tab) {
$$('[data-tab]').forEach((b) => b.classList.toggle('active', b === tab));
$$('.tab-pane').forEach((pane) => pane.classList.toggle('hidden', pane.id !== `tab-${tab.dataset.tab}`));
return;
}
if (event.target.closest('[data-close]')) { closeModal(); return; }
if (event.target.id === 'modal-overlay') { closeModal(); return; }
const inPop = event.target.closest('#float-pop');
const button = event.target.closest('[data-action]');
if (!button) { if (!inPop) hidePop(); return; }
const action = button.dataset.action; const itemId = button.dataset.id;
try {
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
else if (action === 'scan-reality') openRealityScanner();
else if (action === 'copy-field') { const input = button.form ? button.form.elements[button.dataset.field] : null; await copyText(input ? input.value : ''); toast('已复制到剪贴板'); }
else if (action === 'add-server') serverForm();
else if (action === 'edit-server') serverForm(serverById(itemId));
else if (action === 'enroll-server') await enrollModal(itemId);
else if (action === 'agent-uninstall') uninstallModal();
else if (action === 'expand-server') {
if (state.expandedServers.has(itemId)) state.expandedServers.delete(itemId); else state.expandedServers.add(itemId);
render();
}
else if (action === 'toggle-addr') { state.addrVisible = !state.addrVisible; render(); }
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 = serverById(itemId); if (!item) return;
const name = await promptModal('遗忘离线设备', `仅在服务器永久下线、销毁,或已手工清除其代理配置时使用。强制遗忘会放弃 ${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') {
if (!await confirmModal('删除控制台登记并吊销 Agent 密钥?如需彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。', '删除')) return;
await api(`/api/servers/${itemId}`, { method: 'DELETE' }); toast('设备登记已删除'); await load();
}
else if (action === 'add-customer') customerForm();
else if (action === 'edit-customer') customerForm(customerById(itemId));
else if (action === 'customer-subscription') subscriptionModal(customerById(itemId));
else if (action === 'customer-access') await customerAccessModal(customerById(itemId));
else if (action === 'customer-inbounds') {
const item = customerById(itemId); if (!item) return;
const chips = customerBindings(item.id).map((nc) => {
const node = nodeById(nc.nodeId); if (!node) return '';
const fam = protocolFamily(node.protocol);
return `${esc(node.name)} `;
}).join('');
showPop(button, `关联入站 ${customerBindings(item.id).length} 个
${chips}
`);
}
else if (action === 'clear-node-filter') { state.customerNodeFilter = ''; state.pages.customers = 1; render(); }
else if (action === 'reset-access') {
if (!await confirmModal('清空此客户的订阅访问日志和最近 24 小时的客户端估计数?节点 IP 观察记录不受影响。', '清空')) return;
await api(`/api/customers/${itemId}/access`, { method: 'DELETE' });
toast('订阅访问窗口已清空'); await load('customers');
const item = customerById(itemId); if (item) await customerAccessModal(item);
}
else if (action === 'rotate-subscription') {
if (!await confirmModal('旧订阅地址会立刻失效。已导入的节点凭据不会随订阅令牌重置,确认继续?', '重置')) return;
const result = await api(`/api/customers/${itemId}/rotate-subscription`, { method: 'POST', body: '{}' });
state.customers = state.customers.map((entry) => entry.id === itemId ? result.customer : entry);
toast('订阅地址已重置');
if ($('#customer-form')) {
customerFormState.credentials = null;
renderCredentialsPane(result.customer);
api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {});
} else if (modalOpen()) subscriptionModal(result.customer);
}
else if (action === 'exit-mode') {
const form = $('#node-form');
if (form) { form.dataset.exitMode = button.dataset.mode; syncNodeForm(); }
}
else if (action === 'regen-credentials') {
const customerId = $('#customer-form').dataset.customerId;
if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return;
const data = await api(`/api/customers/${customerId}/credentials/regenerate`, { method: 'POST', body: '{}' });
customerFormState.credentials = data;
renderCredentialsPane(customerById(customerId));
toast('凭据已重新生成');
}
else if (action === 'add-manual-link') { collectLinksFromPane(); customerFormState.manualLinks.push({ uri: '', remark: '', enabled: true }); renderLinksPane(customerById($('#customer-form').dataset.customerId)); }
else if (action === 'remove-manual-link') { collectLinksFromPane(); customerFormState.manualLinks.splice(Number(button.dataset.index), 1); renderLinksPane(customerById($('#customer-form').dataset.customerId)); }
else if (action === 'add-ext-source') { collectLinksFromPane(); customerFormState.externalSources.push({ url: '', remark: '', enabled: true }); renderLinksPane(customerById($('#customer-form').dataset.customerId)); }
else if (action === 'remove-ext-source') { collectLinksFromPane(); customerFormState.externalSources.splice(Number(button.dataset.index), 1); renderLinksPane(customerById($('#customer-form').dataset.customerId)); }
else if (action === 'refresh-ext-source') {
const customerId = $('#customer-form').dataset.customerId;
button.disabled = true;
const result = await api(`/api/customers/${customerId}/external-sources/${button.dataset.sid}/refresh`, { method: 'POST', body: '{}' });
toast(result.ok ? `已刷新:${result.count} 条节点` : `刷新失败:${result.error || '未知错误'}`, !result.ok);
const fresh = (await api('/api/customers')).customers;
state.customers = fresh;
const statusEl = $(`[data-source-status="${button.dataset.sid}"]`);
const freshCustomer = customerById(customerId);
const source = freshCustomer && (freshCustomer.externalSources || []).find((s) => s.id === button.dataset.sid);
if (statusEl && source) statusEl.innerHTML = linkStatusLine(freshCustomer, source);
button.disabled = false;
}
else if (action === 'reset-usage') {
if (!await confirmModal('确认把该客户已用流量清零?', '清零')) return;
await api(`/api/customers/${itemId}/reset-usage`, { method: 'POST', body: '{}' });
toast('客户流量已清零');
if ($('#customer-form')) closeModal();
await load('customers');
}
else if (action === 'delete-customer') {
if (!await confirmModal('删除客户及订阅地址?须先移除全部节点分配。', '删除')) return;
await api(`/api/customers/${itemId}`, { method: 'DELETE' }); toast('客户已删除'); await load();
}
else if (action === 'qr-uri') {
const row = button.closest('.subscription-row');
const target = row ? row.nextElementSibling : null;
if (!target || !target.classList.contains('subscription-qr')) return;
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 === 'add-node') nodeForm();
else if (action === 'edit-node') nodeForm(nodeById(itemId));
else if (action === 'node-clients') { hidePop(); nodeClientsModal(nodeById(itemId)); }
else if (action === 'node-clients-jump') { state.customerNodeFilter = itemId; setPage('customers'); }
else if (action === 'node-menu') {
const node = nodeById(itemId); if (!node) return;
showPop(button, nodeMenuHTML(node));
}
else if (action === 'deploy-node') { hidePop(); if (!await confirmModal('现在向所选设备下发这个节点?', '部署')) return; await api(`/api/nodes/${itemId}/deploy`, { method: 'POST', body: '{}' }); toast('部署任务已进入队列'); await load(); }
else if (action === 'repair-node') { hidePop(); if (!await confirmModal('重新下发该节点配置?', '修复')) return; await api(`/api/nodes/${itemId}/repair`, { method: 'POST', body: '{}' }); toast('修复任务已进入队列'); await load(); }
else if (action === 'remove-node') { hidePop(); if (!await confirmModal('停用会从相关设备移除配置,节点保留为草稿,确认继续?', '停用')) return; await api(`/api/nodes/${itemId}/remove`, { method: 'POST', body: '{}' }); toast('停用任务已进入队列'); await load(); }
else if (action === 'delete-node') { hidePop(); if (!await confirmModal('确定删除这个节点?会移除全部分配并从设备清理资源。', '删除')) return; 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]) return;
if (!await confirmModal('恢复会覆盖当前全部控制面数据,确认继续?', '恢复')) 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();
}
if (inPop && action !== 'node-menu') hidePop();
} catch (error) { toast(error.message, true); }
});
/* ---------- 变更(开关 / 表单联动 / 筛选) ---------- */
document.addEventListener('change', async (event) => {
const target = event.target;
try {
if (target.matches('[data-toggle-server]')) {
await api(`/api/servers/${target.dataset.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: target.checked }) });
const item = serverById(target.dataset.id); if (item) item.enabled = target.checked;
toast(target.checked ? '设备已启用' : '设备已停用');
} else if (target.matches('[data-toggle-customer]')) {
const item = customerById(target.dataset.id); if (!item) return;
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
if (target.checked === !manualOff) return;
await api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: target.checked ? 'active' : 'suspended' }) });
toast('客户状态已更新'); await load('customers');
} else if (target.matches('[data-toggle-node]')) {
const node = nodeById(target.dataset.id); if (!node) return;
if (target.checked) { await api(`/api/nodes/${node.id}/deploy`, { method: 'POST', body: '{}' }); toast('部署任务已进入队列'); }
else { await api(`/api/nodes/${node.id}/remove`, { method: 'POST', body: '{}' }); toast('停用任务已进入队列'); }
await load('chains');
} else if (target.matches('[data-group-filter]')) {
state.groupFilter = target.value; state.pages.customers = 1; render();
} else if (target.matches('[data-check-all]')) {
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
} else if (target.matches('[data-node-sync]')) syncNodeForm();
else if (target.matches('[data-exit-link]')) applyExitLink(target);
else if (target.matches('[data-reality-preset]')) {
const option = target.selectedOptions[0]; const form = target.form;
if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }
} else if (target.matches('[data-expiry-quick]') && target.value) {
const date = new Date(); date.setDate(date.getDate() + Number(target.value));
const pad = (n) => String(n).padStart(2, '0');
target.form.elements.expiryDate.value = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
target.form.elements.expiryTime.value = '23:59';
}
} catch (error) { toast(error.message, true); await load(); }
});
document.addEventListener('input', (event) => {
if (event.target.matches('[data-search]')) {
state.search = event.target.value;
state.pages = { servers: 1, customers: 1, nodes: 1 };
const pos = event.target.selectionStart;
render();
const next = $('[data-search]');
if (next) { next.focus(); next.setSelectionRange(pos, pos); }
}
if (event.target.matches('[data-picker-search]')) renderPickerList(event.target.value);
});
/* 节点在线名单浮层(悬停,浮层挂 body) */
document.addEventListener('mouseover', (event) => {
const anchor = event.target.closest('[data-online-pop]');
const pop = $('#float-pop');
if (anchor) {
if (pop._anchor === anchor && !pop.classList.contains('hidden')) return;
const names = (anchor.dataset.names || '').split(',').filter(Boolean);
if (!names.length) { hidePop(); return; }
showPop(anchor, `在线 ${names.length} 位客户
${names.map((name) => ` ${esc(name)}
`).join('')}`);
return;
}
if (!event.target.closest('#float-pop') && !event.target.closest('[data-action="node-menu"]')) {
if (pop._anchor && pop._anchor.hasAttribute && pop._anchor.hasAttribute('data-online-pop')) hidePop();
}
});
window.addEventListener('scroll', () => { const pop = $('#float-pop'); if (pop._anchor && pop._anchor.hasAttribute && pop._anchor.hasAttribute('data-online-pop')) hidePop(); }, true);
/* ---------- 节点拖拽排序 ---------- */
let dragNodeId = null;
document.addEventListener('mousedown', (event) => {
const grip = event.target.closest('[data-grip]');
const row = grip ? grip.closest('[data-node-row]') : null;
if (row) row.draggable = true;
});
document.addEventListener('dragstart', (event) => {
const row = event.target.closest('[data-node-row]');
if (!row) return;
dragNodeId = row.dataset.nodeRow;
row.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
try { event.dataTransfer.setData('text/plain', dragNodeId); } catch (e) { /* noop */ }
});
document.addEventListener('dragover', (event) => {
const row = event.target.closest('[data-node-row]');
if (!row || !dragNodeId) return;
event.preventDefault();
$$('[data-node-row]').forEach((r) => r.classList.toggle('drag-over', r === row && r.dataset.nodeRow !== dragNodeId));
});
document.addEventListener('drop', async (event) => {
const row = event.target.closest('[data-node-row]');
if (!row || !dragNodeId) return;
event.preventDefault();
const targetId = row.dataset.nodeRow;
const ids = state.nodes.map((n) => n.id);
const from = ids.indexOf(dragNodeId); const to = ids.indexOf(targetId);
dragNodeId = null;
$$('[data-node-row]').forEach((r) => { r.classList.remove('drag-over', 'dragging'); r.draggable = false; });
if (from < 0 || to < 0 || from === to) { render(); return; }
ids.splice(to, 0, ids.splice(from, 1)[0]);
try {
await api('/api/nodes/reorder', { method: 'POST', body: JSON.stringify({ ids }) });
toast('节点顺序已保存');
} catch (error) { toast(error.message, true); }
await load('chains');
});
document.addEventListener('dragend', () => {
dragNodeId = null;
$$('[data-node-row]').forEach((r) => { r.classList.remove('drag-over', 'dragging'); r.draggable = false; });
});
/* ---------- 启动 ---------- */
$('#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' && !modalOpen()) load(); }, 45000);
})();