'use strict';
const state = {
session: null, page: 'overview', overview: null, servers: [], customers: [],
chains: [], deployments: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8'
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const esc = (value) => String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[char]);
const selected = (value, current) => String(value) === String(current) ? ' selected' : '';
const checked = (value, values) => (values || []).includes(value) ? ' checked' : '';
const fmtDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
const fmtBytes = (value) => {
let size = Number(value || 0); const units = ['B','KB','MB','GB','TB','PB']; let index = 0;
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
};
const oldUsageAgent = (version) => { const parts = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/); return parts &&
Number(parts[1]) === 0 && (Number(parts[2]) < 6 || (Number(parts[2]) === 6 && Number(parts[3]) < 3)); };
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:'已暂停', suspending:'暂停中', partially_suspended:'部分暂停', removing:'停用中', deleted:'已删除',
redeploying:'重建中', redeploy_pending:'等待重建', changes_pending:'待重新部署'
};
const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' };
const topologyText = { forward:'转发线路', direct:'单机直连' };
const deploymentRoleText = { relay:'客户端入口', exit:'出口传输', 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_chain:'创建线路', update_route:'编辑线路', deploy_route:'部署线路', redeploy_route:'重新部署线路', repair_route:'修复线路', remove_route:'停用线路',
delete_chain:'删除线路', retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接'
};
function setTheme(theme) {
const next = theme === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('nexusgate-theme', next); } catch { /* storage may be disabled */ }
document.querySelector('meta[name="theme-color"]').content = next === 'dark' ? '#0e1421' : '#f5f7fc';
document.querySelector('meta[name="color-scheme"]').content = next;
$$('[data-theme-toggle]').forEach((button) => { button.textContent = next === 'dark' ? '☾ 深色' : '☀ 浅色'; button.title = next === 'dark' ? '切换为浅色主题' : '切换为深色主题'; button.setAttribute('aria-pressed', String(next === 'dark')); });
}
function toast(message, error = false) {
const item = document.createElement('div');
item.className = `toast${error ? ' error' : ''}`; item.textContent = message;
$('#toasts').append(item); setTimeout(() => item.remove(), 3800);
}
async function api(path, options = {}) {
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.headers || {}) };
if (state.session && !['GET','HEAD'].includes(options.method || 'GET')) headers['x-csrf-token'] = state.session.csrf;
const response = await fetch(path, { ...options, headers });
const type = response.headers.get('content-type') || '';
const data = type.includes('json') ? await response.json() : await response.text();
if (!response.ok) {
if (response.status === 401 && path !== '/api/auth/login') showLogin();
throw new Error(data.message || `请求失败 (${response.status})`);
}
return data;
}
function showLogin() {
state.session = null; $('#app').classList.add('hidden'); $('#login').classList.remove('hidden');
}
function showApp() {
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
$('#user-chip').textContent = `${state.session.user.username} · 管理员`;
}
async function load(page = state.page) {
try {
if (page === 'overview') {
const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/chains')]);
state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
} else if (page === 'servers') state.servers = (await api('/api/servers')).servers;
else if (page === 'customers') state.customers = (await api('/api/customers')).customers;
else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
} else if (page === 'deployments') {
const [servers, customers, routes] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains')]);
state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments;
} else if (page === 'commands') { /* Command reference needs no API request. */ }
else if (page === 'operations') {
const [jobs, servers, overview] = await Promise.all([api('/api/jobs'), api('/api/servers'), api('/api/overview')]);
state.jobs = jobs.jobs; state.servers = servers.servers; state.overview = overview; state.version = overview.version || state.version;
}
render();
} catch (error) { toast(error.message, true); }
}
function setPage(page) {
state.page = page; state.search = ''; closeSidebar();
$$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page));
const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', deployments:'部署与链接', commands:'安装与命令', operations:'设置与运维' };
$('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`;
load(page);
}
function status(value) { return `${esc(statusText[value] || value)}`; }
function tags(values) { return (values || []).map((value) => `${esc(value)}`).join('') || '—'; }
function empty(title, note) { return `
${esc(title)}${esc(note)}
`; }
function names(ids, collection) { return (ids || []).map((key) => (collection.find((item) => item.id === key) || {}).name || '已删除').join('、'); }
function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; }
function renderOverview() {
const counts = (state.overview && state.overview.counts) || {};
const metrics = [
['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'],
['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'],
['编排线路', counts.chains || 0, '转发与单机节点', '#9667e8'],
['活动部署', counts.activeDeployments || 0, '实际运行资源', '#e8a63c'],
['待办任务', counts.queuedJobs || 0, 'Agent 自动领取', '#e5667c']
];
const serverRows = state.servers.slice(0, 7).map((server) => `${esc(server.name)}${esc(roleText[server.role] || server.role)} · ${esc(server.region || server.publicAddress)}
${status(server.status)}
`).join('');
const events = ((state.overview && state.overview.activity) || []).map((event) => `${esc(auditText[event.action] || event.action)}${esc(event.actor)} · ${esc(event.target)}
`).join('');
const failures = ((state.overview && state.overview.degraded) || []).length;
return `${metrics.map(([label,value,note,color]) => `${label}${value}${note}`).join('')}
${failures ? `检测到 ${failures} 个失败部署。请到“转发与节点”查看错误后使用“修复失败项”。更新 Agent 后无需重建节点。
` : ''}
${serverRows || empty('还没有设备','先添加入口、出口或综合节点')}
${events || empty('暂无活动','操作记录会显示在这里')}
`;
}
function renderServers() {
const q = state.search.toLowerCase();
const resources = (system) => {
if (!system || !Number.isFinite(system.memoryTotal)) return '';
const memory = Number(system.memoryAvailable ?? system.memoryFree);
const disk = Number(system.diskAvailable);
const warning = memory < 200 * 1024 * 1024 || (Number.isFinite(disk) && disk < 1024 ** 3);
const text = `内存可用 ${fmtBytes(memory)} / ${fmtBytes(system.memoryTotal)} · 负载 ${esc(system.load1 ?? '—')} / ${esc(system.cpuCount ?? '—')} 核`
+ (Number.isFinite(disk) ? ` · 系统盘可用 ${fmtBytes(disk)} / ${fmtBytes(system.diskTotal)}` : '');
return `${text}${warning ? ' · 资源余量偏低' : ''}`;
};
const rows = state.servers.filter((item) => [item.name,item.region,item.publicAddress,item.publicAddressV6,...(item.labels || [])].join(' ').toLowerCase().includes(q)).map((server) => `
| ${esc(server.name)}${esc(server.publicAddress)}${server.publicAddressV6 ? ` · ${esc(server.publicAddressV6)}` : ''} | ${esc(roleText[server.role] || server.role)} | ${esc(server.region || '未分组')} |
${tags(server.labels)} | ${status(server.status)}${server.lastSeenAt ? `最后上报 ${fmtDate(server.lastSeenAt)}` : '等待 Agent 注册'} · Agent ${esc(server.agentVersion || '未上报版本')}${resources(server.system)}${server.tlsDomain ? `节点证书 ${esc(server.tlsDomain)} · ${server.engine?.certificates?.includes(server.tlsDomain) ? '已确认' : '待确认'}` : ''}${server.role !== 'exit' && server.engine ? `AnyTLS 引擎 · ${server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)'}` : ''}${oldUsageAgent(server.agentVersion) ? '旧版流量采集有问题,请在该机运行 ng-agent-update' : ''}${server.usage ? `流量统计 ${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项` : '尚无流量统计上报'}${server.usage?.error ? `统计异常:${esc(server.usage.error)}` : ''}${server.engine && server.engine.status === 'error' ? `引擎故障:${esc(server.engine.detail)}` : ''}${server.pendingCleanup ? `待清理 ${server.pendingCleanup} 项` : ''} |
${rowActions(server.id, ``, [
['agent-uninstall','SSH 卸载'], ...(server.pendingCleanup ? [['retry-cleanup','重试清理']] : []),
server.pendingCleanup && server.status !== 'online' ? ['forget-server','遗忘离线设备'] : ['delete-server','删除设备']
])} |
`).join('');
return `统一管理入口转发机、出口落地机与单机节点。Agent 主动连接控制面,不保存设备 SSH 密码;重装 Agent 会自动对账并恢复已有资源。
| 设备 | 用途 | 地区 | 标签 | 状态 | 操作 |
${rows || `| ${empty('没有匹配设备','添加设备后生成一次性注册命令')} |
`}
`;
}
function renderCustomers() {
const q = state.search.toLowerCase();
const rows = state.customers.filter((item) => [item.name,item.group,...(item.tags || [])].join(' ').toLowerCase().includes(q)).map((item) => {
const percent = item.trafficLimitBytes ? Math.min(100, Math.round(item.usedBytes / item.trafficLimitBytes * 100)) : 0;
return `| ${esc(item.name)}${esc(item.group || '未分组')} | ${status(item.status)}${item.status === 'suspended' ? `${esc(({ traffic_limit:'额度耗尽', expired:'已到期', ip_limit:'节点 IP 超限', manual:'手动停用' })[item.suspendReason] || '待恢复')}` : ''} |
${fmtBytes(item.usedBytes)} / ${item.trafficLimitBytes ? fmtBytes(item.trafficLimitBytes) : '不限'}上行 ${fmtBytes(item.usedUplinkBytes)} · 下行 ${fmtBytes(item.usedDownlinkBytes)} ${item.lastUsageAt ? `最后计量 ${fmtDate(item.lastUsageAt)}` : '尚未收到入口流量统计'} |
${item.expiresAt ? fmtDate(item.expiresAt) : '不限期'} | 节点 IP ${item.observedIpCount || 0} / ${item.ipLimit || '不限'}订阅客户端约 ${item.subscriptionClientCount || 0} / ${item.deviceLimit || '不限'} | ${tags(item.tags)} |
${rowActions(item.id, ``, [['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}${item.pendingCleanup ? `待清理 ${item.pendingCleanup} 项` : ''} |
`;
}).join('');
return `流量按入口上行+下行双向累计,Agent 约每分钟上报。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复原节点;待 Agent 完成恢复任务,订阅才可更新。旧版已经变为草稿的线路可点“恢复原节点”。
| 客户 | 状态 | 流量 | 到期 | 使用限制 | 标签 | 操作 |
${rows || `| ${empty('还没有客户','先创建客户,再编排线路')} |
`}
`;
}
function chainActions(chain) {
const edit = ``;
const remove = ``;
if (chain.status === 'draft') return `${edit}${chain.generation > 0 ? `` : ``}${remove}`;
if (chain.status === 'suspended') return `${edit}${state.customers.some((item) => chain.customerIds.includes(item.id) && item.status === 'active') ? `` : ''}${remove}`;
if (chain.status === 'changes_pending') return `${edit}`;
if (chain.status === 'degraded') return `${edit}${remove}`;
if (['active','deploying','suspending','partially_suspended'].includes(chain.status)) return `${edit}`;
return `${edit}${remove}`;
}
function rowActions(id, primary, more) {
return `${primary}
`;
}
function compactChainActions(chain) {
const actions = chainActions(chain).match(/