'use strict'; /* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 / 安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */ const state = { session: null, page: 'overview', overview: null, system: null, servers: [], customers: [], nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], version: '0.6.8', search: '', groupFilter: '', customerNodeFilter: '', pages: { servers: 1, customers: 1, nodes: 1 }, addrVisible: false, expandedServers: new Set() }; const PAGE_SIZE = 50; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const esc = (value) => String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]); const selected = (value, current) => String(value) === String(current) ? ' selected' : ''; const fmtDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—'; const fmtBytes = (value) => { let size = Number(value || 0); const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; }; const fmtSpeed = (value) => { const v = Math.round(Number(value || 0)); if (v <= 0) return '0 B/s'; if (v < 1024) return `${v} B/s`; if (v < 1024 * 1024) return `${(v / 1024).toFixed(v >= 10240 ? 0 : 1)} KB/s`; if (v < 1024 * 1024 * 1024) return `${(v / 1048576).toFixed(2)} MB/s`; return `${(v / 1073741824).toFixed(2)} GB/s`; }; const fmtUptime = (seconds) => { const s = Math.max(0, Math.floor(Number(seconds || 0))); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; }; const relTime = (value) => { if (!value) return '—'; const diff = Date.now() - new Date(value).getTime(); if (!Number.isFinite(diff)) return '—'; if (diff < 60000) return '刚刚'; if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; return fmtDate(value); }; const versionParts = (v) => (String(v || '').match(/^(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number); const versionLessThan = (a, b) => { const pa = versionParts(a); const pb = versionParts(b); if (pa.length < 3) return false; for (let i = 0; i < 3; i += 1) { if (pa[i] !== pb[i]) return pa[i] < pb[i]; } return false; }; const oldUsageAgent = (version) => { const p = versionParts(version); return p.length === 3 && p[0] === 0 && (p[1] < 6 || (p[1] === 6 && p[2] < 3)); }; const splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean); const statusText = { online: '在线', offline: '离线', pending: '待注册', active: '运行中', draft: '草稿', deploying: '部署中', queued: '排队中', running: '执行中', completed: '已完成', failed: '失败', degraded: '异常', suspended: '已暂停', removing: '停用中', deleted: '已删除' }; const roleText = { relay: '入口 / 转发', exit: '出口 / 落地', hybrid: '综合节点' }; const topologyText = { forward: '转发线路', direct: '单机直连' }; const auditText = { create_server: '添加设备', update_server: '编辑设备', delete_server: '删除设备', create_enrollment: '生成注册令牌', enroll_agent: 'Agent 注册', reconcile_server: '设备配置对账', set_tls_domain: '节点证书同步', create_customer: '添加客户', update_customer: '编辑客户', reset_customer_usage: '重置客户流量', delete_customer: '删除客户', create_node: '创建节点', update_node: '编辑节点', deploy_node: '部署节点', repair_node: '修复节点', remove_node: '停用节点', reorder_nodes: '节点排序', delete_node: '删除节点', assign_customer: '分配客户', unassign_customer: '移除分配', suspend_customer_resources: '暂停客户资源', resume_customer_resources: '恢复客户资源', retry_cleanup: '重试遗留清理', force_forget_server: '强制遗忘离线设备', update_account: '修改管理员账号', suspend_ip_limit: 'IP 超限停用', rotate_subscription: '重置订阅链接', regenerate_credentials: '重新生成凭据', monthly_reset: '按月重置流量' }; const suspendReasonText = { traffic_limit: '额度耗尽', expired: '已到期', ip_limit: '节点 IP 超限', manual: '手动停用' }; const ICONS = { qr: '', info: '', reset: '', edit: '', trash: '', copy: '', dots: '', grip: '', users: '', eye: '', eyeOff: '', warn: '' }; async function copyText(text) { text = String(text ?? ''); if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;'; const overlay = $('#modal-overlay'); const host = (overlay && !overlay.classList.contains('hidden')) ? overlay : document.body; host.appendChild(ta); ta.focus(); ta.select(); try { ta.setSelectionRange(0, ta.value.length); } catch (e) { /* noop */ } let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } ta.remove(); if (!ok) throw new Error('复制失败,请手动长按复制'); } function setTheme(theme) { const next = theme === 'dark' ? 'dark' : 'light'; document.documentElement.dataset.theme = next; try { localStorage.setItem('nexusgate-theme', next); } catch { /* storage may be disabled */ } const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.content = next === 'dark' ? '#1a1b1f' : '#f0f2f5'; const scheme = document.querySelector('meta[name="color-scheme"]'); if (scheme) scheme.content = next; $$('[data-theme-toggle]').forEach((button) => { button.textContent = next === 'dark' ? '☀️ 浅色' : '🌙 深色'; }); } function toast(message, error = false) { const item = document.createElement('div'); item.className = `toast${error ? ' error' : ''}`; item.textContent = message; $('#toasts').append(item); setTimeout(() => item.remove(), 3800); } async function api(path, options = {}) { const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.headers || {}) }; if (state.session && !['GET', 'HEAD'].includes(options.method || 'GET')) headers['x-csrf-token'] = state.session.csrf; const response = await fetch(path, { ...options, headers }); const type = response.headers.get('content-type') || ''; const data = type.includes('json') ? await response.json() : await response.text(); if (!response.ok) { if (response.status === 401 && path !== '/api/auth/login') showLogin(); throw new Error((data && data.message) || `请求失败 (${response.status})`); } return data; } function showLogin() { state.session = null; $('#app').classList.add('hidden'); $('#login').classList.remove('hidden'); } function showApp() { $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); $('#user-chip').textContent = state.session.user.username; $('#version-label').textContent = `v${state.version}`; } /* ---------- 弹窗 ---------- */ function openModal(title, bodyHTML, wide = false) { $('#modal-title').textContent = title; $('#modal-body').innerHTML = bodyHTML; $('.modal', $('#modal-overlay')).classList.toggle('wide', !!wide); $('#modal-overlay').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } function closeModal() { $('#modal-overlay').classList.add('hidden'); $('#modal-body').innerHTML = ''; document.body.style.overflow = ''; } function modalOpen() { return !$('#modal-overlay').classList.contains('hidden'); } function confirmModal(message, confirmLabel = '确认') { return new Promise((resolve) => { openModal('请确认', `

${esc(message)}

`); $('[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, `

${esc(message)}

`); 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 `
${total} 条${nums.map((n) => n === '…' ? '…' : ``).join('')}
`; } /* ---------- 数据加载与路由 ---------- */ 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 === 'commands') { /* 静态页 */ } 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', 'commands', 'operations']; const TITLES = { overview: '系统状态', servers: '服务器', customers: '客户与订阅', chains: '转发与节点', commands: '安装与命令', operations: '设置与运维' }; function pageFromHash() { const page = location.hash.replace(/^#/, '').split('?')[0]; return PAGES.includes(page) ? page : 'overview'; } function setPage(page) { if (!PAGES.includes(page)) page = 'overview'; state.page = page; state.search = ''; state.groupFilter = ''; if (page !== 'customers') state.customerNodeFilter = ''; state.pages = { servers: 1, customers: 1, nodes: 1 }; hidePop(); if (location.hash !== `#${page}`) history.pushState(null, '', `#${page}`); $$('#nav .mi').forEach((item) => item.classList.toggle('active', item.dataset.page === page)); $('#page-title').textContent = TITLES[page]; load(page); } function emptyRow(colspan, title) { return `
${esc(title)}
`; } function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; } function nodeById(id) { return state.nodes.find((item) => item.id === id); } function customerById(id) { return state.customers.find((item) => item.id === id); } function serverById(id) { return state.servers.find((item) => item.id === id); } /* ---------- SVG 走势线 ---------- */ function sparkSVG(values, id, fixedMax = null) { const pts = (values || []).filter((v) => v != null && Number.isFinite(v)); if (pts.length < 2) return '
'; const max = fixedMax || Math.max(...pts, 1); const min = fixedMax ? 0 : Math.min(...pts, 0); const span = (max - min) || 1; const stepX = 260 / (pts.length - 1); const coords = pts.map((v, i) => `${(i * stepX).toFixed(1)},${(42 - ((v - min) / span) * 38).toFixed(1)}`); const line = `M${coords.join(' L')}`; const mean = pts.reduce((a, b) => a + b, 0) / pts.length; const meanY = (42 - ((mean - min) / span) * 38).toFixed(1); return ``; } function dualChartSVG(history) { const pts = history || []; if (pts.length < 2) return '
'; const max = Math.max(1, ...pts.map((p) => Math.max(p.upBps || 0, p.downBps || 0))); const stepX = 880 / (pts.length - 1); const line = (key) => `M${pts.map((p, i) => `${(i * stepX).toFixed(1)},${(228 - ((p[key] || 0) / max) * 200).toFixed(1)}`).join(' L')}`; const upLine = line('upBps'); return ``; } function connChartSVG(history) { const pts = history || []; if (pts.length < 2) return '
'; const values = pts.map((p) => p.connections || 0); const max = Math.max(...values, 1); const stepX = 420 / (pts.length - 1); const line = `M${values.map((v, i) => `${(i * stepX).toFixed(1)},${(170 - (v / max) * 140).toFixed(1)}`).join(' L')}`; return ``; } /* ---------- 系统状态 ---------- */ function renderOverview() { const sys = state.system; if (!sys) return '
暂无数据
'; const host = sys.host || {}; const net = sys.network || {}; const history = sys.history || []; const cpuHist = history.map((p) => p.cpu); const memHist = history.map((p) => p.mem); const trafHist = history.map((p) => (p.upBps || 0) + (p.downBps || 0)); const statCard = (icon, label, scope, big, unit, det, mm, spark) => `
${icon}${esc(label)}${esc(scope)}
${big}${esc(unit)}
${det}
${mm}
${spark}
`; const gaugeIcon = ''; const memIcon = ''; const diskIcon = ''; const trafIcon = ''; const cpuVals = cpuHist.filter((v) => v != null); const cpuMm = cpuVals.length ? `均值 ${(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length).toFixed(0)}%峰值 ${Math.max(...cpuVals).toFixed(0)}%` : '—'; const memVals = memHist.filter((v) => v != null); const memMm = memVals.length ? `均值 ${(memVals.reduce((a, b) => a + b, 0) / memVals.length).toFixed(0)}%峰值 ${Math.max(...memVals).toFixed(0)}%` : '—'; const todayTotal = (sys.today.upBytes || 0) + (sys.today.downBytes || 0); const peakSpeed = trafHist.length ? Math.max(...trafHist) : 0; const cards = [ statCard(gaugeIcon, 'CPU', '主控', host.cpuPercent == null ? '—' : host.cpuPercent.toFixed(1), '%', `${host.cpuCount || '—'} 核 · 负载 ${host.load1 != null ? Number(host.load1).toFixed(2) : '—'}`, cpuMm, sparkSVG(cpuHist, 'gCpu', 100)), statCard(memIcon, '内存', '主控', host.memPercent == null ? '—' : host.memPercent.toFixed(1), '%', `${fmtBytes(host.memUsed)} / ${fmtBytes(host.memTotal)}`, memMm, sparkSVG(memHist, 'gMem', 100)), statCard(diskIcon, '存储', '主控', host.diskPercent == null ? '—' : host.diskPercent.toFixed(1), '%', `${fmtBytes(host.diskUsed)} / ${fmtBytes(host.diskTotal)}`, `剩余 ${fmtBytes((host.diskTotal || 0) - (host.diskUsed || 0))}`, sparkSVG(history.map(() => host.diskPercent), 'gDisk', 100)), statCard(trafIcon, '今日流量', '全网汇总', (() => { const [v, u] = fmtBytes(todayTotal).split(' '); return v; })(), (() => { const p = fmtBytes(todayTotal).split(' '); return p[1] || ''; })(), `↑ ${fmtBytes(sys.today.upBytes)} · ↓ ${fmtBytes(sys.today.downBytes)}`, `累计 ${fmtBytes((sys.totals.upBytes || 0) + (sys.totals.downBytes || 0))}峰值 ${fmtSpeed(peakSpeed)}`, sparkSVG(trafHist, 'gTraf')) ].join(''); const meanUp = history.length ? history.reduce((a, p) => a + (p.upBps || 0), 0) / history.length : 0; const meanDown = history.length ? history.reduce((a, p) => a + (p.downBps || 0), 0) / history.length : 0; const degraded = (state.overview && state.overview.degraded) || []; const events = ((state.overview && state.overview.activity) || []).map((event) => `
${esc(auditText[event.action] || event.action)} ${esc(event.actor)} · ${esc(event.target)}
`).join(''); return `
${cards}

整体速度

↑ 上传${fmtSpeed(net.upBps)}↓ 下载${fmtSpeed(net.downBps)}
${dualChartSVG(history)}
已发送
${fmtBytes(sys.totals.upBytes)}
已接收
${fmtBytes(sys.totals.downBytes)}
窗口均值
↑ ${fmtSpeed(meanUp)}  ↓ ${fmtSpeed(meanDown)}

连接数

${net.connections || 0}在线客户
在线节点${net.onlineNodes || 0} / ${net.nodesTotal || 0} 客户${net.customersTotal || 0}
${connChartSVG(history)}
运行时间
面板${fmtUptime(sys.panelUptimeSec)}
系统${fmtUptime(sys.hostUptimeSec)}
面板
内存${fmtBytes(sys.panelMemBytes)}
在线节点${net.onlineNodes || 0} / ${net.nodesTotal || 0}
客户${net.customersTotal || 0}
IP 地址
IPv4${esc((sys.ips && sys.ips.ipv4) || '—')}
IPv6${esc((sys.ips && sys.ips.ipv6) || '—')}
${degraded.length ? `
${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「转发与节点」查看并修复。
` : ''}

最近活动

${events || '
暂无活动
'}
`; } /* ---------- 服务器 ---------- */ function maskAddr(addr) { const value = String(addr || ''); if (!value) return '—'; if (/^\d+\.\d+\.\d+\.\d+$/.test(value)) { const p = value.split('.'); return `${p[0]}.${p[1]}.*.*`; } const parts = value.split('.'); if (parts.length >= 3) return `${parts[0]}.****.${parts[parts.length - 1]}`; if (parts.length === 2) return `${parts[0].slice(0, 2)}****.${parts[1]}`; return value; } function serverClients(serverId) { const serverNodes = state.nodes.filter((n) => n.serverId === serverId); const ids = new Set(serverNodes.map((n) => n.id)); const total = state.nodeClients.filter((nc) => ids.has(nc.nodeId)).length; const online = new Set(); for (const node of serverNodes) for (const id of node.onlineCustomerIds || []) online.add(id); return { total, online: online.size }; } function serverDetailRow(server) { const system = server.system || {}; const memAvail = Number(system.memoryAvailable ?? system.memoryFree); const resources = Number.isFinite(system.memoryTotal) ? `内存可用 ${fmtBytes(memAvail)} / ${fmtBytes(system.memoryTotal)} · 负载 ${system.load1 ?? '—'} / ${system.cpuCount ?? '—'} 核${Number.isFinite(system.diskAvailable) ? ` · 系统盘可用 ${fmtBytes(system.diskAvailable)} / ${fmtBytes(system.diskTotal)}` : ''}${memAvail < 200 * 1024 * 1024 || (Number.isFinite(system.diskAvailable) && system.diskAvailable < 1024 ** 3) ? ' · 资源余量偏低' : ''}` : '—'; const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置'; const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报'); const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · 异常:${esc(server.usage.error)}` : ''}` : '尚无上报'; return `
资源余量${resources}
TLS 证书(${esc(server.tlsDomain || '—')})${tls}
引擎状态${engine}
流量统计${usage}
系统${esc([system.hostname, system.platform, system.release, system.arch].filter(Boolean).join(' · ') || '—')}
用途 / 地区 / 标签${esc(roleText[server.role] || server.role)} · ${esc(server.region || '未分组')} · ${esc((server.labels || []).join('、') || '—')}
${server.engine && server.engine.status === 'error' ? `
引擎故障${esc(server.engine.detail || '')}
` : ''} ${oldUsageAgent(server.agentVersion) ? '
Agent旧版流量采集有问题,请在该机运行 ng-agent update
' : ''}
${server.pendingCleanup ? `` : ''} ${server.pendingCleanup && server.status !== 'online' ? `` : ''}
`; } function renderServers() { const q = state.search.toLowerCase(); const onlineCount = state.servers.filter((s) => s.status === 'online').length; const stats = `
设备总数
${state.servers.length}
在线
${onlineCount}
离线
${state.servers.length - onlineCount}
`; const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q)); const { pageItems } = pageSlice(filtered, 'servers'); const rows = pageItems.map((server) => { const system = server.system || {}; const cpuPct = Number.isFinite(system.load1) && system.cpuCount ? Math.min(100, (system.load1 / system.cpuCount) * 100) : null; const memPct = Number.isFinite(system.memoryTotal) && system.memoryTotal > 0 ? (1 - Number(system.memoryAvailable ?? system.memoryFree) / system.memoryTotal) * 100 : null; const clients = serverClients(server.id); const expanded = state.expandedServers.has(server.id); const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress)); const row = `
${esc(server.name)} ${addr} ${server.status === 'online' ? '在线' : '离线'}${server.pendingCleanup ? ` ${ICONS.warn}` : ''} ${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`} ${memPct == null ? '—' : `${memPct.toFixed(1)}%`} ${esc(server.agentVersion || '—')}${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '有可用更新' : ''} ${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')} ${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'} ${ICONS.users}${clients.total}${clients.online} ${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}`; return expanded ? row + serverDetailRow(server) : row; }).join(''); return `${stats}
${rows || emptyRow(14, '没有匹配设备')}
操作已启用名称地址状态CPU内存Agent 版本核心版本运行时长客户端上次心跳
${pagerHTML('servers', filtered.length)}
`; } /* ---------- 客户与订阅 ---------- */ function protocolFamily(protocol) { const id = String(protocol || ''); if (id.startsWith('vless')) return { cls: '', label: 'VLESS' }; if (id.startsWith('shadowsocks')) return { cls: 'ss', label: 'SS' }; if (id.startsWith('socks')) return { cls: 'mixed', label: 'Mixed' }; if (id.startsWith('hysteria2')) return { cls: '', label: 'HY2' }; if (id.startsWith('anytls')) return { cls: '', label: 'AnyTLS' }; return { cls: '', label: id || '—' }; } function customerBindings(customerId) { return state.nodeClients.filter((nc) => nc.customerId === customerId) .slice().sort((a, b) => ((nodeById(a.nodeId) || {}).sortIndex ?? 1e9) - ((nodeById(b.nodeId) || {}).sortIndex ?? 1e9)); } function trafficPill(item) { const used = fmtBytes(item.usedBytes); if (!item.trafficLimitBytes) return `${used} / 不限`; const pct = Math.min(100, (item.usedBytes / item.trafficLimitBytes) * 100); const cls = pct >= 90 ? ' danger' : pct >= 70 ? ' warn' : ''; return `${used}${fmtBytes(item.trafficLimitBytes)}`; } function remainingPill(item) { if (!item.trafficLimitBytes) return '不限'; const left = item.trafficLimitBytes - item.usedBytes; if (left <= 0) return '0 B'; return `${fmtBytes(left)}`; } function durationPill(item) { if (!item.expiresAt) return '不限期'; const diff = new Date(item.expiresAt).getTime() - Date.now(); if (diff <= 0) return '已过期'; const days = Math.floor(diff / 86400000); if (days >= 1) return `${days}天后`; const hours = Math.max(1, Math.floor(diff / 3600000)); return `${hours}小时后`; } function renderCustomers() { const q = state.search.toLowerCase(); const groups = [...new Set(state.customers.map((c) => c.group).filter(Boolean))]; let filtered = state.customers.filter((item) => { if (state.groupFilter && item.group !== state.groupFilter) return false; if (state.customerNodeFilter && !state.nodeClients.some((nc) => nc.customerId === item.id && nc.nodeId === state.customerNodeFilter)) return false; return [item.name, item.group, item.notes, ...(item.tags || [])].join(' ').toLowerCase().includes(q); }); const { pageItems } = pageSlice(filtered, 'customers'); const filterNode = state.customerNodeFilter ? nodeById(state.customerNodeFilter) : null; const rows = pageItems.map((item) => { const bindings = customerBindings(item.id); const first = bindings[0]; const firstNode = first ? nodeById(first.nodeId) : null; const fam = firstNode ? protocolFamily(firstNode.protocol) : null; const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0); const manualOff = item.status === 'suspended' && item.suspendReason === 'manual'; return `
${item.online ? '在线' : '离线'}${item.status === 'suspended' ? `已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}` : ''} ${esc(item.name)}${item.notes ? `${esc(item.notes)}` : ''} ${firstNode ? `${esc(firstNode.name)}` : '—'}${bindings.length > 1 ? `` : ''} ${trafficPill(item)} ${speedTotal > 0 ? `${fmtSpeed(speedTotal)}` : '—'} ${remainingPill(item)} ${durationPill(item)}`; }).join(''); const sum = state.customerSummary; const statCell = (lab, cls, svg, val) => `
${lab}
${svg}${val}
`; const custStats = sum ? `
${statCell('客户端', 'ic-dim', '', sum.total)} ${statCell('在线', 'ic-green', '', sum.online)} ${statCell('耗尽', 'ic-red', '', sum.depleted)} ${statCell('即将耗尽', 'ic-bolt', '', sum.depletingSoon)} ${statCell('关闭', 'ic-red', '', sum.disabled)} ${statCell('启用', 'ic-green', '', sum.enabled)}
` : ''; return `${custStats}
${filterNode ? `节点:${esc(filterNode.name)}` : ''} ${filtered.length} 位客户
${rows || emptyRow(9, '还没有客户')}
操作已启用在线客户端关联入站流量⇅速度剩余⇅时长⇅
${pagerHTML('customers', filtered.length)}
`; } function localDateParts(value) { if (!value) return { date: '', time: '23:59' }; const date = new Date(value); const pad = (n) => String(n).padStart(2, '0'); return { date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, time: `${pad(date.getHours())}:${pad(date.getMinutes())}` }; } function timeOptions(current = '23:59') { const values = ['00:00', '06:00', '12:00', '18:00', '23:59']; if (current && !values.includes(current)) values.push(current); return values.sort().map((value) => ``).join(''); } function expiryIso(formData) { const date = formData.get('expiryDate'); if (!date) return null; const time = formData.get('expiryTime') || '23:59'; const result = new Date(`${date}T${time}:00`); if (!Number.isFinite(result.getTime())) throw new Error('请选择有效的到期日期'); return result.toISOString(); } /* 客户表单(三页签:基本 / 凭据 / 链接)。关联入站多选、凭据、外部 链接与订阅源都随同一个保存动作提交。 */ const customerFormState = { pickerSelected: new Set(), manualLinks: [], externalSources: [], credentials: null }; function pickerHTML(selectedIds) { const chips = [...selectedIds].map((id) => { const node = nodeById(id); return node ? `${esc(node.name)}` : ''; }).join(''); return `
${chips || '选择节点'}
`; } function renderPickerList(filter = '') { const list = $('[data-picker-list]'); if (!list) return; const q = filter.trim().toLowerCase(); list.innerHTML = state.nodes.filter((node) => node.name.toLowerCase().includes(q)).map((node) => { const server = serverById(node.serverId); const on = customerFormState.pickerSelected.has(node.id); return `
${esc(node.name)}${esc(server ? server.name : '')} · ${esc(statusText[node.status] || node.status)}✓
`; }).join('') || '
无匹配节点
'; } function refreshPicker() { const box = $('[data-picker-box]'); if (!box) return; const chips = [...customerFormState.pickerSelected].map((id) => { const node = nodeById(id); return node ? `${esc(node.name)}` : ''; }).join(''); box.innerHTML = `${chips || '选择节点'}`; renderPickerList($('[data-picker-search]') ? $('[data-picker-search]').value : ''); } function credRowHTML(label, value) { return `
${esc(label)}${esc(value || '—')} ${value ? `` : ''}
`; } function renderCredentialsPane(customer) { const pane = $('#tab-credentials'); if (!pane) return; if (!customer) { pane.innerHTML = '
创建客户后生成凭据
'; return; } const data = customerFormState.credentials; if (!data) { pane.innerHTML = '
加载中…
'; return; } const groups = data.items.map((entry) => `
${esc(entry.nodeName)}${esc(statusText[entry.status] || entry.status)}${esc(entry.protocol)}
${credRowHTML('UUID', entry.clientId)} ${credRowHTML('密码', entry.password)} ${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''} ${entry.username ? credRowHTML('用户名', entry.username) : ''}
`).join(''); pane.innerHTML = `
订阅
订阅令牌${esc(data.subscriptionToken || '—')}
${groups || '
尚未关联节点
'}`; } function linkStatusLine(customer, source) { const status = customer && customer.externalStatus ? customer.externalStatus[source.id] : null; if (!status) return '尚未拉取'; const parts = [`${status.count || 0} 条`]; if (status.okAt) parts.push(`上次成功 ${relTime(status.okAt)}`); if (status.droppedCount) parts.push(`已忽略 ${status.droppedCount} 条`); if (status.lastError) parts.push(`${esc(status.lastError)}`); return parts.join(' · '); } function renderLinksPane(customer) { const pane = $('#tab-links'); if (!pane) return; const manual = customerFormState.manualLinks.map((link, index) => ``).join(''); const sources = customerFormState.externalSources.map((source, index) => ``).join(''); pane.innerHTML = `
${manual || ''}
${sources || ''}
`; } 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 ? '编辑客户' : '添加客户', `
流量重置
关联入站${pickerHTML(customerFormState.pickerSelected)}
${item ? `` : ''}
`, true); renderPickerList(); renderCredentialsPane(item); renderLinksPane(item); if (item) { api(`/api/customers/${item.id}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(item); }).catch((error) => { const pane = $('#tab-credentials'); if (pane) pane.innerHTML = `
${esc(error.message)}
`; }); } const modeSelect = $('[name="trafficResetMode"]'); const dayInput = $('[name="trafficResetDay"]'); const syncReset = () => { dayInput.disabled = modeSelect.value !== '1'; }; modeSelect.addEventListener('change', syncReset); syncReset(); } /* ---------- 订阅弹窗 ---------- */ function subscriptionModal(customer) { if (!customer || !customer.subscriptionToken) return toast('客户订阅令牌尚未生成,请刷新页面', true); const formats = [ ['auto', '自动识别客户端'], ['v2ray', 'V2Ray / v2rayNG'], ['shadowrocket', 'Shadowrocket 小火箭'], ['base64', '通用 Base64'], ['clash', 'Mihomo / Clash 简洁版'], ['clash-smart', 'Mihomo / Clash 智能分流'], ['singbox', 'sing-box JSON'], ['surge', 'Surge(仅兼容节点)'], ['raw', '原始节点链接'] ]; const infoUrl = `${location.origin}/s/${customer.subscriptionToken}/info`; const autoUrl = `${location.origin}/s/${customer.subscriptionToken}/auto`; const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => { const node = nodeById(nc.nodeId); if (!node || node.status !== 'active' || !nc.clientUri) return ''; const server = serverById(node.serverId); const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`; return `
${esc(label)}${esc(nc.clientUri.slice(0, 60))}...
`; }).join(''); openModal(`${customer.name} · 订阅`, `
📱 订阅信息页${esc(infoUrl)}
${formats.map(([format, label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')} ${nodeLinks ? `

各节点入站链接

${nodeLinks}` : ''}
`, true); const mainQr = $('[data-main-qr]'); if (mainQr && typeof qrcode === 'function') { const qr = qrcode(0, 'M'); qr.addData(autoUrl, 'Byte'); qr.make(); mainQr.innerHTML = qr.createSvgTag(4, 4); } } /* ---------- 客户详情(访问记录) ---------- */ async function customerAccessModal(customer) { const data = await api(`/api/customers/${customer.id}/access`); const ips = data.observedIps.map((entry) => `${esc(entry.ip)}${fmtDate(entry.firstSeenAt)}${fmtDate(entry.lastSeenAt)}`).join(''); const logs = data.events.map((entry) => `${fmtDate(entry.at)}${entry.status === 200 ? '已返回' : `${entry.status} ${esc(entry.reason || '')}`}${esc(entry.ip)}
${esc(entry.userAgent || '未提供 User-Agent')}
${esc(entry.format)}${fmtBytes(entry.bytes)}`).join(''); openModal(`${customer.name} · 详情`, `
状态${esc(statusText[customer.status] || customer.status)}${customer.status === 'suspended' ? ` · ${esc(suspendReasonText[customer.suspendReason] || '')}` : ''}
分组 / 标签${esc(customer.group || '—')} · ${esc((customer.tags || []).join('、') || '—')}
流量${fmtBytes(customer.usedBytes)} / ${customer.trafficLimitBytes ? fmtBytes(customer.trafficLimitBytes) : '不限'}(上行 ${fmtBytes(customer.usedUplinkBytes)} · 下行 ${fmtBytes(customer.usedDownlinkBytes)})
到期${customer.expiresAt ? fmtDate(customer.expiresAt) : '不限期'}
使用限制节点 IP ${data.observedIps.length} / ${data.limits.ip || '不限'} · 订阅客户端约 ${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}
最近活跃${customer.lastActiveAt ? relTime(customer.lastActiveAt) : '—'}
节点观测 IP
${ips || ''}
IP首次最后
当前窗口没有 Agent 上报
最近订阅请求(最多 100 条)
${logs || ''}
时间结果来源 / 客户端格式返回
暂无访问记录
`, true); } /* ---------- 服务器表单与维护弹窗 ---------- */ function serverForm(item = null) { const server = item || { role: 'hybrid', portRangeStart: 20000, portRangeEnd: 50000, labels: [] }; openModal(item ? '编辑设备' : '添加设备', `
`); } function enrollModal(serverId) { return api(`/api/servers/${serverId}/enrollment-token`, { method: 'POST', body: '{}' }).then((result) => { const command = `curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/main/scripts/agent-install.sh | bash -s -- --server ${location.origin} --token ${result.token}`; openModal('Agent 注册 / 重装命令', `
令牌 30 分钟内有效且只能使用一次。
${esc(command)}
`); }); } function uninstallModal() { const command = 'ng-agent uninstall'; const fallback = 'curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/main/scripts/agent-uninstall.sh | bash'; openModal('在目标服务器 SSH 卸载 Agent', `
先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止专属服务并移除密钥与节点配置;之后再删除设备登记。
${esc(command)}
${esc(fallback)}
`); } /* ---------- 转发与节点 ---------- */ function protoBadges(node) { const fam = (id) => { const v = String(id || ''); if (v.startsWith('vless')) return ['vless', 'VLESS']; if (v.startsWith('shadowsocks')) return ['ss', 'Shadowsocks']; if (v.startsWith('socks')) return ['mixed', 'Mixed']; if (v.startsWith('hysteria2')) return ['other', 'Hysteria2']; if (v.startsWith('anytls')) return ['other', 'AnyTLS']; return ['other', v || '—']; }; const [cls, label] = fam(node.protocol); const trans = node.protocol.includes('ws') ? 'WS' : node.protocol.startsWith('hysteria2') ? 'UDP' : node.protocol.startsWith('shadowsocks') ? 'TCP,UDP' : 'TCP'; const sec = /reality/.test(node.protocol) ? 'Reality' : /(tls|hysteria2|anytls)/.test(node.protocol) ? 'TLS' : ''; let html = `${esc(label)}${trans}${sec}`; if ((node.topology || 'forward') === 'forward' && node.exitProtocol) { const [eCls, eLabel] = fam(node.exitProtocol); html += `${esc(eLabel)}`; } return html; } function renderNodes() { const q = state.search.toLowerCase(); const filtered = state.nodes.filter((node) => [node.name, node.protocol, (serverById(node.serverId) || {}).name].join(' ').toLowerCase().includes(q)); const { pageItems } = pageSlice(filtered, 'nodes'); const active = state.nodes.filter((n) => n.status === 'active').length; const degraded = state.nodes.filter((n) => ['degraded', 'failed'].includes(n.status)).length; const rows = pageItems.map((node, index) => { const server = serverById(node.serverId); const chipCls = !server || server.status !== 'online' ? 'offline' : (node.status === 'degraded' || (server.engine && server.engine.status === 'error')) ? 'degraded' : 'online'; const total = node.clientCount != null ? node.clientCount : state.nodeClients.filter((nc) => nc.nodeId === node.id).length; const onlineCount = node.onlineCount || 0; const onlineNames = (node.onlineCustomerIds || []).map((id) => (customerById(id) || {}).name).filter(Boolean); const speed = (node.speedUpBps || 0) + (node.speedDownBps || 0); const metered = node.meteredTraffic ? (node.meteredTraffic.uplink || 0) + (node.meteredTraffic.downlink || 0) : 0; const enabled = !['draft', 'removing'].includes(node.status); const busy = ['deploying', 'removing'].includes(node.status); return ` ${ICONS.grip} ${(node.sortIndex != null ? node.sortIndex : index) + 1} ${esc(node.name)}${node.error ? `
${esc(String(node.error).slice(0, 40))}
` : ''} ${esc(server ? server.name : '已删除')} ${node.port || '—'} ${protoBadges(node)} ${ICONS.users}${total}${onlineCount} ${fmtBytes(metered)} / ∞ ${speed > 0 ? `↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)}` : '—'} ∞`; }).join(''); return `
${state.nodes.length} 个节点在线 ${active}${degraded ? `降级 ${degraded}` : ''}
${rows || emptyRow(13, '还没有节点')}
ID⇅菜单启用备注节点端口⇅协议客户端流量⇅速度到期时间
${pagerHTML('nodes', filtered.length)}
`; } function protocolOptions(role, current) { return state.protocols.filter((entry) => entry.role === role && entry.deployable).map((entry) => ``).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 ? '编辑节点' : '新建节点', `
出口与转发传输
`, true); syncNodeForm(); } function syncNodeForm() { const form = $('#node-form'); if (!form) return; const direct = form.elements.topology.value === 'direct'; $$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct)); form.elements.exitServerId.required = !direct; form.elements.exitProtocol.required = !direct; const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality)); form.elements.realityServerName.required = reality; for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) { const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct); form.elements[port].disabled = !fixed; form.elements[port].required = fixed; } } function nodeClientsModal(node) { const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id); const rows = assigned.map((nc) => { const customer = customerById(nc.customerId); return `${esc(customer ? customer.name : '已删除客户')} ${esc(statusText[nc.status] || nc.status)} ${nc.clientUri ? `` : '—'}`; }).join(''); openModal(`${node.name} · 客户端`, `
${rows || ''}
客户状态链接
暂无客户端
`, true); } function nodeMenuHTML(node) { const items = []; if (node.status === 'draft') items.push(``); if (['failed', 'degraded'].includes(node.status)) items.push(``); if (!['draft', 'removing'].includes(node.status)) items.push(``); items.push(``); items.push(``); return items.join(''); } /* ---------- 安装与命令 ---------- */ function renderCommands() { const groups = [ ['面板机', [['更新控制面', 'ng update'], ['检查面板 HTTPS', 'ng cert'], ['备份面板', 'ng backup']]], ['入口机 / 中转机', [['更新 Agent', 'ng-agent update'], ['申请或续签节点证书', 'ng-agent cert'], ['安装 sing-box 引擎', 'ng-agent engine install'], ['检查证书、引擎与双向计数', 'ng-agent doctor']]], ['出口机 / 落地机', [['更新 Agent', 'ng-agent update'], ['检查运行状态', 'ng-agent doctor']]] ]; return `
${groups.map(([title, commands]) => `

${esc(title)}

${commands.map(([label, command]) => `
${esc(label)}${esc(command)}
`).join('')}
`).join('')}
`; } /* ---------- 设置与运维 ---------- */ function renderOperations() { const rows = state.jobs.slice(0, 100).map((job) => `${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}
${esc(job.id)}
${esc((serverById(job.serverId) || {}).name || job.serverId)}${esc(statusText[job.status] || job.status)}${job.attempts}${fmtDate(job.updatedAt)}${esc(job.error || '')}`).join(''); return `

账号安全

备份与恢复

备份含客户凭据,请加密保管。
ng backup\nng restore

版本

amo当前版本
v${esc(state.version)}
Caddy HTTPS证书自动申请与续签
运行中
ng update\nng domain new.example.com\nng cert

任务队列

${rows || emptyRow(6, '任务队列为空')}
任务设备状态尝试更新时间错误
`; } function render() { const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, 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) }; 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 === 'add-server') serverForm(); else if (action === 'edit-server') serverForm(serverById(itemId)); else if (action === 'enroll-server') await enrollModal(itemId); else if (action === 'agent-uninstall') uninstallModal(); else if (action === 'expand-server') { if (state.expandedServers.has(itemId)) state.expandedServers.delete(itemId); else state.expandedServers.add(itemId); render(); } else if (action === 'toggle-addr') { state.addrVisible = !state.addrVisible; render(); } else if (action === 'retry-cleanup') { const result = await api(`/api/servers/${itemId}/cleanup`, { method: 'POST', body: '{}' }); toast(result.queued ? `已重新排队 ${result.queued} 个清理任务` : '清理任务已在队列中,请等待设备上线'); await load(); } else if (action === 'forget-server') { const item = serverById(itemId); if (!item) return; const name = await promptModal('遗忘离线设备', `仅在服务器永久下线、销毁,或已手工清除其代理配置时使用。强制遗忘会放弃 ${item.pendingCleanup} 项远程清理;旧节点若仍运行,配置可能继续监听。\n\n请输入设备名称「${item.name}」确认:`); if (name !== item.name) return; await api(`/api/servers/${itemId}/forget`, { method: 'POST', body: JSON.stringify({ confirm: 'FORGET', name }) }); toast('已强制遗忘设备'); await load(); } else if (action === 'delete-server') { if (!await confirmModal('删除控制台登记并吊销 Agent 密钥?如需彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。', '删除')) return; await api(`/api/servers/${itemId}`, { method: 'DELETE' }); toast('设备登记已删除'); await load(); } else if (action === 'add-customer') customerForm(); else if (action === 'edit-customer') customerForm(customerById(itemId)); else if (action === 'customer-subscription') subscriptionModal(customerById(itemId)); else if (action === 'customer-access') await customerAccessModal(customerById(itemId)); else if (action === 'customer-inbounds') { const item = customerById(itemId); if (!item) return; const chips = customerBindings(item.id).map((nc) => { const node = nodeById(nc.nodeId); if (!node) return ''; const fam = protocolFamily(node.protocol); return `${esc(node.name)}`; }).join(''); showPop(button, `
关联入站${customerBindings(item.id).length} 个
${chips}
`); } else if (action === 'clear-node-filter') { state.customerNodeFilter = ''; state.pages.customers = 1; render(); } else if (action === 'reset-access') { if (!await confirmModal('清空此客户的订阅访问日志和最近 24 小时的客户端估计数?节点 IP 观察记录不受影响。', '清空')) return; await api(`/api/customers/${itemId}/access`, { method: 'DELETE' }); toast('订阅访问窗口已清空'); await load('customers'); const item = customerById(itemId); if (item) await customerAccessModal(item); } else if (action === 'rotate-subscription') { if (!await confirmModal('旧订阅地址会立刻失效。已导入的节点凭据不会随订阅令牌重置,确认继续?', '重置')) return; const result = await api(`/api/customers/${itemId}/rotate-subscription`, { method: 'POST', body: '{}' }); state.customers = state.customers.map((entry) => entry.id === itemId ? result.customer : entry); toast('订阅地址已重置'); if ($('#customer-form')) { customerFormState.credentials = null; renderCredentialsPane(result.customer); api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {}); } else if (modalOpen()) subscriptionModal(result.customer); } else if (action === 'regen-credential') { const customerId = $('#customer-form').dataset.customerId; if (!await confirmModal(`重新生成「${button.dataset.node}」的凭据?该客户在此节点的现有连接将立即失效,需要重新导入订阅。`, '重新生成')) return; await api(`/api/customers/${customerId}/credentials/${button.dataset.ncid}/regenerate`, { method: 'POST', body: '{}' }); const data = await api(`/api/customers/${customerId}/credentials`); 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-reality-preset]')) { const option = target.selectedOptions[0]; const form = target.form; if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; } } else if (target.matches('[data-expiry-quick]') && target.value) { const date = new Date(); date.setDate(date.getDate() + Number(target.value)); const pad = (n) => String(n).padStart(2, '0'); target.form.elements.expiryDate.value = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; target.form.elements.expiryTime.value = '23:59'; } } catch (error) { toast(error.message, true); await load(); } }); document.addEventListener('input', (event) => { if (event.target.matches('[data-search]')) { state.search = event.target.value; state.pages = { servers: 1, customers: 1, nodes: 1 }; const pos = event.target.selectionStart; render(); const next = $('[data-search]'); if (next) { next.focus(); next.setSelectionRange(pos, pos); } } if (event.target.matches('[data-picker-search]')) renderPickerList(event.target.value); }); /* 节点在线名单浮层(悬停,浮层挂 body) */ document.addEventListener('mouseover', (event) => { const anchor = event.target.closest('[data-online-pop]'); const pop = $('#float-pop'); if (anchor) { if (pop._anchor === anchor && !pop.classList.contains('hidden')) return; const names = (anchor.dataset.names || '').split(',').filter(Boolean); if (!names.length) { hidePop(); return; } showPop(anchor, `
在线${names.length} 位客户
${names.map((name) => `
${esc(name)}
`).join('')}
依据 Agent 每分钟活跃上报
`); 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); })();