Files
NexusGate/public/app.js
T
aaaamoooo a9d98675ac feat: show server Reality keypair on node edit form (item 17)
New read-only GET /api/nodes/:id/reality-keys returns the
panel-hosted keypair for the node's server from keyStore (display
only — it never mints a pair; a missing entry reports nulls). The
node edit form shows public and private key rows with copy buttons
for Reality protocols.
2026-10-21 10:44:32 +00:00

1448 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3zM21 14v.01M14 21v.01M18.5 18.5H21V21h-2.5z"/></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.8h.01"/></svg>',
reset: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 11-2.64-6.36"/><path d="M21 3v6h-6"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>',
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>',
dots: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="12" cy="19" r="1.8"/></svg>',
grip: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.7"/><circle cx="15" cy="6" r="1.7"/><circle cx="9" cy="12" r="1.7"/><circle cx="15" cy="12" r="1.7"/><circle cx="9" cy="18" r="1.7"/><circle cx="15" cy="18" r="1.7"/></svg>',
users: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="9" cy="8" r="3.2"/><path d="M3 19.5c.7-3 3-4.6 6-4.6s5.3 1.6 6 4.6"/><path d="M15.5 5.1a3.2 3.2 0 010 6M21 19.5c-.5-2.3-1.9-3.8-4-4.4"/></svg>',
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>',
eyeOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 3l18 18"/><path d="M10.6 5.1A9.8 9.8 0 0112 5c6.5 0 10 7 10 7a17.6 17.6 0 01-3.2 3.9M6.6 6.9A16.9 16.9 0 002 12s3.5 7 10 7c1.5 0 2.9-.4 4.1-1"/><path d="M9.9 9.9a3 3 0 004.2 4.2"/></svg>',
warn: '<svg class="warn-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3L2 20h20z"/><path d="M12 10v4M12 17.5h.01"/></svg>'
};
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('请确认', `<div class="stack"><p style="font-size:14px">${esc(message)}</p>
<div class="form-actions"><button type="button" class="btn" data-confirm-no>取消</button><button type="button" class="btn primary" data-confirm-yes>${esc(confirmLabel)}</button></div></div>`);
$('[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, `<div class="stack"><p style="font-size:13.5px;white-space:pre-wrap">${esc(message)}</p>
<label>输入<input name="prompt-value" placeholder="${esc(placeholder)}"></label>
<div class="form-actions"><button type="button" class="btn" data-prompt-no>取消</button><button type="button" class="btn primary" data-prompt-yes>确认</button></div></div>`);
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 `<div class="pager"><span>${total} 条</span><button class="pg-btn" data-page-nav="${key}:-1" ${page <= 1 ? 'disabled' : ''}>‹</button>${nums.map((n) => n === '…' ? '<span>…</span>' : `<button class="pg-btn${n === page ? ' active' : ''}" data-page-nav="${key}:${n}">${n}</button>`).join('')}<button class="pg-btn" data-page-nav="${key}:1" ${page >= totalPages ? 'disabled' : ''}>›</button></div>`;
}
/* ---------- 数据加载与路由 ---------- */
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 `<tr><td colspan="${colspan}"><div class="empty"><b>${esc(title)}</b></div></td></tr>`; }
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 '<div class="spark"></div>';
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 `<svg class="spark" viewBox="0 0 260 46" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop class="stop-up-a" offset="0"/><stop class="stop-up-b" offset="1"/></linearGradient></defs>
<path d="${line} L260,46 L0,46 Z" fill="url(#${id})" stroke="none"/>
<path class="ln-up" stroke-width="1.5" d="${line}"/>
<line class="mean" x1="0" y1="${meanY}" x2="260" y2="${meanY}"/></svg>`;
}
function dualChartSVG(history) {
const pts = history || [];
if (pts.length < 2) return '<div class="chart" style="height:238px"></div>';
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 `<svg class="chart" viewBox="0 0 880 250" preserveAspectRatio="none" style="height:238px" aria-hidden="true">
<defs><linearGradient id="gSpeed" x1="0" y1="0" x2="0" y2="1"><stop class="stop-up-a" offset="0"/><stop class="stop-up-b" offset="1"/></linearGradient></defs>
<path class="grid-ln" d="M0,70 H880 M0,120 H880 M0,170 H880 M0,220 H880"/>
<path d="${upLine} L880,250 L0,250 Z" fill="url(#gSpeed)" stroke="none"/>
<path class="ln-dn" d="${line('downBps')}"/>
<path class="ln-up" d="${upLine}"/></svg>`;
}
function connChartSVG(history) {
const pts = history || [];
if (pts.length < 2) return '<div class="chart" style="height:190px"></div>';
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 `<svg class="chart" viewBox="0 0 420 190" preserveAspectRatio="none" style="height:190px" aria-hidden="true">
<defs><linearGradient id="gConn" x1="0" y1="0" x2="0" y2="1"><stop class="stop-up-a" offset="0"/><stop class="stop-up-b" offset="1"/></linearGradient></defs>
<path d="${line} L420,190 L0,190 Z" fill="url(#gConn)" stroke="none"/>
<path class="ln-up" d="${line}"/></svg>`;
}
/* ---------- 系统状态 ---------- */
function renderOverview() {
const sys = state.system;
if (!sys) return '<div class="empty"><b>暂无数据</b></div>';
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) => `<article class="stat">
<div class="hd">${icon}${esc(label)}<span class="scope">${esc(scope)}</span></div>
<div class="big">${big}<small>${esc(unit)}</small></div><div class="det">${det}</div>
<div class="mm">${mm}</div>${spark}</article>`;
const gaugeIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a9 9 0 019 9h-3.2A5.8 5.8 0 0012 6.2z"/><path d="M12 21a9 9 0 119-9"/><path d="M12 12l4.5-4.5"/></svg>';
const memIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9v6M11 9v6M15 9v6M19 9v6"/></svg>';
const diskIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5.5" rx="8" ry="2.8"/><path d="M4 5.5v13c0 1.6 3.6 2.9 8 2.9s8-1.3 8-2.9v-13"/><path d="M4 12c0 1.6 3.6 2.9 8 2.9s8-1.3 8-2.9"/></svg>';
const trafIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17V7M7 7L4 10M7 7l3 3M17 7v10m0 0l3-3m-3 3l-3-3"/></svg>';
const cpuVals = cpuHist.filter((v) => v != null);
const cpuMm = cpuVals.length ? `<span>均值 ${(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length).toFixed(0)}%</span><span>峰值 ${Math.max(...cpuVals).toFixed(0)}%</span>` : '<span>—</span><span></span>';
const memVals = memHist.filter((v) => v != null);
const memMm = memVals.length ? `<span>均值 ${(memVals.reduce((a, b) => a + b, 0) / memVals.length).toFixed(0)}%</span><span>峰值 ${Math.max(...memVals).toFixed(0)}%</span>` : '<span>—</span><span></span>';
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)}`, `<span>剩余 ${fmtBytes((host.diskTotal || 0) - (host.diskUsed || 0))}</span><span></span>`, 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)}`, `<span>累计 ${fmtBytes((sys.totals.upBytes || 0) + (sys.totals.downBytes || 0))}</span><span>峰值 ${fmtSpeed(peakSpeed)}</span>`, 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 `<section class="stat-grid">${cards}</section>
<section class="mid-grid">
<article class="panel chart-card">
<div class="chart-hd"><div><h2>整体速度</h2></div>
<div class="legend"><span class="up"><span class="lb">↑ 上传</span>${fmtSpeed(net.upBps)}</span><span class="dn"><span class="lb">↓ 下载</span>${fmtSpeed(net.downBps)}</span></div></div>
${dualChartSVG(history)}
<div class="stat3">
<div><span class="k">已发送</span><div class="v">${fmtBytes(sys.totals.upBytes)}</div></div>
<div><span class="k">已接收</span><div class="v">${fmtBytes(sys.totals.downBytes)}</div></div>
<div><span class="k">窗口均值</span><div class="v" style="font-size:15px;white-space:nowrap">↑ ${fmtSpeed(meanUp)}&nbsp;&nbsp;↓ ${fmtSpeed(meanDown)}</div></div>
</div>
</article>
<article class="panel chart-card conn">
<div class="chart-hd"><div><h2>连接数</h2></div></div>
<div class="big">${net.connections || 0}<span>在线客户</span></div>
<div class="proto-row">
<span><i class="swatch"></i>在线节点<b>${net.onlineNodes || 0} / ${net.nodesTotal || 0}</b></span>
<span><i class="swatch g"></i>客户<b>${net.customersTotal || 0}</b></span>
</div>
${connChartSVG(history)}
</article>
</section>
<section class="panel info-bar">
<div class="seg"><div class="cap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>运行时间</div>
<div class="kv"><div class="item"><span class="k">面板</span><span class="v">${fmtUptime(sys.panelUptimeSec)}</span></div><div class="item"><span class="k">系统</span><span class="v">${fmtUptime(sys.hostUptimeSec)}</span></div></div></div>
<div class="seg"><div class="cap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M7 15h4"/></svg>面板</div>
<div class="kv"><div class="item"><span class="k">内存</span><span class="v">${fmtBytes(sys.panelMemBytes)}</span></div><div class="item"><span class="k">在线节点</span><span class="v">${net.onlineNodes || 0} / ${net.nodesTotal || 0}</span></div><div class="item"><span class="k">客户</span><span class="v">${net.customersTotal || 0}</span></div></div></div>
<div class="seg"><div class="cap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>IP 地址</div>
<div class="kv"><div class="item"><span class="k">IPv4</span><span class="v mono">${esc((sys.ips && sys.ips.ipv4) || '—')}</span></div><div class="item"><span class="k">IPv6</span><span class="v mono">${esc((sys.ips && sys.ips.ipv6) || '—')}</span></div></div></div>
</section>
${degraded.length ? `<div class="notice">${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「入站&出站」查看并修复。</div>` : ''}`;
}
/* ---------- 服务器 ---------- */
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 ? ` · <span class="v err">异常:${esc(server.usage.error)}</span>` : ''}` : '尚无上报';
return `<tr class="detail-row"><td colspan="14"><div class="detail-grid">
<div><span class="k">资源余量</span><span class="v">${resources}</span></div>
<div><span class="k">TLS 证书(${esc(server.tlsDomain || '—')})</span><span class="v">${tls}</span></div>
<div><span class="k">引擎状态</span><span class="v">${engine}</span></div>
<div><span class="k">流量统计</span><span class="v">${usage}</span></div>
<div><span class="k">系统</span><span class="v">${esc([system.hostname, system.platform, system.release, system.arch].filter(Boolean).join(' · ') || '—')}</span></div>
<div><span class="k">用途 / 地区 / 标签</span><span class="v">${esc(roleText[server.role] || server.role)} · ${esc(server.region || '未分组')} · ${esc((server.labels || []).join('、') || '—')}</span></div>
${server.engine && server.engine.status === 'error' ? `<div><span class="k">引擎故障</span><span class="v err">${esc(server.engine.detail || '')}</span></div>` : ''}
${oldUsageAgent(server.agentVersion) ? '<div><span class="k">Agent</span><span class="v err">旧版流量采集有问题,请在该机运行 ng-agent update</span></div>' : ''}
</div>
<div class="detail-actions">
${server.pendingCleanup ? `<button class="btn small" data-action="retry-cleanup" data-id="${esc(server.id)}">重试清理(${server.pendingCleanup})</button>` : ''}
${server.pendingCleanup && server.status !== 'online' ? `<button class="btn small danger" data-action="forget-server" data-id="${esc(server.id)}">遗忘离线设备</button>` : ''}
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
</div></td></tr>`;
}
function renderServers() {
const q = state.search.toLowerCase();
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
const stats = `<section class="stats">
<div class="stat"><div class="lab">设备总数</div><div class="val"><svg class="ic-dim" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>${state.servers.length}</div></div>
<div class="stat"><div class="lab">在线</div><div class="val"><svg class="ic-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/></svg>${onlineCount}</div></div>
<div class="stat"><div class="lab">离线</div><div class="val"><svg class="ic-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>${state.servers.length - onlineCount}</div></div>
</section>`;
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 = `<tr>
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
<td><input type="checkbox" data-row-check></td>
<td><div class="op-cell"><button class="icon-btn" data-tip="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" data-tip="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
<td><span class="dev-name">${esc(server.name)}</span></td>
<td><span class="addr">${addr}</span></td>
<td>${server.status === 'online' ? '<span class="status on"><span class="dot on"></span>在线</span>' : '<span class="status off"><span class="dot bad"></span>离线</span>'}${server.pendingCleanup ? ` ${ICONS.warn}` : ''}</td>
<td><span class="num">${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}</span></td>
<td><span class="num">${memPct == null ? '—' : `${memPct.toFixed(1)}%`}</span></td>
<td><span class="ver-cell"><span class="ver">${esc(server.agentVersion || '—')}</span>${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '<span class="badge orange">有可用更新</span>' : ''}</span></td>
<td><span class="ver">${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')}</span></td>
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
return expanded ? row + serverDetailRow(server) : row;
}).join('');
return `${stats}
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
}
/* ---------- 用户订阅 ---------- */
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 `<span class="pill">${used} / 不限</span>`;
const pct = Math.min(100, (item.usedBytes / item.trafficLimitBytes) * 100);
const cls = pct >= 90 ? ' danger' : pct >= 70 ? ' warn' : '';
return `<span class="pill${cls}"><span>${used}</span><span class="mini-bar"><i style="width:${pct.toFixed(0)}%"></i></span><span>${fmtBytes(item.trafficLimitBytes)}</span></span>`;
}
function remainingPill(item) {
if (!item.trafficLimitBytes) return '<span class="pill gray">不限</span>';
const left = item.trafficLimitBytes - item.usedBytes;
if (left <= 0) return '<span class="pill red">0 B</span>';
return `<span class="pill green">${fmtBytes(left)}</span>`;
}
function durationPill(item) {
if (!item.expiresAt) return '<span class="pill gray">不限期</span>';
const diff = new Date(item.expiresAt).getTime() - Date.now();
if (diff <= 0) return '<span class="pill red">已过期</span>';
const days = Math.floor(diff / 86400000);
if (days >= 1) return `<span class="pill green">${days}天后</span>`;
const hours = Math.max(1, Math.floor(diff / 3600000));
return `<span class="pill green">${hours}小时后</span>`;
}
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 `<tr>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
<td><span class="pres ${item.online ? 'on' : 'off'}"><span class="dot ${item.online ? 'on' : 'off'}"></span>${item.online ? '在线' : '离线'}</span>${item.status === 'suspended' ? `<span class="paused-note">已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}</span>` : ''}</td>
<td><span class="cust-name">${esc(item.name)}</span>${item.notes ? `<span class="cust-note">${esc(item.notes)}</span>` : '<span class="cust-note"></span>'}</td>
<td>${firstNode ? `<span class="in-chip ${fam.cls}">${esc(firstNode.name)}</span>` : '<span class="mut">—</span>'}${bindings.length > 1 ? `<button type="button" class="in-chip more" data-action="customer-inbounds" data-id="${esc(item.id)}">+${bindings.length - 1}</button>` : ''}</td>
<td>${trafficPill(item)}</td>
<td class="ctr">${speedTotal > 0 ? `<span class="speed" title="↑ ${fmtSpeed(item.speedUpBps)} · ↓ ${fmtSpeed(item.speedDownBps)}">${fmtSpeed(speedTotal)}</span>` : '<span class="speed mut">—</span>'}</td>
<td>${remainingPill(item)}</td>
<td>${durationPill(item)}</td></tr>`;
}).join('');
const sum = state.customerSummary;
const statCell = (lab, cls, svg, val) => `<div class="stat"><div class="lab">${lab}</div><div class="val"><svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${svg}</svg>${val}</div></div>`;
const custStats = sum ? `<section class="stats six">
${statCell('客户端', 'ic-dim', '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19c.6-3 2.8-4.5 5.5-4.5s4.9 1.5 5.5 4.5"/><path d="M16 5.5a3.5 3.5 0 010 6.5M17.5 14.7c1.7.6 2.7 1.9 3 4.3"/>', sum.total)}
${statCell('在线', 'ic-green', '<path d="M3 12h4l2.5-6 4 12L16 12h5"/>', sum.online)}
${statCell('耗尽', 'ic-red', '<rect x="3" y="8" width="15" height="8" rx="2"/><path d="M21 11v2"/><path d="M6.5 11v2"/>', sum.depleted)}
${statCell('即将耗尽', 'ic-bolt', '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', sum.depletingSoon)}
${statCell('关闭', 'ic-red', '<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/>', sum.disabled)}
${statCell('启用', 'ic-green', '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/>', sum.enabled)}
</section>` : '';
return `${custStats}<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
<span class="badge blue">${filtered.length} 位客户</span></div></div>
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
}
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) => `<option value="${value}"${selected(value, current)}>${value}</option>`).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 ? `<span class="chip">${esc(node.name)}<button type="button" data-picker-remove="${esc(id)}">×</button></span>` : '';
}).join('');
return `<div class="picker" data-picker>
<div class="picker-box" data-picker-box>${chips || '<span class="ph">选择节点</span>'}<svg class="search-ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></div>
<div class="picker-panel hidden" data-picker-panel>
<div class="picker-tools"><input type="search" data-picker-search placeholder="搜索节点"><button type="button" class="btn small" data-picker-all>全选</button><button type="button" class="btn small" data-picker-clear>全部清除</button></div>
<div class="picker-list" data-picker-list></div>
</div></div>`;
}
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 `<div class="picker-opt${on ? ' sel' : ''}" data-picker-opt="${esc(node.id)}"><span>${esc(node.name)}</span><small>${esc(server ? server.name : '')} · ${esc(statusText[node.status] || node.status)}</small><span class="ck">✓</span></div>`;
}).join('') || '<div class="empty">无匹配节点</div>';
}
function refreshPicker() {
const box = $('[data-picker-box]');
if (!box) return;
const chips = [...customerFormState.pickerSelected].map((id) => {
const node = nodeById(id);
return node ? `<span class="chip">${esc(node.name)}<button type="button" data-picker-remove="${esc(id)}">×</button></span>` : '';
}).join('');
box.innerHTML = `${chips || '<span class="ph">选择节点</span>'}<svg class="search-ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>`;
renderPickerList($('[data-picker-search]') ? $('[data-picker-search]').value : '');
}
function credRowHTML(label, value) {
return `<div class="cred-row"><span class="k">${esc(label)}</span><span class="v">${esc(value || '—')}</span>
${value ? `<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
}
function renderCredentialsPane(customer) {
const pane = $('#tab-credentials');
if (!pane) return;
if (!customer) { pane.innerHTML = '<div class="empty"><b>创建客户后生成凭据</b></div>'; return; }
const data = customerFormState.credentials;
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; 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 = `<div class="cred-group"><div class="g-head">订阅</div>
<div class="cred-row"><span class="k">订阅令牌</span><span class="v">${esc(data.subscriptionToken || '—')}</span>
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
<button type="button" class="icon-btn" data-tip="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
<div class="cred-group"><div class="g-head">凭据(全部节点共用)<button type="button" class="icon-btn" style="margin-left:auto" data-tip="重新生成凭据" data-action="regen-credentials" data-id="${esc(customer.id)}">${ICONS.reset}</button></div>
${credRowHTML('UUID', creds.clientId)}
${credRowHTML('密码', creds.password)}
${credRowHTML('Reality Short ID', creds.shortId)}
</div>`;
}
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(`<span class="err">${esc(status.lastError)}</span>`);
return parts.join(' · ');
}
function renderLinksPane(customer) {
const pane = $('#tab-links');
if (!pane) return;
const manual = customerFormState.manualLinks.map((link, index) => `<div class="link-item" data-link-item="${index}">
<div class="row"><input type="text" data-link-uri placeholder="节点链接(vless:// / ss:// / hysteria2:// / anytls:// / socks5://)" value="${esc(link.uri || '')}">
<input type="text" class="remark" data-link-remark placeholder="备注" value="${esc(link.remark || '')}">
<label class="tgl"><input type="checkbox" data-link-enabled ${link.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
const sources = customerFormState.externalSources.map((source, index) => `<div class="link-item" data-source-item="${index}">
<div class="row"><input type="text" data-source-url placeholder="订阅地址(https://…)" value="${esc(source.url || '')}">
<input type="text" class="remark" data-source-remark placeholder="备注" value="${esc(source.remark || '')}">
<label class="tgl"><input type="checkbox" data-source-enabled ${source.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
<div class="row"><input type="text" data-source-ua placeholder="User-Agent(可选)" value="${esc(source.userAgent || '')}">
<span class="status-line" data-source-status="${esc(source.id || '')}">${linkStatusLine(customer, source)}</span>
${source.id ? `<button type="button" class="btn small" data-action="refresh-ext-source" data-sid="${esc(source.id)}">立即刷新</button>` : ''}</div></div>`).join('');
pane.innerHTML = `<p class="links-note">添加第三方分享链接和远程订阅地址,将其包含在该客户的订阅中。</p>
<div class="links-head"><h3>外部链接</h3><button type="button" class="btn small" data-action="add-manual-link">+ 添加外部链接</button></div>
<div class="stack" style="gap:8px">${manual || '<div class="links-empty">暂无外部链接</div>'}</div>
<div class="links-head"><h3>外部订阅</h3><button type="button" class="btn small" data-action="add-ext-source">+ 添加外部订阅</button></div>
<div class="stack" style="gap:8px">${sources || '<div class="links-empty">暂无外部订阅</div>'}</div>`;
}
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 ? '编辑客户' : '添加客户', `<form id="customer-form" data-customer-id="${esc(item ? item.id : '')}">
<div class="tabs">
<button type="button" class="active" data-tab="basic">基本</button>
<button type="button" data-tab="credentials">凭据</button>
<button type="button" data-tab="links">链接</button>
</div>
<div class="tab-pane" id="tab-basic">
<div class="form-grid">
<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)}"></label>
<label>到期时间<select name="expiryTime">${timeOptions(expiry.time)}</select></label>
<label>IP 限制(0 不限)<input name="ipLimit" type="number" min="0" step="1" value="${Number(customer.ipLimit || 0)}"></label>
<label>设备限制(0 不限)<input name="deviceLimit" type="number" min="0" step="1" value="${Number(customer.deviceLimit || 0)}"></label>
<div class="field"><span style="display:block;font-size:13px;color:var(--text-2);margin-bottom:5px">流量重置</span>
<div class="pair-fields"><label style="flex:1"><select name="trafficResetMode"><option value="0"${selected('0', String(customer.trafficResetDay ? 1 : 0))}>不重置</option><option value="1"${selected('1', String(customer.trafficResetDay ? 1 : 0))}>每月</option></select></label>
<label style="flex:1">每月重置日<input name="trafficResetDay" type="number" min="1" max="31" step="1" value="${Number(customer.trafficResetDay || 1)}"></label></div></div>
<label>标签(逗号分隔)<input name="tags" value="${esc((customer.tags || []).join(', '))}"></label>
<label class="wide">备注<textarea name="notes">${esc(customer.notes || '')}</textarea></label>
<div class="field wide"><span style="display:block;font-size:13px;color:var(--text-2);margin-bottom:5px">关联入站</span>${pickerHTML(customerFormState.pickerSelected)}</div>
<label style="flex-direction:row;align-items:center;gap:10px">启用<span class="tgl"><input type="checkbox" name="enabledToggle" ${item ? (manualOff ? '' : 'checked') : 'checked'}><span class="tr"></span></span></label>
</div>
</div>
<div class="tab-pane hidden" id="tab-credentials"></div>
<div class="tab-pane hidden" id="tab-links"></div>
<div class="form-actions">
${item ? `<button type="button" class="btn danger left" data-action="reset-usage" data-id="${esc(item.id)}">重置流量</button>` : ''}
<button type="button" class="btn" data-close>取消</button>
<button class="btn primary" type="submit">${item ? '保存修改' : '创建客户'}</button>
</div></form>`, 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 = `<div class="empty"><b>${esc(error.message)}</b></div>`; });
}
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 `<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" class="btn small" data-action="copy-text" data-value="${esc(nc.clientUri)}">复制</button><button type="button" class="btn small" data-action="qr-uri" data-value="${esc(nc.clientUri)}">二维码</button></div></div><div class="subscription-qr" hidden></div>`;
}).join('');
openModal(`${customer.name} · 订阅`, `<div class="stack">
<div class="subscription-qr" data-main-qr></div>
<div class="subscription-row"><div><b>📱 订阅信息页</b><code title="${esc(infoUrl)}">${esc(infoUrl)}</code></div><div class="subscription-actions"><button type="button" class="btn small" data-action="copy-text" data-value="${esc(infoUrl)}">复制</button><a href="${esc(infoUrl)}" target="_blank" rel="noopener"><button type="button" class="btn small">打开</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" class="btn small" data-action="copy-text" data-value="${esc(url)}">复制</button><button type="button" class="btn small" data-action="qr-uri" data-value="${esc(url)}">二维码</button></div></div><div class="subscription-qr" hidden></div>`; }).join('')}
${nodeLinks ? `<h3 style="font-size:14px">各节点入站链接</h3>${nodeLinks}` : ''}
<div class="form-actions"><button type="button" class="btn danger left" data-action="rotate-subscription" data-id="${esc(customer.id)}">重置订阅地址</button><button type="button" class="btn" data-close>关闭</button></div></div>`, 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) => `<tr><td><code class="mono">${esc(entry.ip)}</code></td><td>${fmtDate(entry.firstSeenAt)}</td><td>${fmtDate(entry.lastSeenAt)}</td></tr>`).join('');
const logs = data.events.map((entry) => `<tr><td>${fmtDate(entry.at)}</td><td>${entry.status === 200 ? '<span class="badge green">已返回</span>' : `<span class="badge red">${entry.status} ${esc(entry.reason || '')}</span>`}</td><td>${esc(entry.ip)}<div class="mut" style="font-size:12px">${esc(entry.userAgent || '未提供 User-Agent')}</div></td><td>${esc(entry.format)}</td><td>${fmtBytes(entry.bytes)}</td></tr>`).join('');
openModal(`${customer.name} · 详情`, `<div class="stack">
<table class="kv-table">
<tr><td class="k">状态</td><td>${esc(statusText[customer.status] || customer.status)}${customer.status === 'suspended' ? ` · ${esc(suspendReasonText[customer.suspendReason] || '')}` : ''}</td></tr>
<tr><td class="k">分组 / 标签</td><td>${esc(customer.group || '—')} · ${esc((customer.tags || []).join('、') || '—')}</td></tr>
<tr><td class="k">流量</td><td>${fmtBytes(customer.usedBytes)} / ${customer.trafficLimitBytes ? fmtBytes(customer.trafficLimitBytes) : '不限'}(上行 ${fmtBytes(customer.usedUplinkBytes)} · 下行 ${fmtBytes(customer.usedDownlinkBytes)})</td></tr>
<tr><td class="k">到期</td><td>${customer.expiresAt ? fmtDate(customer.expiresAt) : '不限期'}</td></tr>
<tr><td class="k">使用限制</td><td>节点 IP ${data.observedIps.length} / ${data.limits.ip || '不限'} · 订阅客户端约 ${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}</td></tr>
<tr><td class="k">最近活跃</td><td>${customer.lastActiveAt ? relTime(customer.lastActiveAt) : '—'}</td></tr>
</table>
<div><b>节点观测 IP</b><div class="table-wrap"><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"><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 type="button" class="btn danger left" data-action="reset-access" data-id="${esc(customer.id)}">清空订阅访问窗口</button><button type="button" class="btn" data-close>关闭</button></div></div>`, true);
}
/* ---------- 服务器表单与维护弹窗 ---------- */
function serverForm(item = null) {
const server = item || { role: 'hybrid', portRangeStart: 20000, portRangeEnd: 50000, labels: [] };
openModal(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 域名<input name="tlsDomain" value="${esc(server.tlsDomain || '')}" placeholder="node.example.com"></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 wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '添加设备'}</button></div></form>`);
}
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 注册 / 重装命令', `<div class="stack">
<div class="warn-note">令牌 30 分钟内有效且只能使用一次。</div>
<div class="codebox">${esc(command)}</div>
<div class="form-actions"><button type="button" class="btn primary" data-action="copy-text" data-value="${esc(command)}">复制命令</button><button type="button" class="btn" data-close>关闭</button></div></div>`);
});
}
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', `<div class="stack">
<div class="warn-note">先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止专属服务并移除密钥与节点配置;之后再删除设备登记。</div>
<div class="codebox">${esc(command)}</div>
<button type="button" class="btn" data-action="copy-text" data-value="${esc(command)}">复制命令</button>
<div class="codebox">${esc(fallback)}</div>
<button type="button" class="btn" data-action="copy-text" data-value="${esc(fallback)}">复制兼容命令(旧版 Agent)</button>
<div class="form-actions"><button type="button" class="btn" data-close>关闭</button></div></div>`);
}
/* ---------- 入站&出站 ---------- */
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) ? '<span class="sec">Reality</span>' : /(tls|hysteria2|anytls)/.test(node.protocol) ? '<span class="sec">TLS</span>' : '';
let html = `<span class="proto ${cls}">${esc(label)}</span><span class="trans">${trans}</span>${sec}`;
if ((node.topology || 'forward') === 'forward' && node.exitProtocol) {
const [eCls, eLabel] = fam(node.exitProtocol);
html += `<span class="proto ${eCls}">${esc(eLabel)}</span>`;
}
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 `<tr data-node-row="${esc(node.id)}">
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><input type="checkbox" data-row-check></td>
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</td>
<td class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
<td><span class="node-name">${esc(node.name)}</span>${node.error ? `<div class="paused-note" title="${esc(node.error)}">${esc(String(node.error).slice(0, 40))}</div>` : ''}</td>
<td><span class="srv-chip ${chipCls}">${esc(server ? server.name : '已删除')}</span></td>
<td class="port">${node.port || '—'}</td>
<td><span class="badge-stack">${protoBadges(node)}</span></td>
<td><span class="clients" data-action="node-clients-jump" data-id="${esc(node.id)}" title="查看客户">${ICONS.users}<span>${total}</span><span class="online-num${onlineCount ? '' : ' zero'}" data-online-pop="${esc(node.id)}" data-names="${esc(onlineNames.join(','))}">${onlineCount}</span></span></td>
<td><span class="pill">${fmtBytes(metered)} / ∞</span></td>
<td>${speed > 0 ? `<span class="pill">↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)}</span>` : '<span class="pill mut">—</span>'}</td>
<td><span class="pill">∞</span></td></tr>`;
}).join('');
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
}
function protocolOptions(role, current) {
return state.protocols.filter((entry) => entry.role === role && entry.deployable).map((entry) => `<option value="${esc(entry.id)}"${selected(entry.id, current)}>${esc(entry.name)}${entry.status === 'beta' ? ' · 测试' : ''}</option>`).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 ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid" data-exit-mode="${node.exitMode === 'manual' ? 'manual' : 'managed'}"><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></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>
<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></label>
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
<label data-socks-only>SOCKS5 固定密码<div class="inline-fields"><input name="socksPassword" value="${esc(node.socksPassword || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksPassword">${ICONS.copy}</button></div></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>
<div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div>
<label class="wide" data-forward-only data-manual-only>从链接导入<input data-exit-link placeholder="粘贴 vless:// / ss:// / socks5:// 链接,自动填入下方字段"></label>
<label data-forward-only data-managed-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 data-manual-only>落地地址<input name="exitAddress" value="${esc(node.exitAddress || '')}" placeholder="IP 或域名"></label>
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select></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>
<label data-forward-only data-manual-only data-exit-cred="vless">VLESS 用户 ID<input name="exitClientId" placeholder="${item ? '留空保持不变' : '链接中的 UUID'}"></label>
<label data-forward-only data-manual-only data-exit-cred="socksuser">SOCKS5 用户名<input name="exitSocksUser" placeholder="${item ? '留空保持不变' : ''}"></label>
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, 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 = `<div class="g-head">服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)</div>
${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '<div class="empty"><b>尚未生成(首次部署时由主控生成)</b></div>'}`;
}).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '<div class="empty"><b>密钥读取失败</b></div>'; });
}
}
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('已从链接填入落地端点');
}
function nodeClientsModal(node) {
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
const rows = assigned.map((nc) => {
const customer = customerById(nc.customerId);
return `<tr><td><span class="cust-name">${esc(customer ? customer.name : '已删除客户')}</span></td>
<td><span class="badge ${nc.status === 'active' ? 'green' : 'gray'}">${esc(statusText[nc.status] || nc.status)}</span></td>
<td>${nc.clientUri ? `<button type="button" class="btn small" data-action="copy-text" data-value="${esc(nc.clientUri)}">复制链接</button>` : '<span class="mut">—</span>'}</td></tr>`;
}).join('');
openModal(`${node.name} · 客户端`, `<div class="stack">
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>链接</th></tr></thead><tbody>${rows || '<tr><td colspan="3"><div class="empty"><b>暂无客户端</b></div></td></tr>'}</tbody></table></div>
<div class="form-actions"><button type="button" class="btn" data-close>关闭</button></div></div>`, true);
}
function nodeMenuHTML(node) {
const items = [];
if (node.status === 'draft') items.push(`<button type="button" class="menu-item" data-action="deploy-node" data-id="${esc(node.id)}">部署</button>`);
if (['failed', 'degraded'].includes(node.status)) items.push(`<button type="button" class="menu-item" data-action="repair-node" data-id="${esc(node.id)}">修复</button>`);
if (!['draft', 'removing'].includes(node.status)) items.push(`<button type="button" class="menu-item" data-action="remove-node" data-id="${esc(node.id)}">停用</button>`);
items.push(`<button type="button" class="menu-item" data-action="node-clients" data-id="${esc(node.id)}">客户端</button>`);
items.push(`<button type="button" class="menu-item danger" data-action="delete-node" data-id="${esc(node.id)}">删除</button>`);
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 `<div class="command-grid">${groups.map(([title, commands]) => `<section class="panel"><div class="panel-head"><h2>${esc(title)}</h2></div><div class="panel-body">${commands.map(([label, command]) => `<div class="command-item"><div><b>${esc(label)}</b><code>${esc(command)}</code></div><button type="button" class="btn small" data-action="copy-text" data-value="${esc(command)}">复制命令</button></div>`).join('')}</div></section>`).join('')}</div>`;
}
function renderOperations() {
const rows = state.jobs.slice(0, 100).map((job) => `<tr><td><b>${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}</b><div class="mut" style="font-size:12px">${esc(job.id)}</div></td><td>${esc((serverById(job.serverId) || {}).name || job.serverId)}</td><td>${esc(statusText[job.status] || job.status)}</td><td>${job.attempts}</td><td>${fmtDate(job.updatedAt)}</td><td><span class="mut">${esc(job.error || '')}</span></td></tr>`).join('');
return `<section class="grid-3">
<article class="panel"><div class="panel-head"><h2>账号安全</h2></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="btn primary" type="submit">保存并重新登录</button></form></div></article>
<article class="panel"><div class="panel-head"><h2>备份与恢复</h2></div><div class="panel-body stack">
<div class="warn-note">备份含客户凭据,请加密保管。</div>
<div class="toolbar"><button type="button" class="btn primary" data-action="download-backup">下载 JSON</button><button type="button" class="btn" data-action="restore-backup">恢复 JSON</button></div>
<div class="codebox">ng backup\nng restore</div></div></article>
<article class="panel"><div class="panel-head"><h2>版本</h2></div><div class="panel-body">
<div class="health-row"><div><b>amo</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><span class="badge green">运行中</span></div>
<div class="codebox" style="margin-top:10px">ng update\nng domain new.example.com\nng cert</div></div></article>
</section>
${commandsHTML()}
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table class="w-jobs"><thead><tr><th>任务</th><th>设备</th><th>状态</th><th>尝试</th><th>更新时间</th><th>错误</th></tr></thead><tbody>${rows || emptyRow(6, '任务队列为空')}</tbody></table></div></section>`;
}
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 === '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 `<span class="in-chip ${fam.cls}">${esc(node.name)}</span>`;
}).join('');
showPop(button, `<div class="pop-head"><b>关联入站</b><span>${customerBindings(item.id).length} 个</span></div><div class="chip-list">${chips}</div>`);
}
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, `<div class="pop-head"><b>在线</b><span>${names.length} 位客户</span></div>${names.map((name) => `<div class="pop-item"><span class="dot on"></span>${esc(name)}</div>`).join('')}<div class="pop-foot">依据 Agent 每分钟活跃上报</div>`);
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);
})();