agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.
panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
1352 lines
106 KiB
JavaScript
1352 lines
106 KiB
JavaScript
'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: '<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 === '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 `<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) || [];
|
||
const events = ((state.overview && state.overview.activity) || []).map((event) => `<div class="event"><i class="event-dot"></i><div><b>${esc(auditText[event.action] || event.action)}</b> <small>${esc(event.actor)} · ${esc(event.target)}</small></div><time>${fmtDate(event.at)}</time></div>`).join('');
|
||
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)} ↓ ${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>` : ''}
|
||
<section class="panel"><div class="panel-head"><h2>最近活动</h2></div><div class="activity-list">${events || '<div class="empty"><b>暂无活动</b></div>'}</div></section>`;
|
||
}
|
||
|
||
/* ---------- 服务器 ---------- */
|
||
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 groups = data.items.map((entry) => `<div class="cred-group">
|
||
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span>
|
||
<button type="button" class="icon-btn" style="margin-left:auto" data-tip="重新生成凭据" data-action="regen-credential" data-ncid="${esc(entry.nodeClientId)}" data-node="${esc(entry.nodeName)}">${ICONS.reset}</button></div>
|
||
${credRowHTML('UUID', entry.clientId)}
|
||
${credRowHTML('密码', entry.password)}
|
||
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
|
||
${entry.username ? credRowHTML('用户名', entry.username) : ''}
|
||
</div>`).join('');
|
||
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>
|
||
${groups || '<div class="empty"><b>尚未关联节点</b></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"><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>
|
||
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
|
||
<div class="section-title" data-forward-only>出口与转发传输</div>
|
||
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select></label>
|
||
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random', node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, 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 `<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 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 `<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>
|
||
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table><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, 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 `<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 === '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, `<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);
|
||
})();
|