diff --git a/panel-go/golden/golden.json b/panel-go/golden/golden.json
index 8346b00..0244ca6 100644
--- a/panel-go/golden/golden.json
+++ b/panel-go/golden/golden.json
@@ -1,6 +1,6 @@
{
"meta": {
- "capturedAt": "2026-10-20T21:35:58.550Z",
+ "capturedAt": "2026-10-20T21:46:16.902Z",
"note": "Go panel golden baseline; see README.md in this directory."
},
"sections": {
@@ -3584,7 +3584,7 @@
},
"errors": {
"badSubscription": {
- "body": "\n\n
\n \n \n \n \n amo\n \n \n\n\n \n\n \n\n \n \n \n \n \n\n\n",
+ "body": "\n\n\n \n \n \n \n amo\n \n \n\n\n \n\n \n
\n\n
\n \n amo / 系统状态\n \n \n \n \n \n
\n
\n\n \n \n \n \n \n\n\n",
"status": 200
},
"loginFailure": {
diff --git a/public/app.js b/public/app.js
index 99eb2f9..2341f46 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,7 +1,7 @@
'use strict';
-/* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 /
- 安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
+/* amo 控制台前端。五个页面:系统状态 / 节点管理 / 用户订阅 / 入站&出站 /
+ 设置。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
const state = {
session: null, page: 'overview', overview: null, system: null,
@@ -62,14 +62,7 @@ const statusText = {
};
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 = {
@@ -264,8 +257,7 @@ async function load(page = state.page) {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
- } else if (page === 'commands') { /* 静态页 */ }
- else if (page === 'operations') {
+ } else if (page === 'operations') {
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
$('#version-label').textContent = `v${state.version}`;
@@ -274,8 +266,8 @@ async function load(page = state.page) {
} catch (error) { toast(error.message, true); }
}
-const PAGES = ['overview', 'servers', 'customers', 'chains', 'commands', 'operations'];
-const TITLES = { overview: '系统状态', servers: '服务器', customers: '客户与订阅', chains: '转发与节点', commands: '安装与命令', operations: '设置与运维' };
+const PAGES = ['overview', 'servers', 'customers', 'chains', 'operations'];
+const TITLES = { overview: '系统状态', servers: '节点管理', customers: '用户订阅', chains: '入站&出站', operations: '设置' };
function pageFromHash() { const page = location.hash.replace(/^#/, '').split('?')[0]; return PAGES.includes(page) ? page : 'overview'; }
function setPage(page) {
if (!PAGES.includes(page)) page = 'overview';
@@ -370,7 +362,6 @@ function renderOverview() {
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 `
@@ -401,8 +392,7 @@ function renderOverview() {
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('、')}。到「转发与节点」查看并修复。
` : ''}
- `;
+ ${degraded.length ? `${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「入站&出站」查看并修复。
` : ''}`;
}
/* ---------- 服务器 ---------- */
@@ -488,7 +478,7 @@ function renderServers() {
${rows || emptyRow(14, '没有匹配设备')}${pagerHTML('servers', filtered.length)}`;
}
-/* ---------- 客户与订阅 ---------- */
+/* ---------- 用户订阅 ---------- */
function protocolFamily(protocol) {
const id = String(protocol || '');
if (id.startsWith('vless')) return { cls: '', label: 'VLESS' };
@@ -841,7 +831,7 @@ function uninstallModal() {
`);
}
-/* ---------- 转发与节点 ---------- */
+/* ---------- 入站&出站 ---------- */
function protoBadges(node) {
const fam = (id) => {
const v = String(id || '');
@@ -893,9 +883,9 @@ function renderNodes() {
${speed > 0 ? `↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)}` : '—'} |
∞ | `;
}).join('');
- return `
+ return `
+
${state.nodes.length} 个入站在线 ${active}${degraded ? `
降级 ${degraded}` : ''}
${pagerHTML('nodes', filtered.length)}`;
}
@@ -916,7 +906,7 @@ function nodeForm(item = null) {
出口与转发传输
-
+
@@ -1036,8 +1026,8 @@ function nodeMenuHTML(node) {
return items.join('');
}
-/* ---------- 安装与命令 ---------- */
-function renderCommands() {
+/* ---------- 设置(命令速查已合并至此页) ---------- */
+function commandsHTML() {
const groups = [
['面板机', [['更新控制面', 'ng update'], ['检查面板 HTTPS', 'ng cert'], ['备份面板', 'ng backup']]],
['入口机 / 中转机', [['更新 Agent', 'ng-agent update'], ['申请或续签节点证书', 'ng-agent cert'], ['安装 sing-box 引擎', 'ng-agent engine install'], ['检查证书、引擎与双向计数', 'ng-agent doctor']]],
@@ -1046,7 +1036,6 @@ function renderCommands() {
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 `
@@ -1065,11 +1054,12 @@ function renderOperations() {
ng update\nng domain new.example.com\nng cert
- 任务队列
| 任务 | 设备 | 状态 | 尝试 | 更新时间 | 错误 |
${rows || emptyRow(6, '任务队列为空')}
`;
+ ${commandsHTML()}
+ 任务队列
| 任务 | 设备 | 状态 | 尝试 | 更新时间 | 错误 |
${rows || emptyRow(6, '任务队列为空')}
`;
}
function render() {
- const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations };
+ const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
}
diff --git a/public/index.html b/public/index.html
index 9797592..2bb09a0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -28,11 +28,10 @@
amo
@@ -41,7 +40,6 @@
diff --git a/public/styles.css b/public/styles.css
index 49f1304..7ba8715 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -65,7 +65,6 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
.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)}
.content{padding:24px;width:100%}
@@ -137,13 +136,6 @@ select.search{width:auto}
.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}
/* ---------- 表格通用 ---------- */
.table-wrap{overflow-x:auto}
@@ -152,6 +144,9 @@ table{width:100%;border-collapse:collapse}
table.w-servers{min-width:1390px}
table.w-customers{min-width:1120px}
table.w-nodes{min-width:1180px}
+table.w-jobs td{white-space:nowrap;padding-top:5px;padding-bottom:5px}
+table.w-jobs th:last-child{width:22%}
+table.w-jobs td:last-child{white-space:normal;min-width:120px;word-break:break-word}
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}
@@ -322,9 +317,9 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all}
/* 页签 */
-.seg{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
-.seg button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
-.seg button.active{background:var(--primary);color:#fff}
+.segctl{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
+.segctl button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
+.segctl button.active{background:var(--primary);color:#fff}
.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}
@@ -379,7 +374,8 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.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-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-bottom:16px}
+.command-grid .panel{margin-bottom:0}
.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}
@@ -406,4 +402,4 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.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)}.stats.six{grid-template-columns:repeat(3,1fr)}}
-@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}.content{padding:8px}}
+@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}