diff --git a/public/app.js b/public/app.js index 21733ad..f097084 100644 --- a/public/app.js +++ b/public/app.js @@ -1,69 +1,122 @@ 'use strict'; +/* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 / + 安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */ + const state = { - session: null, page: 'overview', overview: null, servers: [], customers: [], - nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8' + 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 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; + 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 fmtSpeed = (value) => { + const v = Math.round(Number(value || 0)); + if (v <= 0) return '0 B/s'; + if (v < 1024) return `${v} B/s`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(v >= 10240 ? 0 : 1)} KB/s`; + if (v < 1024 * 1024 * 1024) return `${(v / 1048576).toFixed(2)} MB/s`; + return `${(v / 1073741824).toFixed(2)} GB/s`; +}; +const fmtUptime = (seconds) => { + const s = Math.max(0, Math.floor(Number(seconds || 0))); + const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +}; +const relTime = (value) => { + if (!value) return '—'; + const diff = Date.now() - new Date(value).getTime(); + if (!Number.isFinite(diff)) return '—'; + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; + return fmtDate(value); +}; +const versionParts = (v) => (String(v || '').match(/^(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number); +const versionLessThan = (a, b) => { + const pa = versionParts(a); const pb = versionParts(b); + if (pa.length < 3) return false; + for (let i = 0; i < 3; i += 1) { if (pa[i] !== pb[i]) return pa[i] < pb[i]; } + return false; +}; +const oldUsageAgent = (version) => { const p = versionParts(version); return p.length === 3 && p[0] === 0 && (p[1] < 6 || (p[1] === 6 && p[2] < 3)); }; +const splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean); +const statusText = { + online: '在线', offline: '离线', pending: '待注册', active: '运行中', draft: '草稿', deploying: '部署中', queued: '排队中', + running: '执行中', completed: '已完成', failed: '失败', degraded: '异常', suspended: '已暂停', removing: '停用中', deleted: '已删除' +}; +const roleText = { relay: '入口 / 转发', exit: '出口 / 落地', hybrid: '综合节点' }; +const topologyText = { forward: '转发线路', direct: '单机直连' }; +const auditText = { + create_server: '添加设备', update_server: '编辑设备', delete_server: '删除设备', create_enrollment: '生成注册令牌', enroll_agent: 'Agent 注册', + reconcile_server: '设备配置对账', set_tls_domain: '节点证书同步', create_customer: '添加客户', update_customer: '编辑客户', reset_customer_usage: '重置客户流量', delete_customer: '删除客户', + create_node: '创建节点', update_node: '编辑节点', deploy_node: '部署节点', repair_node: '修复节点', remove_node: '停用节点', reorder_nodes: '节点排序', + delete_node: '删除节点', assign_customer: '分配客户', unassign_customer: '移除分配', suspend_customer_resources: '暂停客户资源', resume_customer_resources: '恢复客户资源', + retry_cleanup: '重试遗留清理', force_forget_server: '强制遗忘离线设备', update_account: '修改管理员账号', suspend_ip_limit: 'IP 超限停用', rotate_subscription: '重置订阅链接', + regenerate_credentials: '重新生成凭据', monthly_reset: '按月重置流量' +}; +const suspendReasonText = { traffic_limit: '额度耗尽', expired: '已到期', ip_limit: '节点 IP 超限', manual: '手动停用' }; + +const ICONS = { + qr: '', + info: '', + reset: '', + edit: '', + trash: '', + copy: '', + dots: '', + grip: '', + users: '', + eye: '', + eyeOff: '', + warn: '' +}; + async function copyText(text) { text = String(text ?? ''); if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } - // HTTP 下 clipboard API 不可用,降级用 execCommand。 - // 注意:弹窗是 模态的,此时 document.body 处于 inert 状态, - // textarea 必须插进弹窗内部,否则 select/execCommand 会复制走弹窗内的当前选区。 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 dialog = $('#modal'); - const host = (dialog && dialog.open) ? dialog : document.body; + 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) {} + 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('复制失败,请手动长按复制'); } -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:'停用节点', - 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:'重置订阅链接' -}; 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')); }); + 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) { @@ -74,13 +127,13 @@ function toast(message, error = false) { 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; + 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})`); + throw new Error((data && data.message) || `请求失败 (${response.status})`); } return data; } @@ -90,369 +143,405 @@ function showLogin() { } function showApp() { $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); - $('#user-chip').textContent = `${state.session.user.username} · 管理员`; + $('#user-chip').textContent = state.session.user.username; + $('#avatar').textContent = (state.session.user.username || '管').slice(0, 1).toUpperCase(); + $('#version-label').textContent = `v${state.version}`; } +/* ---------- 弹窗 ---------- */ +function openModal(title, bodyHTML, wide = false) { + $('#modal-title').textContent = title; + $('#modal-body').innerHTML = bodyHTML; + $('.modal', $('#modal-overlay')).classList.toggle('wide', !!wide); + $('#modal-overlay').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; +} +function closeModal() { + $('#modal-overlay').classList.add('hidden'); + $('#modal-body').innerHTML = ''; + document.body.style.overflow = ''; +} +function modalOpen() { return !$('#modal-overlay').classList.contains('hidden'); } + +function confirmModal(message, confirmLabel = '确认') { + return new Promise((resolve) => { + openModal('请确认', `

${esc(message)}

+
`); + $('[data-confirm-yes]').addEventListener('click', () => { closeModal(); resolve(true); }); + $('[data-confirm-no]').addEventListener('click', () => { closeModal(); resolve(false); }); + }); +} +function promptModal(title, message, placeholder = '') { + return new Promise((resolve) => { + openModal(title, `

${esc(message)}

+ +
`); + const input = $('[name="prompt-value"]'); input.focus(); + $('[data-prompt-yes]').addEventListener('click', () => { const v = input.value; closeModal(); resolve(v); }); + $('[data-prompt-no]').addEventListener('click', () => { closeModal(); resolve(null); }); + }); +} + +/* ---------- body 浮层 ---------- */ +function showPop(anchor, html) { + const pop = $('#float-pop'); + pop.innerHTML = html; + pop.classList.remove('hidden'); + const rect = anchor.getBoundingClientRect(); + const width = pop.offsetWidth; + let left = Math.min(rect.left, window.innerWidth - width - 10); + if (left < 8) left = 8; + pop.style.left = `${left}px`; + pop.style.top = `${rect.bottom + 6}px`; + pop._anchor = anchor; +} +function hidePop() { const pop = $('#float-pop'); pop.classList.add('hidden'); pop.innerHTML = ''; pop._anchor = null; } + +/* ---------- 分页 ---------- */ +function pageSlice(items, key) { + const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); + if (state.pages[key] > totalPages) state.pages[key] = totalPages; + const page = state.pages[key]; + return { pageItems: items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), page, totalPages }; +} +function pagerHTML(key, total) { + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(state.pages[key], totalPages); + const nums = []; + for (let i = 1; i <= totalPages; i += 1) { + if (i === 1 || i === totalPages || Math.abs(i - page) <= 2) nums.push(i); + else if (nums[nums.length - 1] !== '…') nums.push('…'); + } + return `
${total} 条${nums.map((n) => n === '…' ? '…' : ``).join('')}
`; +} + +/* ---------- 数据加载与路由 ---------- */ async function load(page = state.page) { try { if (page === 'overview') { - const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]); - state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; - } else if (page === 'servers') state.servers = (await api('/api/servers')).servers; - else if (page === 'customers') { + 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.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.nodes = routes.nodes; state.nodeClients = routes.nodeClients; - } - else if (page === 'chains') { + } 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.nodes = routes.nodes; state.nodeClients = routes.nodeClients; state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; - } else if (page === 'commands') { /* Command reference needs no API request. */ } + } else if (page === 'commands') { /* 静态页 */ } 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; + 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']; -function pageFromHash() { const page = location.hash.replace(/^#/, ''); return PAGES.includes(page) ? page : 'overview'; } +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 = ''; closeSidebar(); + 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 button').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); - const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', commands:'安装与命令', operations:'设置与运维' }; - $('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`; + $$('#nav .mi').forEach((item) => item.classList.toggle('active', item.dataset.page === page)); + $('#page-title').textContent = 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 emptyRow(colspan, title) { return `
${esc(title)}
`; } function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; } +function nodeById(id) { return state.nodes.find((item) => item.id === id); } +function customerById(id) { return state.customers.find((item) => item.id === id); } +function serverById(id) { return state.servers.find((item) => item.id === id); } -function renderOverview() { - const counts = (state.overview && state.overview.counts) || {}; - const metrics = [ - ['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'], - ['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'], - ['共享节点', counts.nodes || 0, '多客户共用入口', '#9667e8'], - ['运行中节点', counts.activeNodes || 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('暂无活动','操作记录会显示在这里')}
`; +/* ---------- SVG 走势线 ---------- */ +function sparkSVG(values, id, fixedMax = null) { + const pts = (values || []).filter((v) => v != null && Number.isFinite(v)); + if (pts.length < 2) return '
'; + const max = fixedMax || Math.max(...pts, 1); + const min = fixedMax ? 0 : Math.min(...pts, 0); + const span = (max - min) || 1; + const stepX = 260 / (pts.length - 1); + const coords = pts.map((v, i) => `${(i * stepX).toFixed(1)},${(42 - ((v - min) / span) * 38).toFixed(1)}`); + const line = `M${coords.join(' L')}`; + const mean = pts.reduce((a, b) => a + b, 0) / pts.length; + const meanY = (42 - ((mean - min) / span) * 38).toFixed(1); + return ``; +} +function dualChartSVG(history) { + const pts = history || []; + if (pts.length < 2) return '
'; + const max = Math.max(1, ...pts.map((p) => Math.max(p.upBps || 0, p.downBps || 0))); + const stepX = 880 / (pts.length - 1); + const line = (key) => `M${pts.map((p, i) => `${(i * stepX).toFixed(1)},${(228 - ((p[key] || 0) / max) * 200).toFixed(1)}`).join(' L')}`; + const upLine = line('upBps'); + return ``; +} +function connChartSVG(history) { + const pts = history || []; + if (pts.length < 2) return '
'; + const values = pts.map((p) => p.connections || 0); + const max = Math.max(...values, 1); + const stepX = 420 / (pts.length - 1); + const line = `M${values.map((v, i) => `${(i * stepX).toFixed(1)},${(170 - (v / max) * 140).toFixed(1)}`).join(' L')}`; + return ``; } +/* ---------- 系统状态 ---------- */ +function renderOverview() { + const sys = state.system; + if (!sys) return '
暂无数据
'; + const host = sys.host || {}; const net = sys.network || {}; const history = sys.history || []; + const cpuHist = history.map((p) => p.cpu); const memHist = history.map((p) => p.mem); + const trafHist = history.map((p) => (p.upBps || 0) + (p.downBps || 0)); + const statCard = (icon, label, scope, big, unit, det, mm, spark) => `
+
${icon}${esc(label)}${esc(scope)}
+
${big}${esc(unit)}
${det}
+
${mm}
${spark}
`; + const gaugeIcon = ''; + const memIcon = ''; + const diskIcon = ''; + const trafIcon = ''; + const cpuVals = cpuHist.filter((v) => v != null); + const cpuMm = cpuVals.length ? `均值 ${(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length).toFixed(0)}%峰值 ${Math.max(...cpuVals).toFixed(0)}%` : '—'; + const memVals = memHist.filter((v) => v != null); + const memMm = memVals.length ? `均值 ${(memVals.reduce((a, b) => a + b, 0) / memVals.length).toFixed(0)}%峰值 ${Math.max(...memVals).toFixed(0)}%` : '—'; + const todayTotal = (sys.today.upBytes || 0) + (sys.today.downBytes || 0); + const peakSpeed = trafHist.length ? Math.max(...trafHist) : 0; + const cards = [ + statCard(gaugeIcon, 'CPU', '主控', host.cpuPercent == null ? '—' : host.cpuPercent.toFixed(1), '%', `${host.cpuCount || '—'} 核 · 负载 ${host.load1 != null ? Number(host.load1).toFixed(2) : '—'}`, cpuMm, sparkSVG(cpuHist, 'gCpu', 100)), + statCard(memIcon, '内存', '主控', host.memPercent == null ? '—' : host.memPercent.toFixed(1), '%', `${fmtBytes(host.memUsed)} / ${fmtBytes(host.memTotal)}`, memMm, sparkSVG(memHist, 'gMem', 100)), + statCard(diskIcon, '存储', '主控', host.diskPercent == null ? '—' : host.diskPercent.toFixed(1), '%', `${fmtBytes(host.diskUsed)} / ${fmtBytes(host.diskTotal)}`, `剩余 ${fmtBytes((host.diskTotal || 0) - (host.diskUsed || 0))}`, sparkSVG(history.map(() => host.diskPercent), 'gDisk', 100)), + statCard(trafIcon, '今日流量', '全网汇总', (() => { const [v, u] = fmtBytes(todayTotal).split(' '); return v; })(), (() => { const p = fmtBytes(todayTotal).split(' '); return p[1] || ''; })(), `↑ ${fmtBytes(sys.today.upBytes)} · ↓ ${fmtBytes(sys.today.downBytes)}`, `累计 ${fmtBytes((sys.totals.upBytes || 0) + (sys.totals.downBytes || 0))}峰值 ${fmtSpeed(peakSpeed)}`, sparkSVG(trafHist, 'gTraf')) + ].join(''); + const meanUp = history.length ? history.reduce((a, p) => a + (p.upBps || 0), 0) / history.length : 0; + const meanDown = history.length ? history.reduce((a, p) => a + (p.downBps || 0), 0) / history.length : 0; + const degraded = (state.overview && state.overview.degraded) || []; + const events = ((state.overview && state.overview.activity) || []).map((event) => `
${esc(auditText[event.action] || event.action)} ${esc(event.actor)} · ${esc(event.target)}
`).join(''); + return `
${cards}
+
+
+

整体速度

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

连接数

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

最近活动

${events || '
暂无活动
'}
`; +} + +/* ---------- 服务器 ---------- */ +function maskAddr(addr) { + const value = String(addr || ''); + if (!value) return '—'; + if (/^\d+\.\d+\.\d+\.\d+$/.test(value)) { const p = value.split('.'); return `${p[0]}.${p[1]}.*.*`; } + const parts = value.split('.'); + if (parts.length >= 3) return `${parts[0]}.****.${parts[parts.length - 1]}`; + if (parts.length === 2) return `${parts[0].slice(0, 2)}****.${parts[1]}`; + return value; +} +function serverClients(serverId) { + const serverNodes = state.nodes.filter((n) => n.serverId === serverId); + const ids = new Set(serverNodes.map((n) => n.id)); + const total = state.nodeClients.filter((nc) => ids.has(nc.nodeId)).length; + const online = new Set(); + for (const node of serverNodes) for (const id of node.onlineCustomerIds || []) online.add(id); + return { total, online: online.size }; +} +function serverDetailRow(server) { + const system = server.system || {}; + const memAvail = Number(system.memoryAvailable ?? system.memoryFree); + const resources = Number.isFinite(system.memoryTotal) + ? `内存可用 ${fmtBytes(memAvail)} / ${fmtBytes(system.memoryTotal)} · 负载 ${system.load1 ?? '—'} / ${system.cpuCount ?? '—'} 核${Number.isFinite(system.diskAvailable) ? ` · 系统盘可用 ${fmtBytes(system.diskAvailable)} / ${fmtBytes(system.diskTotal)}` : ''}${memAvail < 200 * 1024 * 1024 || (Number.isFinite(system.diskAvailable) && system.diskAvailable < 1024 ** 3) ? ' · 资源余量偏低' : ''}` + : '—'; + const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置'; + const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报'); + const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · 异常:${esc(server.usage.error)}` : ''}` : '尚无上报'; + return `
+
资源余量${resources}
+
TLS 证书(${esc(server.tlsDomain || '—')})${tls}
+
引擎状态${engine}
+
流量统计${usage}
+
系统${esc([system.hostname, system.platform, system.release, system.arch].filter(Boolean).join(' · ') || '—')}
+
用途 / 地区 / 标签${esc(roleText[server.role] || server.role)} · ${esc(server.region || '未分组')} · ${esc((server.labels || []).join('、') || '—')}
+ ${server.engine && server.engine.status === 'error' ? `
引擎故障${esc(server.engine.detail || '')}
` : ''} + ${oldUsageAgent(server.agentVersion) ? '
Agent旧版流量采集有问题,请在该机运行 ng-agent update
' : ''} +
+
+ ${server.pendingCleanup ? `` : ''} + ${server.pendingCleanup && server.status !== 'online' ? `` : ''} + +
`; +} function renderServers() { const q = state.search.toLowerCase(); - const 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 会自动对账并恢复已有资源。

-
${state.servers.length} 台
-
${rows || ``}
设备用途地区标签状态操作
${empty('没有匹配设备','添加设备后生成一次性注册命令')}
`; + const onlineCount = state.servers.filter((s) => s.status === 'online').length; + const stats = `
+
设备总数
${state.servers.length}
+
在线
${onlineCount}
+
离线
${state.servers.length - onlineCount}
+
平均延迟
—
+
`; + const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q)); + const { pageItems } = pageSlice(filtered, 'servers'); + const rows = pageItems.map((server) => { + const system = server.system || {}; + const cpuPct = Number.isFinite(system.load1) && system.cpuCount ? Math.min(100, (system.load1 / system.cpuCount) * 100) : null; + const memPct = Number.isFinite(system.memoryTotal) && system.memoryTotal > 0 ? (1 - Number(system.memoryAvailable ?? system.memoryFree) / system.memoryTotal) * 100 : null; + const clients = serverClients(server.id); + const expanded = state.expandedServers.has(server.id); + const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress)); + const row = ` + + +
+ + ${esc(server.name)} + ${addr} + ${server.status === 'online' ? '在线' : '离线'}${server.pendingCleanup ? ` ${ICONS.warn}` : ''} + ${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`} + ${memPct == null ? '—' : `${memPct.toFixed(1)}%`} + ${esc(server.agentVersion || '—')}${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '有可用更新' : ''} + — + ${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'} + ${ICONS.users}${clients.total}${clients.online} + — + ${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}`; + return expanded ? row + serverDetailRow(server) : row; + }).join(''); + return `${stats} +
+
+ ${rows || emptyRow(15, '没有匹配设备')}
操作已启用名称地址状态CPU内存Agent 版本核心版本运行时长客户端延迟上次心跳
${pagerHTML('servers', filtered.length)}
`; } +/* ---------- 客户与订阅 ---------- */ +function protocolFamily(protocol) { + const id = String(protocol || ''); + if (id.startsWith('vless')) return { cls: '', label: 'VLESS' }; + if (id.startsWith('shadowsocks')) return { cls: 'ss', label: 'SS' }; + if (id.startsWith('socks')) return { cls: 'mixed', label: 'Mixed' }; + if (id.startsWith('hysteria2')) return { cls: '', label: 'HY2' }; + if (id.startsWith('anytls')) return { cls: '', label: 'AnyTLS' }; + return { cls: '', label: id || '—' }; +} +function customerBindings(customerId) { + return state.nodeClients.filter((nc) => nc.customerId === customerId) + .slice().sort((a, b) => ((nodeById(a.nodeId) || {}).sortIndex ?? 1e9) - ((nodeById(b.nodeId) || {}).sortIndex ?? 1e9)); +} +function trafficPill(item) { + const used = fmtBytes(item.usedBytes); + if (!item.trafficLimitBytes) return `${used} / 不限`; + const pct = Math.min(100, (item.usedBytes / item.trafficLimitBytes) * 100); + const cls = pct >= 90 ? ' danger' : pct >= 70 ? ' warn' : ''; + return `${used}${fmtBytes(item.trafficLimitBytes)}`; +} +function remainingPill(item) { + if (!item.trafficLimitBytes) return '不限'; + const left = item.trafficLimitBytes - item.usedBytes; + if (left <= 0) return '0 B'; + return `${fmtBytes(left)}`; +} +function durationPill(item) { + if (!item.expiresAt) return '不限期'; + const diff = new Date(item.expiresAt).getTime() - Date.now(); + if (diff <= 0) return '已过期'; + const days = Math.floor(diff / 86400000); + if (days >= 1) return `${days}天后`; + const hours = Math.max(1, Math.floor(diff / 3600000)); + return `${hours}小时后`; +} function renderCustomers() { const q = state.search.toLowerCase(); - const 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, ``, [['assign-nodes','分配节点'],['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}`; + const groups = [...new Set(state.customers.map((c) => c.group).filter(Boolean))]; + let filtered = state.customers.filter((item) => { + if (state.groupFilter && item.group !== state.groupFilter) return false; + if (state.customerNodeFilter && !state.nodeClients.some((nc) => nc.customerId === item.id && nc.nodeId === state.customerNodeFilter)) return false; + return [item.name, item.group, item.notes, ...(item.tags || [])].join(' ').toLowerCase().includes(q); + }); + const { pageItems } = pageSlice(filtered, 'customers'); + const filterNode = state.customerNodeFilter ? nodeById(state.customerNodeFilter) : null; + const rows = pageItems.map((item) => { + const bindings = customerBindings(item.id); + const first = bindings[0]; + const firstNode = first ? nodeById(first.nodeId) : null; + const fam = firstNode ? protocolFamily(firstNode.protocol) : null; + const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0); + const manualOff = item.status === 'suspended' && item.suspendReason === 'manual'; + return ` +
+ + ${item.online ? '在线' : '离线'}${item.status === 'suspended' ? `已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}` : ''} + ${esc(item.name)}${item.notes ? `${esc(item.notes)}` : ''} + ${firstNode ? `${esc(firstNode.name)}` : '—'}${bindings.length > 1 ? `` : ''} + ${trafficPill(item)} + ${speedTotal > 0 ? `${fmtSpeed(speedTotal)}` : '—'} + ${remainingPill(item)} + ${durationPill(item)}`; }).join(''); - return `

流量按入口单向累计,Agent 约每分钟上报,按客户跨节点汇总。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复;待 Agent 完成恢复任务,订阅才可更新。

-
${state.customers.length} 位
-
${rows || ``}
客户状态流量到期使用限制标签操作
${empty('还没有客户','先创建客户,再到“转发与节点”分配')}
`; -} - -function nodeActions(node) { - const edit = ``; - const clients = ``; - const remove = ``; - if (node.status === 'draft') return `${edit}${clients}${remove}`; - if (['failed', 'degraded'].includes(node.status)) return `${edit}${clients}${remove}`; - if (['active', 'deploying'].includes(node.status)) return `${edit}${clients}${remove}`; - return `${edit}${clients}${remove}`; -} - -function rowActions(id, primary, more) { - return `
${primary}
`; -} - -function compactNodeActions(node) { - const actions = nodeActions(node).match(/]*>[^<]*<\/button>/g) || []; - const primary = actions.slice(0, 2).join(''); - const more = actions.slice(2).map((html) => [(html.match(/data-action="([^"]+)"/) || [,''])[1], html.replace(/<[^>]+>/g, '')]); - return more.length ? rowActions(node.id, primary, more) : `
${primary}
`; -} - -function nodeClientCount(nodeId) { - return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length; -} - -function renderNodes() { - const rows = state.nodes.map((node) => { - const direct = (node.topology || 'forward') === 'direct'; - const path = direct ? protocolName(node.protocol, 'relay-ingress') : `${protocolName(node.protocol, 'relay-ingress')} → ${protocolName(node.exitProtocol, 'exit-transport')}`; - const server = state.servers.find((item) => item.id === node.serverId); - const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === node.exitServerId) || {}).name || '已删除'); - const traffic = node.meteredTraffic ? `上行 ${fmtBytes(node.meteredTraffic.uplink)} · 下行 ${fmtBytes(node.meteredTraffic.downlink)}` : '等待入口计量上报'; - return `${esc(node.name)}${esc(topologyText[node.topology || 'forward'])} · ${esc(path)} - ${esc(server ? server.name : '已删除')}${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}${esc(exit)} - ${nodeClientCount(node.id)} 位${traffic}${node.lastUsageAt ? `最后上报 ${fmtDate(node.lastUsageAt)}` : ''} - ${status(node.status)}${node.error ? `${esc(node.error)}` : ''}${compactNodeActions(node)}`; - }).join(''); - return `

一个节点是一个共享入口(对标 3x-ui 的 inbound):多位客户共用同一端口,各自使用独立凭据。分配 / 移除客户后节点自动重建下发;编辑运行中的节点会立即重建应用。

-
${rows || ``}
节点服务器 / 入口出口客户端流量状态操作
${empty('还没有节点','准备好设备和客户后创建第一个共享节点')}
`; -} - -function renderNodeLinks() { - // 按节点分组:一个节点一行,客户收纳为可展开列表 - const byNode = new Map(); - for (const nc of state.nodeClients) { - if (!byNode.has(nc.nodeId)) byNode.set(nc.nodeId, []); - byNode.get(nc.nodeId).push(nc); - } - const rows = [...byNode].map(([nodeId, ncs]) => { - const node = state.nodes.find((item) => item.id === nodeId); - const server = node && state.servers.find((item) => item.id === node.serverId); - const nodeActive = node && node.status === 'active'; - const customerRows = ncs.map((nc) => { - const customer = state.customers.find((item) => item.id === nc.customerId); - const canCopy = nc.status === 'active' && nodeActive && nc.clientUri && customer && customer.status === 'active'; - const name = esc(customer ? customer.name : '已删除客户'); - const st = status(nc.status); - const link = canCopy ? `` : '未运行或已停用'; - return ``; - }).join(''); - const names = ncs.map((nc) => { - const c = state.customers.find((item) => item.id === nc.customerId); - return esc(c ? c.name : '?'); - }).join('、'); - return `${esc(node ? node.name : '已删除节点')}${esc(server ? server.name : '')}${node ? ` :${node.port}` : ''} -
${names}(${ncs.length})
- ${node ? status(node.status) : status('draft')} - ${ncs.length} 个分配`; - }).join(''); - return `

每个客户在每个已分配节点上拥有独立凭据。客户端链接只在节点运行中且客户正常时生成;日常分发请使用客户页的订阅链接。

-
${rows || ``}
节点客户状态链接
${empty('暂无分配','先在“转发与节点”给客户分配节点')}
`; -} - -function renderCommands() { - const groups = [ - ['面板机', [ - ['更新控制面', 'ng update', '只在面板机运行;更新后刷新浏览器。'], - ['检查面板 HTTPS', 'ng cert', '只检查面板的 Caddy 证书,不会申请入口节点证书。'], - ['备份面板', 'ng backup', '客户凭据会包含在备份中,请妥善保管。'] - ]], - ['入口机 / 中转机', [ - ['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'], - ['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'], - ['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'], - ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。'] - ]], - ['出口机 / 落地机', [ - ['更新 Agent', 'ng-agent update', '入口/出口机通用;出口机无需申请入口证书。'], - ['检查运行状态', 'ng-agent doctor', '核对心跳和 sing-box 配置。'] - ]] - ]; - const sections = groups.map(([title, commands]) => `

${esc(title)}

${commands.map(([label, command, hint]) => `
${esc(label)}${esc(hint)}${esc(command)}
`).join('')}
`).join(''); - return `

在对应服务器的 SSH 终端以 root 运行。首次注册请在“服务器”选择目标设备并生成一次性注册命令;证书域名需要先解析到入口机,AnyTLS 须单独安装 sing-box。

${sections}
`; -} - -function renderOperations() { - const rows = state.jobs.slice(0, 100).map((job) => `${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}${esc(job.id)}${esc((state.servers.find((item) => item.id === job.serverId) || {}).name || job.serverId)}${status(job.status)}${job.attempts}${fmtDate(job.updatedAt)}${esc(job.error || '')}`).join(''); - return `
-

账号安全

修改后台登录账号或密码

-

备份与恢复

浏览器 JSON 与整机迁移包

备份含客户凭据,请加密保管。整机迁移推荐 SSH 运行 ng backup,新机安装后把压缩包放进 /root,再运行 ng restore。
-

版本与证书

轻量单进程控制面

NexusGate当前版本
v${esc(state.version)}
Caddy HTTPS证书自动申请与续签
${status('active')}
ng update\nng domain new.example.com\nng cert
-
-

任务队列

任务租约 5 分钟,超时自动重试,最多 3 次

${rows || ``}
任务设备状态尝试更新时间错误
${empty('任务队列为空','部署或停用线路后会产生任务')}
`; -} - -function render() { - const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations }; - $('#content').innerHTML = views[state.page](); -} - -function modal(kicker, title, body) { - $('#modal-kicker').textContent = kicker; $('#modal-title').textContent = title; $('#modal-body').innerHTML = body; - if (!$('#modal').open) $('#modal').showModal(); -} - -function serverForm(item = null) { - const server = item || { role:'hybrid', portRangeStart:20000, portRangeEnd:50000, labels:[] }; - modal(item ? 'EDIT DEVICE' : 'NEW DEVICE', item ? '编辑设备' : '添加设备', `
- - - - - -
`); + return `
+
+ + ${filterNode ? `节点:${esc(filterNode.name)}` : ''} + ${filtered.length} 位客户
+
+ ${rows || emptyRow(9, '还没有客户')}
操作已启用在线客户端关联入站流量⇅速度剩余⇅时长⇅
${pagerHTML('customers', filtered.length)}
`; } function localDateParts(value) { - if (!value) return { date:'', time:'23:59' }; - const date = new Date(value); const pad = (number) => String(number).padStart(2, '0'); - return { date:`${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}`, time:`${pad(date.getHours())}:${pad(date.getMinutes())}` }; + 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']; + const values = ['00:00', '06:00', '12:00', '18:00', '23:59']; if (current && !values.includes(current)) values.push(current); - return values.sort().map((value) => ``).join(''); + return values.sort().map((value) => ``).join(''); } - -function customerForm(item = null) { - const customer = item || { trafficLimitBytes:0, ipLimit:2, deviceLimit:3, tags:[] }; - const expiry = localDateParts(customer.expiresAt); - modal(item ? 'EDIT CUSTOMER' : 'NEW CUSTOMER', item ? '编辑客户' : '添加客户', `
- - - - - - -
`); -} - -function protocolOptions(role, current) { - return state.protocols.filter((item) => item.role === role && item.deployable).map((item) => ``).join(''); -} - -function multiPicker(name, items, values, subtitle, emptyLabel) { - return `
已选择 ${(values || []).length} 项▾ -
-
${items.map((item) => ``).join('') || `没有可用${esc(emptyLabel)}`}
- 支持搜索后连续多选,已选项在折叠后保留。
`; -} - -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 nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => { - const node = state.nodes.find((n) => n.id === nc.nodeId); - if (!node || node.status !== 'active' || !nc.clientUri) return ''; - const server = state.servers.find((s) => s.id === node.serverId); - const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`; - return `
${esc(label)}${esc(nc.clientUri.slice(0, 60))}...
`; - }).join(''); - modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `
仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。
-
📱 订阅信息页${esc(infoUrl)}
- ${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')} - ${nodeLinks ? `

各节点入站链接

${nodeLinks}` : ''} -
二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。
-
订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。
-
`); -} - -async function customerAccessModal(customer) { - const data = await api(`/api/customers/${customer.id}/access`); - const ips = data.observedIps.map((item) => `${esc(item.ip)}${fmtDate(item.firstSeenAt)}${fmtDate(item.lastSeenAt)}`).join(''); - const logs = data.events.map((item) => `${fmtDate(item.at)}${item.status === 200 ? '已返回' : `${item.status} ${esc(item.reason)}`}${esc(item.ip)}${esc(item.userAgent || '未提供 User-Agent')}${esc(item.format)}${fmtBytes(item.bytes)}`).join(''); - modal('ACCESS ACTIVITY', `${customer.name} · 访问记录`, `
节点 IP:${data.observedIps.length} / ${data.limits.ip || '不限'}(滚动观察窗口);订阅客户端约:${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}(最近 24 小时)。订阅统计不能识别物理设备;同一 User-Agent 可能来自多台设备,客户端升级也可能改变 User-Agent。
-
节点观测 IP
${ips || ''}
IP首次最后
当前窗口没有 Agent 上报
-
最近订阅请求(最多 100 条)
${logs || ''}
时间结果来源 / 客户端格式返回
暂无访问记录
-
`); -} - -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' }; - modal(item ? 'EDIT NODE' : 'NEW NODE', item ? '编辑节点' : '新建节点', `
- - - - - -
- - - -
出口与转发传输
- - - -
保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。
-
`); - 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; - const protocol = state.protocols.find((item) => item.id === form.elements.protocol.value && item.role === 'relay-ingress'); - $('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。'; - 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 available = state.customers.filter((customer) => customer.status === 'active' && !assigned.some((nc) => nc.customerId === customer.id)); - const rows = assigned.map((nc) => { - const customer = state.customers.find((item) => item.id === nc.customerId); - return `${esc(customer ? customer.name : '已删除客户')}${esc(customer ? (customer.group || '未分组') : nc.customerId)} - ${status(nc.status)} -
${nc.clientUri ? `` : ''}
`; - }).join(''); - modal('NODE CLIENTS', `${node.name} · 客户端分配`, `
-
多位客户共用同一入口端口,各自使用独立凭据。分配 / 移除后节点自动重建下发(sing-box 重启一次)。
-
${rows || ``}
客户状态操作
${empty('暂无客户端','从下方选择客户分配')}
- ${available.length ? `
` : '没有可分配的活跃客户。'} -
`); -} - -function assignNodesModal(customer) { - const assigned = new Set(state.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => nc.nodeId)); - const rows = state.nodes.map((node) => { - const server = state.servers.find((item) => item.id === node.serverId); - return ``; - }).join(''); - modal('ASSIGN NODES', `${customer.name} · 分配节点`, `
-
勾选即分配,取消即移除。分配到运行中的节点会立即重建下发。
-
${rows || '还没有节点,先去“转发与节点”创建'}
-
`); -} - function expiryIso(formData) { const date = formData.get('expiryDate'); if (!date) return null; const time = formData.get('expiryTime') || '23:59'; @@ -461,173 +550,763 @@ function expiryIso(formData) { return result.toISOString(); } +/* 客户表单(三页签:基本 / 凭据 / 链接)。关联入站多选、凭据、外部 + 链接与订阅源都随同一个保存动作提交。 */ +const customerFormState = { pickerSelected: new Set(), manualLinks: [], externalSources: [], credentials: null }; + +function pickerHTML(selectedIds) { + const chips = [...selectedIds].map((id) => { + const node = nodeById(id); + return node ? `${esc(node.name)}` : ''; + }).join(''); + return `
+
${chips || '选择节点'}
+
`; +} +function renderPickerList(filter = '') { + const list = $('[data-picker-list]'); + if (!list) return; + const q = filter.trim().toLowerCase(); + list.innerHTML = state.nodes.filter((node) => node.name.toLowerCase().includes(q)).map((node) => { + const server = serverById(node.serverId); + const on = customerFormState.pickerSelected.has(node.id); + return `
${esc(node.name)}${esc(server ? server.name : '')} · ${esc(statusText[node.status] || node.status)}✓
`; + }).join('') || '
无匹配节点
'; +} +function refreshPicker() { + const box = $('[data-picker-box]'); + if (!box) return; + const chips = [...customerFormState.pickerSelected].map((id) => { + const node = nodeById(id); + return node ? `${esc(node.name)}` : ''; + }).join(''); + box.innerHTML = `${chips || '选择节点'}`; + renderPickerList($('[data-picker-search]') ? $('[data-picker-search]').value : ''); +} + +function credRowHTML(label, value) { + return `
${esc(label)}${esc(value || '—')} + ${value ? `` : ''}
`; +} +function renderCredentialsPane(customer) { + const pane = $('#tab-credentials'); + if (!pane) return; + if (!customer) { pane.innerHTML = '
创建客户后生成凭据
'; return; } + const data = customerFormState.credentials; + if (!data) { pane.innerHTML = '
加载中…
'; return; } + const groups = data.items.map((entry) => `
+
${esc(entry.nodeName)}${esc(statusText[entry.status] || entry.status)}${esc(entry.protocol)} +
+ ${credRowHTML('UUID', entry.clientId)} + ${credRowHTML('密码', entry.password)} + ${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''} + ${entry.username ? credRowHTML('用户名', entry.username) : ''} +
`).join(''); + pane.innerHTML = `
订阅
+
订阅令牌${esc(data.subscriptionToken || '—')} + +
+ ${groups || '
尚未关联节点
'}`; +} + +function linkStatusLine(customer, source) { + const status = customer && customer.externalStatus ? customer.externalStatus[source.id] : null; + if (!status) return '尚未拉取'; + const parts = [`${status.count || 0} 条`]; + if (status.okAt) parts.push(`上次成功 ${relTime(status.okAt)}`); + if (status.droppedCount) parts.push(`已忽略 ${status.droppedCount} 条`); + if (status.lastError) parts.push(`${esc(status.lastError)}`); + return parts.join(' · '); +} +function renderLinksPane(customer) { + const pane = $('#tab-links'); + if (!pane) return; + const manual = customerFormState.manualLinks.map((link, index) => ``).join(''); + const sources = customerFormState.externalSources.map((source, index) => ``).join(''); + pane.innerHTML = ` + +
${manual || ''}
+ +
${sources || ''}
`; +} +function collectLinksFromPane() { + customerFormState.manualLinks = $$('[data-link-item]').map((el) => ({ + id: (customerFormState.manualLinks[Number(el.dataset.linkItem)] || {}).id, + uri: $('[data-link-uri]', el).value.trim(), + remark: $('[data-link-remark]', el).value.trim(), + enabled: $('[data-link-enabled]', el).checked + })).filter((link) => link.uri || link.remark); + customerFormState.externalSources = $$('[data-source-item]').map((el) => ({ + id: (customerFormState.externalSources[Number(el.dataset.sourceItem)] || {}).id, + url: $('[data-source-url]', el).value.trim(), + remark: $('[data-source-remark]', el).value.trim(), + userAgent: $('[data-source-ua]', el).value.trim(), + enabled: $('[data-source-enabled]', el).checked + })).filter((source) => source.url || source.remark); +} + +function customerForm(item = null) { + const customer = item || { trafficLimitBytes: 0, ipLimit: 2, deviceLimit: 3, tags: [] }; + const expiry = localDateParts(customer.expiresAt); + customerFormState.pickerSelected = new Set(item ? customerBindings(item.id).map((nc) => nc.nodeId) : []); + customerFormState.manualLinks = (item && item.manualLinks || []).map((l) => ({ ...l })); + customerFormState.externalSources = (item && item.externalSources || []).map((s) => ({ ...s })); + customerFormState.credentials = null; + const manualOff = item && item.status === 'suspended' && item.suspendReason === 'manual'; + openModal(item ? '编辑客户' : '添加客户', `
+
+ + + +
+
+
+ + + + + + + + +
流量重置 +
+
+ + +
关联入站${pickerHTML(customerFormState.pickerSelected)}
+ +
+
+ + +
+ ${item ? `` : ''} + + +
`, true); + renderPickerList(); + renderCredentialsPane(item); + renderLinksPane(item); + if (item) { + api(`/api/customers/${item.id}/credentials`).then((data) => { + customerFormState.credentials = data; + renderCredentialsPane(item); + }).catch((error) => { const pane = $('#tab-credentials'); if (pane) pane.innerHTML = `
${esc(error.message)}
`; }); + } + const modeSelect = $('[name="trafficResetMode"]'); + const dayInput = $('[name="trafficResetDay"]'); + const syncReset = () => { dayInput.disabled = modeSelect.value !== '1'; }; + modeSelect.addEventListener('change', syncReset); syncReset(); +} + +/* ---------- 订阅弹窗 ---------- */ +function subscriptionModal(customer) { + if (!customer || !customer.subscriptionToken) return toast('客户订阅令牌尚未生成,请刷新页面', true); + const formats = [ + ['auto', '自动识别客户端'], ['v2ray', 'V2Ray / v2rayNG'], ['shadowrocket', 'Shadowrocket 小火箭'], + ['base64', '通用 Base64'], ['clash', 'Mihomo / Clash 简洁版'], ['clash-smart', 'Mihomo / Clash 智能分流'], + ['singbox', 'sing-box JSON'], ['surge', 'Surge(仅兼容节点)'], ['raw', '原始节点链接'] + ]; + const infoUrl = `${location.origin}/s/${customer.subscriptionToken}/info`; + const autoUrl = `${location.origin}/s/${customer.subscriptionToken}/auto`; + const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => { + const node = nodeById(nc.nodeId); + if (!node || node.status !== 'active' || !nc.clientUri) return ''; + const server = serverById(node.serverId); + const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`; + return `
${esc(label)}${esc(nc.clientUri.slice(0, 60))}...
`; + }).join(''); + openModal(`${customer.name} · 订阅`, `
+
+
📱 订阅信息页${esc(infoUrl)}
+ ${formats.map(([format, label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')} + ${nodeLinks ? `

各节点入站链接

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

${esc(title)}

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

账号安全

+ + + + +
+

备份与恢复

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

版本

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

任务队列

${rows || emptyRow(6, '任务队列为空')}
任务设备状态尝试更新时间错误
`; +} + +function render() { + const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations }; + $('#content').innerHTML = views[state.page](); +} + +/* ---------- 表单提交 ---------- */ document.addEventListener('submit', async (event) => { - event.preventDefault(); const form = event.target; const data = new FormData(form); + 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') }) }); + 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) }); - $('#modal').close(); toast(resourceId ? '设备信息已更新' : '设备已添加'); await load('servers'); + 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 = data.get('resourceId'); - 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:Number(data.get('trafficResetDay') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') }; - await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); - $('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); + 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) }; - const result = await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); - $('#modal').close(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains'); - } else if (form.id === 'assign-form') { - await api(`/api/nodes/${data.get('nodeId')}/assign`, { method:'POST', body:JSON.stringify({ customerId:data.get('customerId') }) }); - toast('客户已分配'); const node = state.nodes.find((item) => item.id === data.get('nodeId')); await load('chains'); if (node) nodeClientsModal(state.nodes.find((item) => item.id === node.id)); - } else if (form.id === 'assign-nodes-form') { - const customerId = data.get('customerId'); - const wanted = new Set(data.getAll('nodeIds')); - const current = state.nodeClients.filter((nc) => nc.customerId === customerId).map((nc) => nc.nodeId); - for (const nodeId of current.filter((id) => !wanted.has(id))) await api(`/api/nodes/${nodeId}/unassign`, { method:'POST', body:JSON.stringify({ customerId }) }); - for (const nodeId of [...wanted].filter((id) => !current.includes(id))) await api(`/api/nodes/${nodeId}/assign`, { method:'POST', body:JSON.stringify({ customerId }) }); - $('#modal').close(); toast('节点分配已更新'); await load('customers'); + 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('账号已更新,请重新登录'); $('#login-form [name="username"]').value = data.get('username'); showLogin(); + 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); } + } catch (error) { + if (form.id === 'login-form') $('#login-error').textContent = error.message; + else toast(error.message, true); + } }); +/* ---------- 点击动作 ---------- */ document.addEventListener('click', async (event) => { - const theme = event.target.closest('[data-theme-toggle]'); if (theme) { setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); return; } - const nav = event.target.closest('[data-page]'); if (nav) { setPage(nav.dataset.page); return; } - const jump = event.target.closest('[data-page-jump]'); if (jump) { setPage(jump.dataset.pageJump); return; } - if (event.target.closest('[data-close]')) { $('#modal').close(); return; } - const button = event.target.closest('[data-action]'); if (!button) return; + 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 === 'add-server') serverForm(); - else if (action === 'edit-server') serverForm(state.servers.find((item) => item.id === itemId)); + 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(state.customers.find((item) => item.id === itemId)); - else if (action === 'customer-subscription') subscriptionModal(state.customers.find((item) => item.id === itemId)); - else if (action === 'customer-access') await customerAccessModal(state.customers.find((item) => item.id === itemId)); - else if (action === 'reset-access' && confirm('清空此客户的订阅访问日志和最近 24 小时的客户端估计数?节点 IP 观察记录不受影响。')) { - await api(`/api/customers/${itemId}/access`, { method:'DELETE' }); - toast('订阅访问窗口已清空'); await load('customers'); await customerAccessModal(state.customers.find((item) => item.id === itemId)); + else if (action === 'edit-customer') customerForm(customerById(itemId)); + else if (action === 'customer-subscription') subscriptionModal(customerById(itemId)); + else if (action === 'customer-access') await customerAccessModal(customerById(itemId)); + else if (action === 'customer-inbounds') { + const item = customerById(itemId); if (!item) return; + const chips = customerBindings(item.id).map((nc) => { + const node = nodeById(nc.nodeId); if (!node) return ''; + const fam = protocolFamily(node.protocol); + return `${esc(node.name)}`; + }).join(''); + showPop(button, `
关联入站${customerBindings(item.id).length} 个
${chips}
`); } - else if (action === 'rotate-subscription' && confirm('旧订阅地址会立刻失效。已导入的节点凭据不会随订阅令牌重置,确认继续?')) { - const result = await api(`/api/customers/${itemId}/rotate-subscription`, { method:'POST', body:'{}' }); - state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item); - subscriptionModal(result.customer); toast('订阅地址已重置'); + 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 === 'add-node') nodeForm(); - else if (action === 'edit-node') { - if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; } - nodeForm(state.nodes.find((item) => item.id === itemId)); - } else if (action === 'node-clients') nodeClientsModal(state.nodes.find((item) => item.id === itemId)); - else if (action === 'assign-nodes') assignNodesModal(state.customers.find((item) => item.id === itemId)); - else if (action === 'unassign-customer' && confirm('移除该客户在此节点的分配?节点会自动重建。')) { - await api(`/api/nodes/${itemId}/unassign`, { method:'POST', body:JSON.stringify({ customerId:button.dataset.customer }) }); - toast('分配已移除'); const node = state.nodes.find((item) => item.id === itemId); await load('chains'); nodeClientsModal(state.nodes.find((item) => item.id === node.id)); - } else if (action === 'enroll-server') { - const result = await api(`/api/servers/${itemId}/enrollment-token`, { method:'POST', body:'{}' }); - 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}`; - modal('ONE-TIME ENROLLMENT', 'Agent 注册 / 重装命令', `
令牌 30 分钟内有效且只能使用一次。重装会保留本机资源文件,并在 Agent 重启后自动与控制面对账。
${esc(command)}
`); - } else if (action === 'copy-uri') { await copyText(button.dataset.value); toast('已复制到剪贴板'); } else if (action === 'qr-uri') { - const target = $('.subscription-qr', button.closest('.subscription-row')); + 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 === 'agent-uninstall') { - const command = 'ng-agent uninstall'; - const fallback = 'curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/main/scripts/agent-uninstall.sh | bash'; - modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `
先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。
${esc(command)}
旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:
${esc(fallback)}
`); + 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 === '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 = state.servers.find((entry) => entry.id === itemId); - if (!item) return; - const name = prompt(`仅在服务器永久下线、销毁,或已手工清除其代理配置时使用。强制遗忘会放弃 ${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' && confirm('删除控制台登记并吊销 Agent 密钥?如需要彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。')) { await api(`/api/servers/${itemId}`, { method:'DELETE' }); toast('设备登记已删除'); await load(); } - else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先移除全部节点分配。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); } - else if (action === 'reset-usage' && confirm('确认把该客户已用流量清零?')) { await api(`/api/customers/${itemId}/reset-usage`, { method:'POST', body:'{}' }); toast('客户流量已清零'); await load(); } - else if (action === 'toggle-customer') { - const item = state.customers.find((entry) => entry.id === itemId); - await api(`/api/customers/${itemId}`, { method:'PATCH', body:JSON.stringify({ status:item.status === 'active' ? 'suspended' : 'active' }) }); toast('客户状态已更新'); await load(); - } else if (action === 'deploy-node' && confirm('现在向所选设备下发这个节点?')) { await api(`/api/nodes/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); } - else if (action === 'repair-node' && confirm('重新下发该节点配置?')) { await api(`/api/nodes/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); } - else if (action === 'remove-node' && confirm('停用会从相关设备移除配置,节点保留为草稿,确认继续?')) { await api(`/api/nodes/${itemId}/remove`, { method:'POST', body:'{}' }); toast('停用任务已进入队列'); await load(); } - else if (action === 'delete-node' && confirm('确定删除这个节点?会移除全部分配并从设备清理资源。')) { await api(`/api/nodes/${itemId}`, { method:'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); } + 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 () => { + const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; + input.onchange = async () => { try { - if (!input.files[0] || !confirm('恢复会覆盖当前全部控制面数据,确认继续?')) 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(); + 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(); + }; + input.click(); } + if (inPop && action !== 'node-menu') hidePop(); } catch (error) { toast(error.message, true); } }); -document.addEventListener('change', (event) => { - if (event.target.matches('[data-row-actions]') && event.target.value) { - const button = document.createElement('button'); button.dataset.action = event.target.value; button.dataset.id = event.target.dataset.id; - event.target.value = ''; button.hidden = true; document.body.append(button); button.click(); button.remove(); return; - } - if (event.target.closest('[data-picker-option]')) { - const picker = event.target.closest('[data-picker]'); - $('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`; - } - if (event.target.matches('[data-node-sync]')) syncNodeForm(); - if (event.target.matches('[data-reality-preset]')) { - const option = event.target.selectedOptions[0]; const form = event.target.form; - if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; } - } - if (event.target.matches('[data-expiry-quick]') && event.target.value) { - const target = new Date(); target.setDate(target.getDate() + Number(event.target.value)); - const pad = (number) => String(number).padStart(2, '0'); - event.target.form.elements.expiryDate.value = `${target.getFullYear()}-${pad(target.getMonth()+1)}-${pad(target.getDate())}`; - event.target.form.elements.expiryTime.value = '23:59'; - } +/* ---------- 变更(开关 / 表单联动 / 筛选) ---------- */ +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-picker-search]')) { - const picker = event.target.closest('[data-picker]'); const query = event.target.value.trim().toLowerCase(); - $$('[data-picker-option]', picker).forEach((option) => { option.hidden = !option.dataset.query.includes(query); }); - return; + 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-search]')) { state.search = event.target.value; render(); const next = $('[data-search]'); next.focus(); next.setSelectionRange(next.value.length,next.value.length); } + if (event.target.matches('[data-picker-search]')) renderPickerList(event.target.value); }); -function closeSidebar() { $('#app').classList.remove('sidebar-open'); } -$('#menu-toggle').addEventListener('click', () => $('#app').classList.add('sidebar-open')); -$('#sidebar-scrim').addEventListener('click', closeSidebar); -$('#refresh').addEventListener('click', () => load()); -$('#logout').addEventListener('click', async () => { try { await api('/api/auth/logout', { method:'POST', body:'{}' }); } finally { showLogin(); } }); +/* 节点在线名单浮层(悬停,浮层挂 body) */ +document.addEventListener('mouseover', (event) => { + const anchor = event.target.closest('[data-online-pop]'); + const pop = $('#float-pop'); + if (anchor) { + if (pop._anchor === anchor && !pop.classList.contains('hidden')) return; + const names = (anchor.dataset.names || '').split(',').filter(Boolean); + if (!names.length) { hidePop(); return; } + showPop(anchor, `
在线${names.length} 位客户
${names.map((name) => `
${esc(name)}
`).join('')}
依据 Agent 每分钟活跃上报
`); + return; + } + if (!event.target.closest('#float-pop') && !event.target.closest('[data-action="node-menu"]')) { + if (pop._anchor && pop._anchor.hasAttribute && pop._anchor.hasAttribute('data-online-pop')) hidePop(); + } +}); +window.addEventListener('scroll', () => { const pop = $('#float-pop'); if (pop._anchor && pop._anchor.hasAttribute && pop._anchor.hasAttribute('data-online-pop')) hidePop(); }, true); +/* ---------- 节点拖拽排序 ---------- */ +let dragNodeId = null; +document.addEventListener('mousedown', (event) => { + const grip = event.target.closest('[data-grip]'); + const row = grip ? grip.closest('[data-node-row]') : null; + if (row) row.draggable = true; +}); +document.addEventListener('dragstart', (event) => { + const row = event.target.closest('[data-node-row]'); + if (!row) return; + dragNodeId = row.dataset.nodeRow; + row.classList.add('dragging'); + event.dataTransfer.effectAllowed = 'move'; + try { event.dataTransfer.setData('text/plain', dragNodeId); } catch (e) { /* noop */ } +}); +document.addEventListener('dragover', (event) => { + const row = event.target.closest('[data-node-row]'); + if (!row || !dragNodeId) return; + event.preventDefault(); + $$('[data-node-row]').forEach((r) => r.classList.toggle('drag-over', r === row && r.dataset.nodeRow !== dragNodeId)); +}); +document.addEventListener('drop', async (event) => { + const row = event.target.closest('[data-node-row]'); + if (!row || !dragNodeId) return; + event.preventDefault(); + const targetId = row.dataset.nodeRow; + const ids = state.nodes.map((n) => n.id); + const from = ids.indexOf(dragNodeId); const to = ids.indexOf(targetId); + dragNodeId = null; + $$('[data-node-row]').forEach((r) => { r.classList.remove('drag-over', 'dragging'); r.draggable = false; }); + if (from < 0 || to < 0 || from === to) { render(); return; } + ids.splice(to, 0, ids.splice(from, 1)[0]); + try { + await api('/api/nodes/reorder', { method: 'POST', body: JSON.stringify({ ids }) }); + toast('节点顺序已保存'); + } catch (error) { toast(error.message, true); } + await load('chains'); +}); +document.addEventListener('dragend', () => { + dragNodeId = null; + $$('[data-node-row]').forEach((r) => { r.classList.remove('drag-over', 'dragging'); r.draggable = false; }); +}); + +/* ---------- 启动 ---------- */ +$('#refresh').addEventListener('click', () => load()); +$('#logout').addEventListener('click', async () => { + try { await api('/api/auth/logout', { method: 'POST', body: '{}' }); } finally { showLogin(); } +}); window.addEventListener('popstate', () => { const page = pageFromHash(); if (state.session && page !== state.page) setPage(page); }); window.addEventListener('hashchange', () => { const page = pageFromHash(); if (state.session && page !== state.page) setPage(page); }); (async function boot() { - let preferred = 'light'; try { preferred = localStorage.getItem('nexusgate-theme') || 'light'; } catch { /* storage may be disabled */ } + 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' && !$('#modal').open) load(); }, 45000); + setInterval(() => { if (state.session && document.visibilityState === 'visible' && !modalOpen()) load(); }, 45000); })(); diff --git a/public/index.html b/public/index.html index 4811986..cc82f63 100644 --- a/public/index.html +++ b/public/index.html @@ -3,66 +3,62 @@ - - - NexusGate · 枢门控制台 - + + + amo + + - - -
-
- +
+ +
- + diff --git a/public/styles.css b/public/styles.css index 70dcad5..dc8e3fd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,111 +1,402 @@ -:root { - --bg: #f4f7fb; --surface: #ffffff; --surface-soft: #f8faff; --surface-hover: #f1f5ff; - --text: #172033; --muted: #6f7b91; --line: #e4e9f2; --line-strong: #d6ddea; - --primary: #4f6ef7; --primary-dark: #3c57d9; --primary-soft: #eef1ff; - --success: #159f70; --success-soft: #e9f8f2; --warning: #bc7a12; --warning-soft: #fff6df; - --danger: #d84d61; --danger-soft: #fff0f2; --shadow: 0 12px 36px rgba(34,51,84,.08); --shadow-lg: 0 28px 80px rgba(23,32,51,.16); - --radius: 16px; font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color-scheme: light; +/* amo 控制台样式 — 按 3x-ui 风格样张落地。浅色默认,[data-theme="dark"] 深色。 + 字体为系统字体栈,零外部依赖(路由器可离线部署)。 */ +:root{ + --bg:#f0f2f5; --sider:#ffffff; --card:#ffffff; --card-hover:#fafafa; + --border:#d9dce1; --border-soft:#e8eaed; + --text:#20242a; --text-2:#5a6068; --text-3:#8a8f98; + --primary:#1668dc; --primary-hover:#3c89e8; --primary-soft:rgba(22,104,220,.10); --primary-text:#0958d9; + --green:#52c41a; --green-text:#237804; --green-soft:rgba(82,196,26,.13); + --red:#ff4d4f; --red-text:#cf1322; --red-soft:rgba(255,77,79,.08); + --orange:#faad14; --orange-text:#975a00; --orange-soft:rgba(250,173,20,.14); + --purple:#9254de; --purple-text:#722ed1; --purple-soft:rgba(146,84,222,.10); + --cyan:#13c2c2; --cyan-text:#096f74; --cyan-soft:rgba(19,194,194,.10); + --pink-text:#c41d7f; --pink-soft:rgba(235,47,150,.09); + --sky-text:#0958d9; --sky-soft:rgba(22,104,220,.09); + --gray-soft:rgba(0,0,0,.05); --pill-bg:rgba(0,0,0,.045); + --hover-bg:rgba(0,0,0,.04); --row-hover:rgba(0,0,0,.02); --th-bg:#fafafa; + --dot-off:#c2c6cc; --tgl-off:#c9ccd3; --track:rgba(0,0,0,.08); + --pop-bg:#ffffff; --pop-shadow:0 12px 32px rgba(0,0,0,.16); + --trans-bg:rgba(82,196,26,.14); --trans-text:#237804; + --chart-2:#6f757f; + --radius:8px; + --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; + --mono:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",monospace; } -html[data-theme="dark"] { - --bg:#0e1421; --surface:#161e2e; --surface-soft:#111827; --surface-hover:#202b40; --text:#edf2fb; --muted:#93a0b6; - --line:#273248; --line-strong:#344159; --primary:#7790ff; --primary-dark:#91a4ff; --primary-soft:#222d50; - --success:#42cea0; --success-soft:#16382f; --warning:#f0bd5f; --warning-soft:#3b301b; --danger:#ff788a; --danger-soft:#45232b; - --shadow:0 16px 42px rgba(0,0,0,.2); --shadow-lg:0 30px 90px rgba(0,0,0,.45); color-scheme:dark; +[data-theme="dark"]{ + --bg:#1a1b1f; --sider:#1e2024; --card:#23252b; --card-hover:#282a31; + --border:#33363e; --border-soft:#2b2d34; + --text:#e9eaee; --text-2:#a2a7b1; --text-3:#6f757f; + --primary-soft:rgba(22,104,220,.16); --primary-text:#69a7ff; + --green-text:#73d13d; + --red-text:#ff7875; --red-soft:rgba(255,77,79,.13); + --orange-text:#ffc53d; --orange-soft:rgba(250,173,20,.13); + --purple-text:#b37feb; --purple-soft:rgba(146,84,222,.17); + --cyan-text:#36cfc9; --cyan-soft:rgba(19,194,194,.13); + --pink-text:#ff85c0; --pink-soft:rgba(235,47,150,.16); + --sky-text:#8dbdff; --sky-soft:rgba(105,167,255,.15); + --gray-soft:rgba(255,255,255,.07); --pill-bg:rgba(255,255,255,.06); + --hover-bg:rgba(255,255,255,.05); --row-hover:rgba(255,255,255,.025); --th-bg:rgba(255,255,255,.02); + --dot-off:#5a5e66; --tgl-off:#3c404a; --track:rgba(255,255,255,.09); + --pop-bg:#2e3038; --pop-shadow:0 12px 32px rgba(0,0,0,.55); + --trans-bg:rgba(82,196,26,.12); --trans-text:#95de64; + --chart-2:#8a8f98; } -*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text)} body{min-height:100vh} -button,input,select,textarea{font:inherit} button{cursor:pointer}.hidden{display:none!important}.muted{color:var(--muted)} -.eyebrow{margin:0 0 7px;color:var(--primary);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase} -.brand{display:flex;align-items:center;gap:12px}.brand>span:last-child{display:flex;flex-direction:column;line-height:1.15}.brand b{font-size:17px;letter-spacing:-.02em}.brand small{margin-top:4px;color:var(--muted);font-size:10px;letter-spacing:.12em} -.brand-mark{width:44px;height:44px;display:grid;place-items:center;color:white;border-radius:14px;background:linear-gradient(145deg,#617cfb,#4b5ed9);box-shadow:0 10px 24px rgba(79,110,247,.3);font-weight:900;font-size:21px}.brand-mark.small{width:38px;height:38px;border-radius:12px;font-size:18px} -.login-shell{min-height:100vh;display:grid;grid-template-columns:minmax(420px,47%) 1fr;background:var(--surface)}.login-main{position:relative;display:grid;place-items:center;padding:60px 8vw}.login-card{width:min(100%,430px)}.login-brand{margin-bottom:70px}.login-copy h1{margin:0 0 12px;font-size:clamp(32px,4vw,44px);letter-spacing:-.04em}.login-copy>p:last-child{margin:0;font-size:15px}.login-form{margin-top:38px}.login-footnote{margin:30px 0 0;color:var(--muted);font-size:12px;text-align:center}.login-theme{position:absolute;top:28px;right:30px} -.login-visual{position:relative;overflow:hidden;margin:18px;padding:clamp(48px,7vw,110px);border-radius:28px;color:white;background:linear-gradient(140deg,#637df8 0%,#4961da 54%,#334bbf 100%);display:flex;flex-direction:column;justify-content:center}.login-visual::after{content:"";position:absolute;inset:0;opacity:.13;background-image:radial-gradient(circle at 1px 1px,#fff 1px,transparent 0);background-size:28px 28px}.visual-copy,.visual-card{position:relative;z-index:1}.visual-copy{max-width:650px}.visual-copy h2{margin:24px 0 18px;font-size:clamp(36px,4.5vw,62px);line-height:1.13;letter-spacing:-.045em}.visual-copy p{max-width:560px;margin:0;color:rgba(255,255,255,.78);font-size:17px;line-height:1.8}.visual-badge{display:inline-flex;padding:9px 14px;border:1px solid rgba(255,255,255,.28);border-radius:999px;background:rgba(255,255,255,.1);font-size:12px;font-weight:750;letter-spacing:.08em}.visual-card{display:flex;align-items:center;gap:15px;width:fit-content;margin-top:70px;padding:18px 22px;border:1px solid rgba(255,255,255,.22);border-radius:15px;background:rgba(255,255,255,.12);backdrop-filter:blur(16px)}.visual-card div{display:flex;flex-direction:column;gap:5px}.visual-card small{color:rgba(255,255,255,.7)}.orb{position:absolute;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06)}.orb-one{width:440px;height:440px;right:-100px;top:-110px}.orb-two{width:260px;height:260px;left:-90px;bottom:-80px} -.app-shell{min-height:100vh;display:grid;grid-template-columns:246px minmax(0,1fr)}.sidebar{position:sticky;top:0;z-index:30;height:100vh;display:flex;flex-direction:column;padding:24px 15px 18px;border-right:1px solid var(--line);background:var(--surface)}.sidebar-brand{padding:0 8px 27px}.nav-caption{margin:5px 12px 10px;color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}#nav{display:flex;flex-direction:column;gap:4px}#nav button{width:100%;display:flex;align-items:center;gap:12px;padding:11px 13px;border:0;border-radius:10px;color:var(--muted);background:transparent;font-weight:650;text-align:left;transition:.16s ease}#nav button:hover{color:var(--text);background:var(--surface-hover)}#nav button.active{color:var(--primary);background:var(--primary-soft)}.nav-icon{width:23px;height:23px;display:grid;place-items:center;border-radius:7px;font-size:17px}#nav button.active .nav-icon{color:white;background:var(--primary);box-shadow:0 5px 12px rgba(79,110,247,.25)}.sidebar-foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;padding:14px 10px 0;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.sidebar-foot>div{display:flex;align-items:center;gap:8px}.live-dot{flex:0 0 auto;width:8px;height:8px;display:inline-block;border-radius:50%;background:#35c493;box-shadow:0 0 0 4px rgba(53,196,147,.15)} -.main-shell{min-width:0}.topbar{height:88px;position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 34px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(18px)}.title-block h1{margin:0;font-size:24px;letter-spacing:-.025em}.top-actions{display:flex;align-items:center;gap:10px}.user-chip{padding:9px 13px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:12px;font-weight:700}.content{max-width:1620px;margin:0 auto;padding:28px 34px 60px} -button,.button{border:1px solid var(--line-strong);border-radius:9px;padding:8px 12px;color:var(--text);background:var(--surface);font-size:12px;font-weight:700;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--primary)}button:active{transform:translateY(1px)}button.primary{border-color:var(--primary);color:white;background:var(--primary);box-shadow:0 6px 16px rgba(79,110,247,.2)}button.primary:hover{background:var(--primary-dark)}button.danger{border-color:transparent;color:var(--danger);background:var(--danger-soft)}button.ghost{background:transparent}button.full{width:100%}button.large{padding:13px 16px;font-size:14px;border-radius:11px}button:disabled{cursor:not-allowed;opacity:.48;transform:none}.icon-button,.theme-button{width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;font-size:18px}.theme-button{color:var(--primary);background:var(--primary-soft);border-color:transparent}.mobile-only,.sidebar-scrim{display:none} -.page-intro{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:18px}.page-intro p{max-width:820px;margin:0;color:var(--muted);font-size:13px;line-height:1.75}.metrics{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:14px;margin-bottom:18px}.metric{position:relative;overflow:hidden;padding:21px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.metric::before{content:"";position:absolute;width:5px;height:46px;left:0;top:22px;border-radius:0 5px 5px 0;background:var(--accent)}.metric span,.metric small{display:block;color:var(--muted);font-size:12px}.metric strong{display:block;margin:7px 0 4px;font-size:29px;letter-spacing:-.04em}.grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.panel{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.panel-head{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:16px 20px;border-bottom:1px solid var(--line)}.panel-head h2{margin:0 0 4px;font-size:15px}.panel-head p{margin:0;color:var(--muted);font-size:11px}.panel-body{padding:20px}.health-list,.timeline,.stack{display:flex;flex-direction:column;gap:13px}.health-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--line)}.health-row:last-child{border:0}.health-row>div{min-width:0;display:flex;flex-direction:column;gap:4px}.health-row span,.health-row small{color:var(--muted);font-size:11px}.event{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:12px;padding:9px 0}.event-dot{width:8px;height:8px;border:2px solid var(--primary);border-radius:50%}.event div{display:flex;flex-direction:column;gap:3px}.event small,.event time{color:var(--muted);font-size:10px} -.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:9px;width:100%}.search{width:min(410px,100%)}.tag{display:inline-flex;align-items:center;margin:2px 4px 2px 0;padding:4px 8px;border-radius:999px;color:var(--primary);background:var(--primary-soft);font-size:10px;font-weight:750}.status{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;color:var(--muted);background:var(--surface-soft);font-size:10px;font-weight:800;white-space:nowrap}.status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.status.online,.status.active,.status.completed{color:var(--success);background:var(--success-soft)}.status.failed,.status.degraded,.status.suspended{color:var(--danger);background:var(--danger-soft)}.status.deploying,.status.queued,.status.running,.status.removing,.status.redeploying,.status.redeploy_pending,.status.changes_pending{color:var(--warning);background:var(--warning-soft)}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{padding:11px 16px;color:var(--muted);background:var(--surface-soft);font-size:10px;text-align:left;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}td{padding:14px 16px;border-top:1px solid var(--line);vertical-align:middle}tbody tr:hover{background:var(--surface-hover)}td strong,td small{display:block}td strong{font-size:12px}td small{max-width:360px;margin-top:4px;color:var(--muted);line-height:1.45}.actions{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}.progress{width:130px;height:4px;margin-top:8px;overflow:hidden;border-radius:5px;background:var(--line)}.progress i{height:100%;display:block;border-radius:inherit;background:var(--primary)}.empty{min-height:120px;display:grid;place-items:center;align-content:center;gap:7px;color:var(--muted);text-align:center}.empty b{color:var(--text)} -label{display:flex;flex-direction:column;gap:7px;color:var(--text);font-size:12px;font-weight:700}input,select,textarea{width:100%;min-height:42px;padding:9px 11px;outline:0;border:1px solid var(--line-strong);border-radius:9px;color:var(--text);background:var(--surface);transition:border-color .15s,box-shadow .15s}input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 14%,transparent)}textarea{min-height:88px;resize:vertical}label small,.field-note{color:var(--muted);font-size:10px;font-weight:500;line-height:1.5}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.form-grid .wide{grid-column:1/-1}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:9px;padding-top:8px}.inline-fields{display:grid;grid-template-columns:1fr 1fr;gap:8px}.check-grid{max-height:215px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:4px}.check-card{display:flex;flex-direction:row;align-items:center;gap:10px;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}.check-card input{width:15px;min-height:auto;accent-color:var(--primary)}.check-card span{display:flex;flex-direction:column;gap:3px}.check-card small{color:var(--muted)}.notice{padding:12px 14px;border:1px solid color-mix(in srgb,var(--primary) 25%,var(--line));border-radius:10px;color:var(--muted);background:var(--primary-soft);font-size:11px;line-height:1.65}.notice.warning{border-color:color-mix(in srgb,var(--warning) 30%,var(--line));background:var(--warning-soft)}.codebox{overflow:auto;padding:14px;border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--surface-soft);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.7;white-space:pre-wrap;word-break:break-all}.form-error{min-height:20px;color:var(--danger);font-size:12px}.section-title{margin:8px 0 -3px;grid-column:1/-1;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.section-title::after{content:"";flex:1;height:1px;background:var(--line)} -dialog{width:min(760px,calc(100% - 32px));max-height:calc(100vh - 40px);padding:0;overflow:auto;border:0;border-radius:18px;color:var(--text);background:var(--surface);box-shadow:var(--shadow-lg)}dialog::backdrop{background:rgba(18,25,39,.48);backdrop-filter:blur(3px)}.dialog-shell>header{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;padding:20px 22px;border-bottom:1px solid var(--line);background:var(--surface)}.dialog-shell h2{margin:0;font-size:20px}#modal-body{padding:22px}#toasts{position:fixed;z-index:80;right:22px;bottom:22px;display:flex;flex-direction:column;gap:9px}.toast{max-width:390px;padding:12px 15px;border:1px solid color-mix(in srgb,var(--success) 25%,var(--line));border-radius:10px;color:var(--success);background:var(--surface);box-shadow:var(--shadow-lg);font-size:12px;font-weight:700}.toast.error{border-color:color-mix(in srgb,var(--danger) 30%,var(--line));color:var(--danger)} -@media(max-width:1120px){.metrics{grid-template-columns:repeat(3,1fr)}.login-shell{grid-template-columns:1fr 1fr}.login-main{padding:50px 7vw}.login-visual{padding:55px}} -@media(max-width:860px){.login-shell{display:block;background:var(--surface)}.login-main{min-height:100vh;padding:50px 24px}.login-visual{display:none}.login-brand{margin-bottom:48px}.app-shell{display:block}.sidebar{position:fixed;left:0;transform:translateX(-105%);width:246px;transition:transform .2s ease;box-shadow:var(--shadow-lg)}.app-shell.sidebar-open .sidebar{transform:translateX(0)}.sidebar-scrim{position:fixed;z-index:25;inset:0;border:0;border-radius:0;background:rgba(15,22,34,.42)}.app-shell.sidebar-open .sidebar-scrim{display:block}.mobile-only{display:grid}.topbar{padding:0 18px}.content{padding:22px 18px 50px}.grid-2,.grid-3{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.user-chip{display:none}} -@media(max-width:570px){.topbar{height:76px}.title-block h1{font-size:19px}.title-block .eyebrow{display:none}#refresh{display:none}.page-intro{align-items:stretch;flex-direction:column}.metrics{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.form-grid .wide,.form-actions,.section-title{grid-column:1}.check-grid{grid-template-columns:1fr}.inline-fields{grid-template-columns:1fr}.login-copy h1{font-size:34px}dialog{width:calc(100% - 18px);max-height:calc(100vh - 18px)}#modal-body{padding:18px}} +*{margin:0;padding:0;box-sizing:border-box} +body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased} +a{color:inherit;text-decoration:none} +.hidden{display:none !important} +button{font-family:var(--font)} +input,select,textarea{font-family:var(--font);color:var(--text)} +:focus-visible{outline:2px solid var(--primary-hover);outline-offset:1px} -/* Compact control-room layout: stable navigation width and readable actions in either theme. */ -:root{--bg:#f5f7fc;--surface-soft:#f8faff;--primary:#3478ee;--primary-dark:#2468dc;--primary-soft:#eaf2ff;--radius:14px;--shadow:0 4px 18px rgba(26,44,80,.04)} -html[data-theme="dark"]{--bg:#0e1420;--surface-soft:#1b2638;--primary:#79a9ff;--primary-dark:#9bbfff;--primary-soft:#213656;--shadow:0 4px 20px rgba(0,0,0,.12)} -.login-shell{display:grid;grid-template-columns:1fr;background:var(--bg)} -.login-main{min-height:100vh;padding:28px;display:grid;place-items:center} -.login-card{width:min(100%,420px);padding:38px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:var(--shadow)} -.login-brand{margin-bottom:32px}.login-copy h1{margin:0;font-size:29px}.login-form{margin-top:28px} -.login-theme{top:24px;right:28px}.login-visual{display:none} -.app-shell{grid-template-columns:218px minmax(0,1fr)} -.sidebar{width:218px;min-width:218px;padding:24px 12px 18px;color:#e7edf9;border-right:0;background:#151e2d} -.sidebar .brand b{color:#fff}.sidebar .brand small,.sidebar .nav-caption{color:#8190a6} -.sidebar-brand{padding:0 10px 28px}.sidebar .brand-mark{background:#3478ee;box-shadow:none} -#nav{min-width:0}#nav button{min-width:0;width:100%;display:flex;flex-flow:row nowrap;align-items:center;gap:12px;white-space:nowrap;line-height:1.3;color:#a9b7cb;padding:11px 12px;font-size:13px} -#nav button .nav-label{display:block;flex:0 0 auto;white-space:nowrap;word-break:keep-all;writing-mode:horizontal-tb} -#nav button:hover{color:#fff;background:#222f44}#nav button.active{color:#fff;background:#2a3b58} -#nav button.active .nav-icon{color:#79a9ff;background:transparent;box-shadow:none} -.nav-icon{flex:0 0 22px;width:22px;height:22px;font-size:16px}.nav-group{margin-top:18px;margin-bottom:6px} -.sidebar-foot{border-color:#354256;color:#91a1b9}.sidebar-foot .icon-button{color:#e7edf9;background:transparent;border-color:#354256} -.topbar{height:74px;padding:0 30px;background:var(--surface);backdrop-filter:none}.title-block h1{font-size:20px}.title-block .eyebrow{margin-bottom:3px;font-size:10px;color:var(--muted)} -.content{max-width:1460px;padding:28px 30px 60px}.theme-button{width:auto;min-width:68px;height:36px;padding:0 11px;font-size:12px;white-space:nowrap} -.page-intro{margin-bottom:18px}.page-intro p{max-width:900px}.panel{box-shadow:var(--shadow)} -.actions{min-width:235px;flex-wrap:wrap}.actions button{white-space:nowrap} -.table-wrap table{min-width:850px}.table-wrap td{line-height:1.5}.table-wrap td:last-child{width:1%;white-space:nowrap} -.protocol-note{grid-column:1/-1;line-height:1.7}.chain-section{grid-column:1/-1;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)} -.chain-section h3{margin:0 0 14px;font-size:14px}.chain-section .form-grid{gap:14px}.field-hint{color:var(--muted);font-size:11px;line-height:1.6} -.row-actions{display:flex;gap:7px;align-items:center}.danger-link{color:var(--danger)} -@media(max-width:860px){.sidebar{width:218px;min-width:218px}.topbar{padding:0 18px}.content{padding:20px 18px 50px}} -@media(max-width:570px){.login-card{padding:28px 22px}.top-actions{gap:5px}.theme-button{min-width:56px}.content{padding:18px 12px 40px}} +/* ---------- 布局 ---------- */ +.layout{display:flex;min-height:100vh} +.sider{flex:0 0 224px;width:224px;background:var(--sider);border-right:1px solid var(--border-soft);position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:30} +.brand{display:flex;align-items:center;height:58px;padding:0 20px;border-bottom:1px solid var(--border-soft);font-size:19px;font-weight:700;letter-spacing:.4px} +.menu{padding:12px 10px;flex:1;overflow-y:auto} +.mi{display:flex;align-items:center;gap:10px;padding:8px 10px;margin:2px 0;border-radius:6px;color:var(--text-2);cursor:pointer} +.mi svg{width:16px;height:16px;flex:none} +.mi:hover{background:var(--hover-bg);color:var(--text)} +.mi.active{background:var(--primary-soft);color:var(--primary-text);font-weight:500} +.sider-foot{padding:12px 20px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:12.5px;display:flex;justify-content:space-between;align-items:center;gap:8px} +.sider-foot .foot-right{display:inline-flex;align-items:center;gap:6px;color:var(--text-3)} +.main{flex:1;min-width:0;display:flex;flex-direction:column} +.topbar{height:58px;display:flex;align-items:center;gap:12px;padding:0 24px;border-bottom:1px solid var(--border-soft);background:var(--bg);position:sticky;top:0;z-index:20} +.crumb{color:var(--text-3);font-size:13px;white-space:nowrap} +.crumb b{color:var(--text);font-weight:600;font-size:15px} +.topbar .sp{flex:1} +.top-search{background:var(--card);border:1px solid var(--border);border-radius:6px;color:var(--text-2);padding:5px 12px;font-size:13px;width:230px} +.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text-2);border-radius:6px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap} +.theme-btn:hover{border-color:var(--primary);color:var(--primary-text)} +.avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#1668dc,#13c2c2);display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:600;flex:none} +.content{padding:16px 20px 28px;max-width:1460px;width:100%;margin:0 auto} +.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px} +.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)} +.panel-head h2{font-size:14.5px;font-weight:600} +.panel-body{padding:14px 16px} +.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px} +.search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px} +select.search{width:auto} +.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap} +.btn:hover{border-color:var(--primary);color:var(--primary-text)} +.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff} +.btn.primary:hover{background:var(--primary-hover);color:#fff} +.btn.danger{color:var(--red-text)} +.btn.danger:hover{border-color:var(--red);color:var(--red-text);background:var(--red-soft)} +.btn.small{padding:2px 10px;font-size:12px} +.btn.full{width:100%;justify-content:center;padding:8px 14px} +.btn:disabled{opacity:.55;cursor:default} -/* Long device and customer lists stay inside a searchable, keyboard-friendly picker. */ -.picker-field{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700} -.picker-field>small,.picker-content>small{color:var(--muted);font-size:11px;font-weight:500} -.multi-picker{border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)} -.multi-picker>summary{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;cursor:pointer;list-style:none} -.multi-picker>summary::-webkit-details-marker{display:none} -.multi-picker[open]>summary{border-bottom:1px solid var(--line);color:var(--primary)} -.picker-content{padding:10px;display:grid;gap:9px}.picker-options{max-height:225px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} -.picker-option{display:flex;flex-direction:row;align-items:center;min-width:0;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);cursor:pointer} -.picker-option[hidden]{display:none}.picker-option:hover{border-color:var(--primary)}.picker-option input{flex:0 0 16px;width:16px;min-height:16px;accent-color:var(--primary)}.picker-option span{min-width:0;display:grid;gap:3px}.picker-option b,.picker-option small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker-option small{color:var(--muted);font-size:10px;font-weight:500} -.subscription-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)} -.subscription-row>div{min-width:0;display:grid;gap:5px}.subscription-row code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.subscription-row button{flex:0 0 auto} -@media(max-width:570px){.picker-options{grid-template-columns:1fr}.subscription-row{align-items:stretch;flex-direction:column}.subscription-row button{align-self:flex-end}} +/* ---------- 系统状态 ---------- */ +.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px} +.stat{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);padding:14px 16px 10px;overflow:hidden} +.stat .hd{display:flex;align-items:center;gap:7px;color:var(--text-2);font-size:13px} +.stat .hd svg{width:15px;height:15px;color:var(--primary);flex:none} +.stat .hd .scope{margin-left:auto;color:var(--text-3);font-size:11.5px} +.stat .big{font-size:30px;font-weight:600;line-height:1.25;margin-top:4px;font-variant-numeric:tabular-nums} +.stat .big small{font-size:15px;font-weight:500;color:var(--text-2);margin-left:2px} +.stat .det{color:var(--text-2);font-size:12.5px;font-variant-numeric:tabular-nums} +.stat .mm{display:flex;justify-content:space-between;color:var(--text-3);font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums} +.spark{display:block;width:100%;height:44px;margin-top:4px} +.mid-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:16px;align-items:stretch} +.chart-card{padding:14px 18px 12px;display:flex;flex-direction:column;margin-bottom:0} +.chart-hd{display:flex;align-items:flex-start;gap:12px} +.chart-hd h2{font-size:14.5px;font-weight:600} +.chart-hd .legend{margin-left:auto;display:flex;gap:18px;font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums} +.legend .up{color:var(--primary-text);font-weight:600} +.legend .dn{color:var(--text-2);font-weight:600} +.legend .lb{color:var(--text-3);font-weight:400;font-size:12px;margin-right:3px} +.chart{flex:1;width:100%;margin-top:8px} +.stat3{display:flex;border-top:1px solid var(--border-soft);margin-top:10px;padding-top:11px} +.stat3>div{flex:1;padding:0 14px} +.stat3>div:first-child{padding-left:0} +.stat3>div+div{border-left:1px solid var(--border-soft)} +.stat3 .k{color:var(--text-3);font-size:12px} +.stat3 .v{font-size:16.5px;font-weight:600;margin-top:1px;font-variant-numeric:tabular-nums} +.conn .big{font-size:34px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums} +.conn .big span{font-size:13px;font-weight:400;color:var(--text-3);margin-left:6px} +.conn .proto-row{display:flex;gap:26px;margin-top:8px;font-size:13px;font-variant-numeric:tabular-nums} +.conn .proto-row b{font-weight:600;margin-left:5px} +.swatch{display:inline-block;width:16px;height:0;border-top:2.5px solid var(--primary);border-radius:2px;vertical-align:middle} +.swatch.g{border-top-color:var(--chart-2)} +.info-bar{display:flex;gap:0;padding:14px 18px} +.info-bar .seg{flex:1;padding:0 18px;min-width:0} +.info-bar .seg:first-child{padding-left:0} +.info-bar .seg+.seg{border-left:1px solid var(--border-soft)} +.info-bar .cap{color:var(--text-3);font-size:12.5px;display:flex;align-items:center;gap:6px;margin-bottom:7px} +.info-bar .cap svg{width:14px;height:14px} +.kv{display:flex;gap:22px;flex-wrap:wrap} +.kv .item .k{display:block;color:var(--text-3);font-size:11px;letter-spacing:.6px} +.kv .item .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums} +.kv .item .v.mono{font-family:var(--mono);font-size:13px;font-weight:500;word-break:break-all} +.ln-up{stroke:var(--primary);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round} +.ln-dn{stroke:var(--chart-2);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round} +.mean{stroke:var(--primary);stroke-width:1;stroke-dasharray:4 4;opacity:.55;fill:none} +.mean-g{stroke:var(--chart-2);stroke-width:1;stroke-dasharray:4 4;opacity:.6;fill:none} +.grid-ln{stroke:var(--border-soft);stroke-width:1;stroke-dasharray:3 5;fill:none} +.stop-up-a{stop-color:var(--primary);stop-opacity:.26} +.stop-up-b{stop-color:var(--primary);stop-opacity:0} +.notice{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px} +.activity-list{display:flex;flex-direction:column} +.activity-list .event{display:flex;align-items:center;gap:10px;padding:7px 16px;border-bottom:1px solid var(--border-soft);font-size:13px} +.activity-list .event:last-child{border-bottom:none} +.activity-list .event b{font-weight:500} +.activity-list .event small{color:var(--text-3)} +.activity-list .event time{margin-left:auto;color:var(--text-3);font-size:12px;white-space:nowrap} +.event-dot{width:7px;height:7px;border-radius:50%;background:var(--primary);flex:none} -/* Readable defaults on wide admin screens without inflating the navigation. */ -table{font-size:13px}th{font-size:11px;letter-spacing:.02em}td strong{font-size:13px}td small{font-size:11px} -.panel-head p,.health-row span,.health-row small,.event small,.event time{font-size:12px} -.status,.tag{font-size:11px}.page-intro p{font-size:13px}.actions{min-width:0} +/* ---------- 表格通用 ---------- */ +.table-wrap{overflow-x:auto} +.table-wrap.visible{overflow:visible} +table{width:100%;border-collapse:collapse} +table.w-servers{min-width:1390px} +table.w-customers{min-width:1120px} +table.w-nodes{min-width:1180px} +thead th{text-align:left;font-size:12.5px;font-weight:600;color:var(--text-2);letter-spacing:.3px;padding:7px 12px;border-bottom:1px solid var(--border-soft);background:var(--th-bg);white-space:nowrap} +thead th .sarr{color:var(--text-3);font-size:10px;margin-left:3px} +thead th.ctr,tbody td.ctr{text-align:center} +tbody td{padding:6px 12px;border-bottom:1px solid var(--border-soft);vertical-align:middle;font-size:13.5px} +tbody tr:last-child td{border-bottom:none} +tbody tr:hover{background:var(--row-hover)} +tbody tr.dragging{opacity:.4} +tbody tr.drag-over td{border-top:2px solid var(--primary)} +.badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:5px;font-size:12px;line-height:20px;white-space:nowrap} +.badge.green{background:var(--green-soft);color:var(--green-text)} +.badge.red{background:var(--red-soft);color:var(--red-text)} +.badge.orange{background:var(--orange-soft);color:var(--orange-text)} +.badge.blue{background:var(--primary-soft);color:var(--primary-text)} +.badge.gray{background:var(--gray-soft);color:var(--text-2)} +.num{font-variant-numeric:tabular-nums;white-space:nowrap} +.mut{color:var(--text-3)} +.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none} +.dot.on{background:var(--green);animation:pulse 2s infinite} +.dot.off{background:var(--dot-off)} +.dot.bad{background:var(--red)} +@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(82,196,26,.45)}70%{box-shadow:0 0 0 6px rgba(82,196,26,0)}100%{box-shadow:0 0 0 0 rgba(82,196,26,0)}} +.op-cell{display:flex;align-items:center;gap:1px;white-space:nowrap} +.op-cell input[type="checkbox"]{margin-right:7px} +.icon-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text-2);border-radius:6px;cursor:pointer;padding:0;vertical-align:middle} +.icon-btn svg{width:17px;height:17px;display:block} +.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)} +.icon-btn.del{color:var(--red-text)} +.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)} +.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none} +.tgl input{opacity:0;width:0;height:0;position:absolute} +.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer} +.tgl .tr::before{content:"";position:absolute;width:17px;height:17px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.35)} +.tgl input:checked + .tr{background:var(--primary)} +.tgl input:checked + .tr::before{transform:translateX(17px)} +.tgl input:focus-visible + .tr{outline:2px solid var(--primary-hover);outline-offset:2px} +.tgl input:disabled + .tr{opacity:.5;cursor:default} +.pill{display:inline-flex;align-items:center;gap:7px;background:var(--pill-bg);color:var(--text);border-radius:999px;padding:3px 11px;font-size:12.5px;white-space:nowrap;font-variant-numeric:tabular-nums} +.pill.green{background:var(--green-soft);color:var(--green-text);font-weight:600} +.pill.red{background:var(--red-soft);color:var(--red-text);font-weight:600} +.pill.gray{background:var(--gray-soft);color:var(--text-2)} +.pill.mut{color:var(--text-3)} +.mini-bar{width:62px;height:6px;border-radius:99px;background:var(--track);overflow:hidden;display:inline-block;flex:none} +.mini-bar i{display:block;height:100%;background:var(--primary);border-radius:99px} +.pill.warn .mini-bar i{background:var(--orange)} +.pill.danger .mini-bar i{background:var(--red)} +.pager{display:flex;justify-content:flex-end;align-items:center;gap:6px;padding:9px 16px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:13px} +.pg-btn{min-width:28px;height:28px;padding:0 7px;border:1px solid var(--border);background:var(--card);border-radius:6px;color:var(--text-2);cursor:pointer;font-size:13px} +.pg-btn.active{border-color:var(--primary);color:var(--primary-text)} +.pg-btn:disabled{opacity:.45;cursor:default} +.empty{padding:28px 16px;text-align:center;color:var(--text-3);font-size:13px} +.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px} -/* Keep dense management tables at one row per item, even at narrow widths. */ -.table-wrap table{min-width:1040px;table-layout:auto} -.table-wrap td{padding:9px 13px;line-height:1.35} -.table-wrap th{padding:10px 13px} -.table-wrap td:last-child{min-width:200px} -.table-wrap td strong{font-weight:650} -.table-wrap td small{font-weight:400;margin-top:3px} -.actions.compact-actions{min-width:0;justify-content:flex-end;flex-wrap:nowrap;align-items:center;gap:4px} -.compact-actions button{padding:6px 8px;font-size:11px;font-weight:600} -.compact-actions select{width:92px;min-height:30px;padding:4px 7px;font-size:11px;font-weight:500;cursor:pointer} -.error-detail{max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--danger)} -.subscription-row{flex-wrap:wrap;background:var(--surface)} -.subscription-row>div:first-child{flex:1 1 310px} -.access-table{max-height:280px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px} -.access-table table{min-width:610px} -.access-table td{padding:9px 12px} -.access-table td small{max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.subscription-actions{display:flex;gap:5px;flex:0 0 auto} -.subscription-qr{flex:1 0 100%;text-align:center;padding:14px;border-top:1px solid var(--line)} -.subscription-qr[hidden]{display:none} -.subscription-qr svg{max-width:min(100%,250px);height:auto;background:#fff;border:9px solid #fff;border-radius:8px} -.status.suspending,.status.partially_suspended{color:var(--warning);background:var(--warning-soft)} -.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:16px;align-items:start} -.command-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)} -.command-item:last-child{border-bottom:0}.command-item>div{min-width:0;display:grid;gap:5px} -.command-item small{color:var(--muted);font-size:11px;line-height:1.55}.command-item code{font-size:12px;color:var(--primary)} -.command-item button{flex:0 0 auto} -.node-link-list{display:grid;gap:8px;margin-top:8px} -.node-link-customer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px} -.node-link-customer .actions{margin:0} -details summary{cursor:pointer;font-weight:600} +/* ---------- 服务器页 ---------- */ +.stats{display:grid;grid-template-columns:repeat(4,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px} +.stats .stat{padding:14px 22px;border:none;background:transparent} +.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px} +.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums} +.stats .stat .val svg{width:19px;height:19px;flex:none} +.ic-dim{color:var(--text-2)} .ic-green{color:var(--green)} .ic-red{color:var(--red)} .ic-bolt{color:var(--orange)} +.exp{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:5px;color:var(--text-2);font-size:14px;line-height:1;background:var(--card);cursor:pointer;padding:0} +.eye-btn{width:24px;height:24px;vertical-align:-6px;margin-left:4px} +.eye-btn svg{width:15px;height:15px} +.dev-name{font-weight:600;font-size:13.5px;white-space:nowrap} +.addr{font-family:var(--mono);font-size:12.5px;color:var(--text-2);white-space:nowrap} +.status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-weight:500} +.status.on{color:var(--green-text)} +.status.off{color:var(--red-text)} +.warn-ic{width:15px;height:15px;color:var(--orange);flex:none;vertical-align:-2px} +.ver-cell{display:inline-flex;align-items:center;gap:7px;white-space:nowrap} +.ver{font-family:var(--mono);font-size:12.5px} +.clients-cell{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-variant-numeric:tabular-nums} +.clients-cell svg{width:16px;height:16px;color:var(--text-3);flex:none} +.clients-cell .on-n{color:var(--green-text);font-weight:600} +.hb{white-space:nowrap;color:var(--text-2)} +.detail-row td{background:var(--th-bg);padding:12px 16px} +.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px 22px;font-size:13px} +.detail-grid .k{color:var(--text-3);font-size:11.5px;display:block} +.detail-grid .v{font-weight:500} +.detail-grid .v.err{color:var(--red-text)} +.detail-actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap} + +/* ---------- 客户页 ---------- */ +.pres{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:2px 11px;font-size:12.5px;font-weight:600;white-space:nowrap} +.pres.on{background:var(--green-soft);color:var(--green-text)} +.pres.off{background:var(--gray-soft);color:var(--text-3);font-weight:400} +.paused-note{display:block;color:var(--red-text);font-size:12px;font-weight:500;margin-top:4px;white-space:nowrap} +.cust-name{font-weight:600;font-size:13.5px;white-space:nowrap} +.cust-note{display:block;color:var(--text-3);font-size:12px;margin-top:1px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.in-chip{display:inline-flex;padding:2px 9px;border-radius:6px;background:var(--primary-soft);color:var(--primary-text);font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap} +.in-chip.more{background:var(--gray-soft);color:var(--text-2);margin-left:5px;cursor:pointer;border:none;font-family:var(--font)} +.in-chip.more:hover{color:var(--primary-text);background:var(--primary-soft)} +.in-chip.ss{background:var(--pink-soft);color:var(--pink-text)} +.in-chip.mixed{background:var(--orange-soft);color:var(--orange-text)} +.speed{font-variant-numeric:tabular-nums;white-space:nowrap} +.speed.mut{color:var(--text-3)} +.filter-chip{display:inline-flex;align-items:center;gap:6px;background:var(--primary-soft);color:var(--primary-text);border-radius:999px;padding:3px 6px 3px 11px;font-size:12.5px} +.filter-chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:2px} + +/* ---------- 节点页 ---------- */ +.grip{display:inline-flex;padding:3px 2px;border-radius:4px;color:var(--text-3);cursor:grab;vertical-align:middle} +.grip svg{width:13px;height:13px;display:block} +.grip:hover{color:var(--text);background:var(--hover-bg)} +.grip:active{cursor:grabbing} +td.menu-td{white-space:nowrap} +.node-name{font-weight:600;font-size:13.5px;white-space:nowrap} +.srv-chip{display:inline-flex;padding:2px 10px;border-radius:6px;font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap} +.srv-chip.online{background:var(--primary-soft);color:var(--primary-text)} +.srv-chip.offline{background:var(--red-soft);color:var(--red-text)} +.srv-chip.degraded{background:var(--orange-soft);color:var(--orange-text)} +.port{font-variant-numeric:tabular-nums} +.proto{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;font-weight:600;line-height:18px;white-space:nowrap} +.proto.vless{background:var(--purple-soft);color:var(--purple-text)} +.proto.ss{background:var(--cyan-soft);color:var(--cyan-text)} +.proto.mixed{background:var(--orange-soft);color:var(--orange-text)} +.proto.other{background:var(--gray-soft);color:var(--text-2)} +.trans{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--trans-bg);color:var(--trans-text)} +.sec{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--sky-soft);color:var(--sky-text)} +.sec.plain{background:var(--gray-soft);color:var(--text-2)} +.badge-stack{display:inline-flex;gap:4px;flex-wrap:nowrap;align-items:center} +.clients{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer} +.clients>svg{width:17px;height:17px;color:var(--text-3);flex:none} +.online-num{color:var(--green-text);font-weight:600;padding:1px 2px} +.online-num.zero{color:var(--text-3);font-weight:400} + +/* ---------- 浮层(挂 body) ---------- */ +.float-pop{position:fixed;z-index:90;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);min-width:176px;max-width:320px;padding:6px 0} +.float-pop .pop-head{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;border-bottom:1px solid var(--border-soft);font-size:12px;color:var(--text-2)} +.float-pop .pop-head b{color:var(--text);font-size:13px} +.float-pop .pop-item{display:flex;align-items:center;gap:8px;padding:5px 12px;color:var(--text);font-size:13px} +.float-pop .pop-item .in-chip{margin:2px 0} +.float-pop .pop-foot{padding:6px 12px;border-top:1px solid var(--border-soft);font-size:11px;color:var(--text-3)} +.float-pop .menu-item{display:flex;width:100%;align-items:center;gap:8px;padding:7px 12px;border:none;background:transparent;color:var(--text);font-size:13px;cursor:pointer;text-align:left} +.float-pop .menu-item:hover{background:var(--hover-bg)} +.float-pop .menu-item.danger{color:var(--red-text)} +.float-pop .chip-list{padding:8px 12px;display:flex;flex-direction:column;gap:6px;align-items:flex-start} + +/* ---------- 弹窗与表单 ---------- */ +.modal-overlay{position:fixed;inset:0;background:rgba(10,12,16,.45);z-index:80;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:36px 16px} +.modal{width:640px;max-width:100%;margin:0} +.modal.wide{width:820px} +.modal-head{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--border-soft)} +.modal-head h2{font-size:15.5px;font-weight:600} +.modal-body{padding:16px 18px} +.stack{display:flex;flex-direction:column;gap:12px} +.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px} +.form-grid .wide{grid-column:1 / -1} +label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:var(--text-2)} +label>input,label>select,label>textarea,.field>input{border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13.5px;color:var(--text)} +label>textarea{min-height:56px;resize:vertical} +label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--primary);outline:none} +.form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px} +.form-actions .left{margin-right:auto} +.inline-fields{display:flex;gap:8px} +.inline-fields>*{flex:1;min-width:0} +.pair-fields{display:flex;gap:10px;align-items:flex-end} +.pair-fields label{flex:1} +.form-error{color:var(--red-text);font-size:13px;min-height:18px} +.codebox{background:var(--bg);border:1px solid var(--border-soft);border-radius:6px;padding:10px 12px;font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;word-break:break-all;color:var(--text)} +.section-title{grid-column:1 / -1;font-size:13px;font-weight:600;color:var(--text);border-top:1px solid var(--border-soft);padding-top:12px} +.kv-table{width:100%;border-collapse:collapse} +.kv-table td{padding:5px 8px;border-bottom:1px solid var(--border-soft);font-size:13px;vertical-align:top} +.kv-table td.k{color:var(--text-3);width:130px;white-space:nowrap} +.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all} + +/* 页签 */ +.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px} +.tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px} +.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500} +.tab-pane.hidden{display:none} + +/* 凭据行 */ +.cred-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border-soft)} +.cred-row:last-child{border-bottom:none} +.cred-row .k{width:110px;flex:none;color:var(--text-3);font-size:12.5px} +.cred-row .v{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;word-break:break-all} +.cred-group{margin-bottom:14px} +.cred-group .g-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:13.5px;margin-bottom:2px} + +/* 链接页签条目 */ +.link-item{border:1px solid var(--border-soft);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px} +.link-item .row{display:flex;gap:8px;align-items:center} +.link-item input[type="text"],.link-item input:not([type]){flex:1;border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13px;min-width:0} +.link-item .remark{flex:0 0 150px} +.link-item .status-line{font-size:12px;color:var(--text-3)} +.link-item .status-line .err{color:var(--red-text)} +.links-head{display:flex;align-items:center;justify-content:space-between;margin:14px 0 8px} +.links-head h3{font-size:13.5px;font-weight:600} +.links-note{font-size:13px;color:var(--text-2)} +.links-empty{color:var(--text-3);font-size:13px;padding:6px 0} + +/* 关联入站选择器 */ +.picker{position:relative} +.picker-box{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:5px 8px;min-height:34px;cursor:text;background:var(--card)} +.picker-box .chip{display:inline-flex;align-items:center;gap:5px;background:var(--primary-soft);color:var(--primary-text);border-radius:5px;padding:1px 4px 1px 8px;font-size:12.5px} +.picker-box .chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:13px;line-height:1;padding:2px} +.picker-box .ph{color:var(--text-3);font-size:13px} +.picker-box .search-ic{margin-left:auto;color:var(--text-3);flex:none} +.picker-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);padding:6px} +.picker-tools{display:flex;gap:8px;align-items:center;padding:2px 4px 6px} +.picker-tools input{flex:1;border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:13px;background:var(--card)} +.picker-list{max-height:220px;overflow-y:auto} +.picker-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--text)} +.picker-opt:hover{background:var(--hover-bg)} +.picker-opt.sel{background:var(--primary-soft)} +.picker-opt .ck{margin-left:auto;color:var(--primary-text);font-weight:700;visibility:hidden} +.picker-opt.sel .ck{visibility:visible} +.picker-opt small{color:var(--text-3)} + +/* 订阅弹窗 */ +.subscription-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)} +.subscription-row>div:first-child{flex:1;min-width:0} +.subscription-row b{display:block;font-size:13.5px} +.subscription-row code{font-family:var(--mono);font-size:11.5px;color:var(--text-2);word-break:break-all} +.subscription-actions{display:flex;gap:6px;flex:none} +.subscription-qr{padding:10px 0;text-align:center} +.subscription-qr svg{max-width:180px;height:auto} +.warn-note{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px} + +/* 命令页 / 运维页 */ +.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} +.command-item{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)} +.command-item:last-child{border-bottom:none} +.command-item>div{flex:1;min-width:0} +.command-item b{display:block;font-size:13.5px} +.command-item code{font-family:var(--mono);font-size:12.5px;color:var(--primary-text)} +.grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} +.grid-3 .panel{margin-bottom:0} +.health-row{display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border-soft);font-size:13.5px} +.health-row:last-child{border-bottom:none} +.health-row span{color:var(--text-3);font-size:12.5px;display:block} +.tag{display:inline-flex;padding:1px 8px;border-radius:5px;background:var(--gray-soft);color:var(--text-2);font-size:12px;line-height:20px} + +/* 登录 */ +.login-shell{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px} +.login-card{width:360px;max-width:100%;padding:26px 26px 18px;position:relative;margin:0} +.login-brand{font-size:26px;font-weight:700;letter-spacing:.4px;margin-bottom:18px} +.login-theme{position:absolute;top:14px;right:14px} +.login-card label{margin-bottom:12px} +.login-card label>input{width:100%} + +/* toast */ +#toasts{position:fixed;right:18px;bottom:18px;z-index:120;display:flex;flex-direction:column;gap:8px} +.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px} +.toast.error{border-left-color:var(--red)} + +@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}} +@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}}