feat(panel, netmgr §11): MeowPanel 网络管理页—netmgrd 期2 面板侧

后端(patch_v2/meowpanel/meowpanel.c,/api/audio 同款透传模式):
- GET /api/net/status    → 0xE0 概览 JSON(接口表 + WiFi/BT 摘要 + 速率)
- GET /api/net/conns?entity= → 0xE8 连接表(解析 entity|本端|远端|状态|寿命|域名 → JSON,transit 截断标记)
- GET /api/net/dns       → 0xEC status(mode/enabled/upstream/缓存命中/查询/deny/resolv_modified)
- POST /api/net/dns/flush → 0xEC flush(写级:HTTP 层走 require_auth;IPC 身份由 netmgrd 判定)
- GET /api/net/log       → TF MeowOS/logs/ 最新 net_*.log tail(256KB 上限,1000 行)
- netmgrd 不在/超时/应答异常 → 503 降级 JSON(NET_UNAVAILABLE),面板其它页不受影响
- 新增 --sock-net / env MEOW_NET_SOCK(宿主/qemu 测试用)

前端(web/,MD3/MIUIx 双主题沿用 270 落地件):
- 侧栏新增「网络」入口(#/net);接口卡 + WiFi 摘要 + 蓝牙摘要 + 每实体连接表
  (entity 前缀筛选 + 5s 轮询)+ DNS 代理卡(统计 + 清缓存按钮)+ 日志查看器
- netmgrd 不在 → 显示「网络管理服务不可用」占位 + 重试,不白屏不报错栈

验证:宿主 cc -Wall -Wextra 0 warning;mipsel 交叉编译 0 warning;假 netmgrd IPC 桩
(Python 帧级桩)联调五端点含 entity 过滤/flush 成功与 403/503 降级全通过;headless
Chromium 渲染 #/net(服务在线/离线两态)与 #/perf #/audio 无回归。
后端起二进制 md5: 21c70046476d3bf63e76f3d63e9eca2c(mipsel 静态)。

遗留(主会话部署时决策):netmgrd flush 写级依 SO_PEERCRED 判系统身份,面板 exe 位于
/usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/(非 /usr/bin/meow_* 亦非 os.meow.* 包)→ 真机
上 flush 会被 netmgrd 拒(403)。需在 netmgrd classify_exe 增认面板路径,或接受 403 只读。
不部署到设备(留主会话热更)。
This commit is contained in:
MeowOS committed 2026-09-01 21:01:00 +08:00
1 parent 52edf0c9e4
commit d8eeef5c57
6 files changed
+484 -2

No files matched your search

+4
View File
@@ -33,6 +33,10 @@
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 10v4h3l5 4V6l-5 4H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
<span class="nav-label">音频</span>
</button>
<button class="nav-item" data-route="net" title="网络管理(meow_netmgrd)">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></svg></span>
<span class="nav-label">网络</span>
</button>
<button class="nav-item" data-route="files" title="文件管理">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
<span class="nav-label">文件</span>
+13
View File
@@ -301,6 +301,19 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
/* 日志查看 */
.log-view{ background:var(--surface-dim); border-radius:var(--radius-m); padding:12px; max-height:60vh; overflow:auto; font-family:var(--mono); font-size:12px; white-space:pre-wrap; color:var(--on-surface); }
/* 网络管理页(meow_netmgrd) */
.net-kind{ color:var(--on-surface-variant); font-size:12px; }
.net-state{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; background:var(--surface-variant); color:var(--on-surface-variant); }
.net-state.ok{ background:rgba(76,175,80,.15); color:var(--success); }
.net-state.warn{ background:rgba(255,179,0,.15); color:var(--warning); }
.net-state.listen{ background:rgba(74,139,245,.15); color:var(--primary); }
.net-state.down{ background:rgba(207,102,121,.15); color:var(--error); }
.net-badge{ display:inline-block; padding:1px 7px; border-radius:999px; font-size:11px; font-family:var(--mono); background:var(--surface-variant); color:var(--on-surface-variant); margin-right:2px; }
.net-badge.app{ background:rgba(74,139,245,.18); color:var(--primary); }
.net-badge.svc{ background:rgba(76,175,80,.18); color:var(--success); }
.net-badge.proc{ background:rgba(255,179,0,.18); color:var(--warning); }
.net-entity{ font-family:var(--mono); font-size:12px; word-break:break-all; }
/* 通知列表 */
.notif{ display:flex; gap:12px; padding:12px; border-radius:var(--radius-m); background:var(--surface-variant); margin-bottom:10px; align-items:flex-start; border:var(--outline-w) solid var(--outline-variant); }
.notif .n-ico{ width:36px; height:36px; border-radius:var(--radius-m); background:var(--primary); color:var(--on-primary); display:flex; align-items:center; justify-content:center; flex:0 0 36px; font-weight:700; }
+216 -1
View File
@@ -433,6 +433,219 @@
audioTimer = setInterval(fetchAudioOnce, 5000);
}
/* ---------------- 2c. 网络管理页(meow_netmgrd 期2 面板侧) ----------------
* 数据源:GET /api/net/status(E0 接口+WiFi/BT 摘要)、/api/net/conns?entity=(E8 连接表)、
* /api/net/dns(EC DNS 统计)、/api/net/dns/flush(EC flush 写级)、/api/net/log(net_*.log tail)。
* netmgrd 不在 → 后端 503(NET_UNAVAILABLE)→ 本页显示「网络管理服务不可用」占位,不白屏。
* status 成功即开始 5s 轮询(接口/WiFi/BT + 连接表 + DNS 统计);日志仅在进入页面/点「刷新」时拉取。 */
let netTimer = null;
let netEntityFilter = '';
function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } }
function fmtDur(sec) { // 连接寿命(秒级精度)
if (sec == null || isNaN(sec)) return '—';
sec = Math.floor(sec);
if (sec < 60) return sec + ' 秒';
const m = Math.floor(sec / 60), s = sec % 60;
if (m < 60) return m + ' 分 ' + s + ' 秒';
const h = Math.floor(m / 60), mm = m % 60;
return h + ' 时 ' + mm + ' 分';
}
function netBadge(e) {
const i = e.indexOf(':');
const kind = i > 0 ? e.slice(0, i) : 'proc';
return '<span class="net-badge ' + esc(kind) + '">' + esc(kind) + '</span>' +
(i > 0 ? ' <span class="net-entity">' + esc(e.slice(i + 1)) + '</span>' : '');
}
function netStateCls(st) {
if (!st) return '';
if (st === 'ESTABLISHED') return 'net-state ok';
if (st === 'LISTEN') return 'net-state listen';
if (st === 'UDP') return 'net-state listen';
return 'net-state warn';
}
function netStatusCards(d) {
let h = '';
// 接口卡(lo/wlan0/usb-eth:状态+IP+速率)
const ifs = (d && d.ifaces) || [];
h += '<div class="card"><h3 class="card-title">网络接口</h3><table class="table"><thead>' +
'<tr><th>接口</th><th>类型</th><th>状态</th><th>IP 地址</th><th>网关</th><th>RX / TX</th></tr></thead><tbody>';
if (!ifs.length) h += '<tr><td colspan="6" class="empty">无接口数据</td></tr>';
ifs.forEach(function (f) {
const up = !!f.up;
h += '<tr><td class="mono">' + esc(f.name) + '</td><td><span class="net-kind">' + esc(f.kind || '—') + '</span></td>' +
'<td><span class="net-state ' + (up ? 'ok' : 'down') + '">' + (up ? 'UP' : 'DOWN') + '</span></td>' +
'<td class="mono">' + esc(f.ip || '—') + '</td><td class="mono">' + esc(f.gw || '—') + '</td>' +
'<td class="muted">' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '</td></tr>';
});
h += '</tbody></table>';
if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null))
h += '<p class="muted">实时速率:↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s</p>';
h += '</div>';
// WiFi 摘要
const w = (d && d.wifi) || {};
h += '<div class="card"><h3 class="card-title">WiFi</h3><table class="table"><tbody>';
if (w.connected) {
h += row('SSID', '<span class="net-entity">' + esc(w.ssid || '—') + '</span>');
h += row('状态', '<span class="net-state ok">' + esc(w.state || '已连接') + '</span>');
h += row('IP', esc(w.ip || '—'));
h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—'));
} else {
h += row('状态', '<span class="net-state warn">未连接</span>');
}
h += '</tbody></table></div>';
// 蓝牙摘要
const bt = (d && d.bt) || {};
h += '<div class="card"><h3 class="card-title">蓝牙</h3><table class="table"><tbody>';
if (bt.available) {
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
h += row('地址', esc(bt.address || '—'));
h += row('已配对', esc(bt.paired != null ? bt.paired : '—'));
h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—'));
} else {
h += row('状态', '<span class="net-state warn">无蓝牙</span>');
}
h += '</tbody></table></div>';
return h;
}
function netConnsBody(cn) {
const list = (cn && cn.conns) || [];
let h = '';
if (!list.length) h += '<div class="empty">暂无连接' + (netEntityFilter ? '(该实体)' : '') + '</div>';
else {
h += '<table class="table"><thead><tr><th>实体</th><th>远端(域名)</th><th>本端</th><th>状态</th><th>寿命</th></tr></thead><tbody>';
list.forEach(function (c) {
h += '<tr><td class="net-entity">' + netBadge(c.entity) + '</td>' +
'<td class="mono">' + esc(c.domain || c.remote || '—') + '</td>' +
'<td class="mono muted">' + esc(c.local || '—') + '</td>' +
'<td><span class="' + netStateCls(c.state) + '">' + esc(c.state || '—') + '</span></td>' +
'<td class="muted">' + fmtDur(c.age) + '</td></tr>';
});
h += '</tbody></table>';
if (cn.truncated) h += '<p class="muted">连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)</p>';
}
return h;
}
function netDnsBody(dn) {
if (!dn) return '<div class="empty">DNS 代理不可用</div>';
let h = '<table class="table"><tbody>' +
row('状态', '<span class="net-state ' + (dn.enabled ? 'ok' : 'warn') + '">' + (dn.enabled ? '已启用' : '已停用') + '</span>') +
row('模式', esc(dn.mode || '—')) +
row('上游', esc(dn.upstream || '—')) +
row('缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0)) +
row('查询数', esc(dn.queries)) +
row('拦截 (deny)', esc(dn.denies)) +
row('首次域名', esc(dn.first_seen)) +
row('resolv.conf', (dn.resolv_modified ? '<span class="muted">已被代理改写</span>' : '未改写')) +
'</tbody></table>';
h += '<p class="muted">写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。</p>';
return h;
}
function netUnavailable(msg) {
stopNet();
const m = msg || 'meow_netmgrd 服务未运行或 IPC 不可达。';
$('#content').innerHTML = '<div class="page" id="net-page">' +
'<div class="card"><h3 class="card-title">网络管理</h3>' +
'<div class="empty">网络管理服务不可用</div>' +
'<p class="muted">' + esc(m) + '</p>' +
'<p class="muted">面板其它页不受影响;恢复 meow_netmgrd 后点击刷新重试。</p>' +
'<div class="field-row"><button class="btn" id="net-retry">重试</button></div>' +
'</div></div>';
}
async function fetchNetOnce() {
if (state.route !== 'net') return;
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body');
let st, cn, dn;
try { st = await api('GET', '/api/net/status'); }
catch (e) { netUnavailable(errText(e)); return; }
try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); }
catch (e) { cn = null; }
try { dn = await api('GET', '/api/net/dns'); }
catch (e) { dn = null; }
if (state.route !== 'net') return;
if (stEl) stEl.innerHTML = netStatusCards(st);
if (cbEl) cbEl.innerHTML = netConnsBody(cn);
if (dbEl) dbEl.innerHTML = netDnsBody(dn);
}
async function fetchNetConns() {
if (state.route !== 'net') return;
const cbEl = $('#net-conns-body'); if (!cbEl) return;
let cn;
try {
cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : ''));
} catch (e) { cn = null; }
if (state.route !== 'net') return;
cbEl.innerHTML = netConnsBody(cn);
}
async function fetchNetLog() {
if (state.route !== 'net') return;
const el = $('#net-log-body');
try {
const d = await api('GET', '/api/net/log');
if (el) {
if (!d || !d.file) { el.innerHTML = '<div class="empty">暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '</div>'; return; }
el.innerHTML = '<pre class="log-view">' + esc((d.lines || []).join('\n') || '(空)') +
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>';
const name = $('#net-log-file'); if (name) name.textContent = d.file;
}
} catch (e) { if (el) el.innerHTML = '<div class="empty">日志加载失败:' + esc(errText(e)) + '</div>'; }
}
async function renderNet() {
stopNet();
netEntityFilter = '';
$('#content').innerHTML =
'<div class="page" id="net-page">' +
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<input type="text" id="net-entity-filter" placeholder="筛选实体(app: / svc: / proc:)" style="max-width:280px" value="' + esc(netEntityFilter) + '">' +
'<button class="btn" id="net-conns-refresh">刷新</button></div>' +
'<div id="net-conns-body"><p class="placeholder">加载连接表…</p></div></div>' +
'<div class="card"><h3 class="card-title">DNS 代理</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
'<button class="btn" id="net-dns-flush">清空缓存</button></div>' +
'<div id="net-dns-body"><p class="placeholder">加载 DNS 统计…</p></div></div>' +
'<div class="card"><h3 class="card-title">网络日志 <span class="muted" id="net-log-file"></span></h3>' +
'<div class="field-row" style="margin-bottom:12px"><button class="btn" id="net-log-refresh">刷新</button></div>' +
'<div id="net-log-body"><p class="placeholder">加载日志…</p></div></div>' +
'</div>';
netTimer = setInterval(fetchNetOnce, 5000); // status 失败时 netUnavailable 会 stopNet() 清掉
fetchNetOnce(); // 填充 status + conns + dns
fetchNetLog();
}
function bindNet() {
$('#content').addEventListener('input', function (e) {
const f = e.target.closest('#net-entity-filter'); if (!f) return;
netEntityFilter = f.value.trim();
const cb = $('#net-conns-body'); if (cb) cb.textContent = '筛选…';
fetchNetConns();
});
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; }
if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; }
if (e.target.closest('#net-retry')) { renderNet(); return; }
const flush = e.target.closest('#net-dns-flush');
if (flush) {
flush.disabled = true;
try {
const d = await api('POST', '/api/net/dns/flush');
toast('已清空 DNS 缓存(' + (d && d.entries != null ? d.entries : '') + ' 条)');
} catch (err) { toast('清缓存失败:' + errText(err)); }
flush.disabled = false;
fetchNetOnce();
}
});
}
/* ---------------- 3. 文件管理页 ---------------- */
// 返回带索引的 rel 显示面包屑
function joinRel(a, b) {
@@ -1258,6 +1471,7 @@
perf: { title: '实时性能', render: renderPerf, bind: null },
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
audio: { title: '音频状态', render: renderAudio, bind: null },
net: { title: '网络管理', render: renderNet, bind: bindNet },
files: { title: '文件管理', render: renderFiles, bind: bindFiles },
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
fonts: { title: '字体管理', render: renderFontsPage, bind: bindFonts },
@@ -1280,6 +1494,7 @@
state.route = id;
stopPerf();
stopAudio();
stopNet();
stopScreenPoll();
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
$('#page-title').textContent = r.title;
@@ -1299,7 +1514,7 @@
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif();
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用