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

Binary file not shown.
+251 -1
View File
@@ -93,6 +93,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notificat
#define POWER_SOCK "/usr/data/meow_power.sock"
#define LAUNCHER_SOCK "/usr/data/meow_ipc.sock" /* launcher 主 IPC(通知活跃集查询/撤清) */
#define AUDIO_SOCK "/usr/data/meow_audio.sock" /* meow_audiod 状态快照(MSG_AUDIO_QUERY→STATE) */
#define NET_SOCK "/usr/data/meow_net.sock" /* meow_netmgrd 网络管理(MSG_NET_*,期2 观测层/DNS 代理) */
#define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */
#define NYAI_PATH_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" /* 旧卡上路径(回退) */
#define BOOT_DEFAULT_FILE "/data/meow_boot_default"
@@ -125,6 +126,7 @@ static const char *g_sock_perf = PERF_SOCK;
static const char *g_sock_power = POWER_SOCK;
static const char *g_sock_launcher = LAUNCHER_SOCK;
static const char *g_sock_audio = AUDIO_SOCK;
static const char *g_sock_net = NET_SOCK; /* meow_netmgrd(--sock-net / env MEOW_NET_SOCK 覆盖) */
/* ---------------- 增量帧缓存(GET /api/screen.jpeg?delta=1,阶段 C 增强) ----------------
* 服务端缓存上一帧紧凑 RGB565,按 8×8 块比对,只编码变化块(外接 bbox 子图)并以响应头回传偏移。
@@ -234,6 +236,8 @@ static int send_err(int sock, int status, const char *code, const char *msg);
static int send_json(int sock, int status, const char *data);
static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename);
static void panel_log(const char *fmt, ...);
static int qparam(const char *query, const char *key, char *out, int outcap); /* 定义在文件后部;网络连接表 query 取值 */
static int tf_present(void); /* 定义在文件后部;net 日志卡根判定 */
static const char *web_root(void); /* 定义在文件后部;此处前向声明供鉴权会话路径使用 */
/* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */
@@ -976,6 +980,242 @@ static int api_audio(int sock)
return rc;
}
/* ---------------- 网络管理:meow_netmgrd IPC(MSG_NET_*,期2 观测层/DNS 代理) ----------------
* 收口于独立 socket /usr/data/meow_net.sock(--sock-net / env MEOW_NET_SOCK 覆盖,宿主测试用)。
* 读级(STATUS/CONNS/DNS status|stats)netmgrd 放行任意调用者;flush 写级——HTTP 层沿用面板
* 鉴权门禁(require_auth),IPC 层身份由 netmgrd 依 SO_PEERCRED 判定(面板路径非系统身份时
* netmgrd 会拒 flush,面板如实回传其错误码/文本,不掩盖)。
* netmgrd 不在/超时/应答异常 → 503 降级 JSON({"ok":false,...}),面板其它页不受影响。 */
static int net_ipc(uint16_t req_type, const void *req, uint32_t req_len,
uint16_t *ret_type, uint32_t *code, uint16_t *meta,
char *out, size_t cap)
{
int fd = ipc_connect_tmo(g_sock_net, IPC_TIMEOUT_MS);
if (fd < 0) return -1;
uint8_t buf[MEOW_IPC_MAX];
uint16_t type = 0; uint32_t rid = 0;
int rlen = -1;
if (send_frame(fd, req_type, 0x1020, req, req_len) == 0)
rlen = recv_frame(fd, &type, &rid, buf, sizeof buf);
close(fd);
if (rlen <= 0) return -1;
if (ret_type) *ret_type = type;
/* m_net_*_result 统一布局:code(u32)|len(u16)|meta/pad(u16)|content[len],content 偏移 8 */
uint32_t c = 0; uint16_t ln = 0, mv = 0;
memcpy(&c, buf, 4); memcpy(&ln, buf + 4, 2); memcpy(&mv, buf + 6, 2);
if (code) *code = c;
if (meta) *meta = mv;
if ((size_t)ln >= cap) ln = (uint16_t)(cap - 1);
if ((size_t)(8 + ln) < sizeof buf) buf[8 + ln] = 0;
memcpy(out, buf + 8, ln);
out[ln] = 0;
return (int)ln;
}
/* GET /api/net/status → 透传 0xE0 概览 JSON(接口表 + WiFi/BT 摘要 + 接口速率)。 */
static int api_net_status(int sock)
{
uint16_t t; uint32_t code; char json[MEOW_IPC_MAX];
int n = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &t, &code, NULL, json, sizeof json);
if (n <= 0 || t != MSG_NET_STATUS_RESULT || code != MEOW_OK || !json[0])
return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用");
return send_json(sock, 200, json);
}
/* 连接表一行:entity|lip:lport|rip:rport|state|age_s|domain(netmgrd build_conns_text)。
* 把每条构造成 `{...}` 对象、逗号分隔(不含外层 [])写入 sbuf,返回条数。 */
static int net_conns_parse_body(sbuf_t *s, const char *txt)
{
const char *p = txt;
int first = 1, n = 0;
while (p && *p) {
const char *nl = strchr(p, '\n');
size_t ll = nl ? (size_t)(nl - p) : strlen(p);
if (ll == 0) { p = nl ? nl + 1 : p + 1; continue; }
char line[384];
if (ll >= sizeof line) ll = sizeof line - 1;
memcpy(line, p, ll); line[ll] = 0;
char *f[6]; int fn = 0; char *cur = line;
while (fn < 6) {
f[fn++] = cur;
char *bar = strchr(cur, '|');
if (!bar) break;
*bar = 0; cur = bar + 1;
}
if (fn >= 6) {
if (!first) sA(s, ",");
first = 0;
sA(s, "{\"entity\":"); sA_str(s, f[0]);
sA(s, ",\"local\":"); sA_str(s, f[1]);
sA(s, ",\"remote\":"); sA_str(s, f[2]);
sA(s, ",\"state\":"); sA_str(s, f[3]);
sA(s, ",\"age\":%lld,\"domain\":", atoll(f[4]));
sA_str(s, strcmp(f[5], "-") == 0 ? "" : f[5]);
sA(s, "}");
n++;
}
p = nl ? nl + 1 : p + ll;
}
return n;
}
/* GET /api/net/conns?entity=<可选> → 0xE8 → 连接表 JSON(可实体前缀过滤)。 */
static int api_net_conns(int sock, const char *query)
{
m_net_conns_req_t q; memset(&q, 0, sizeof q);
char ent[128];
if (qparam(query, "entity", ent, sizeof ent))
strncpy(q.entity, ent, sizeof q.entity - 1);
uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX];
int n = net_ipc(MSG_NET_CONNS_REQ, &q, sizeof q, &t, &code, &meta, txt, sizeof txt);
if (n <= 0 || t != MSG_NET_CONNS_RESULT || code != MEOW_OK)
return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用");
sbuf_t a = {0};
int count = net_conns_parse_body(&a, txt);
sbuf_t s = {0};
sA(&s, "{\"truncated\":%s,\"count\":%d,\"conns\":[", meta ? "true" : "false", count);
if (a.n) sA_append(&s, a.b, a.n);
sA(&s, "]}");
sbuf_free(&a);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* GET /api/net/dns → 0xEC status(netmgrd 对 status/stats 同输出)→ DNS 代理统计 JSON。 */
static int api_net_dns(int sock)
{
m_net_dns_ctl_t q; memset(&q, 0, sizeof q);
strcpy(q.cmd, "status");
uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX];
int n = net_ipc(MSG_NET_DNS_CTL, &q, sizeof q, &t, &code, &meta, txt, sizeof txt);
if (n <= 0 || t != MSG_NET_DNS_RESULT)
return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用");
if (code != MEOW_OK)
return send_err(sock, 503, "NET_NODEV", txt[0] ? txt : "DNS 代理不可用");
/* txt: `dns mode=%s enabled=%d upstream=%s cache=%d/%d q=%llu deny=%llu first=%llu resolv_modified=%d` */
char mode[32] = "", upstream[64] = "";
int enabled = 0, ch = 0, ct = 0, rmod = 0;
unsigned long long qq = 0, deny = 0, first = 0;
(void)sscanf(txt, "dns mode=%31s enabled=%d upstream=%63s cache=%d/%d q=%llu deny=%llu first=%llu resolv_modified=%d",
mode, &enabled, upstream, &ch, &ct, &qq, &deny, &first, &rmod);
sbuf_t s = {0};
sA(&s, "{\"mode\":");
sA_str(&s, mode);
sA(&s, ",\"enabled\":%s,\"upstream\":", enabled ? "true" : "false");
sA_str(&s, upstream);
sA(&s, ",\"cache_hits\":%d,\"cache_total\":%d,\"queries\":%llu,\"denies\":%llu,\"first_seen\":%llu,\"resolv_modified\":%s}",
ch, ct, qq, deny, first, rmod ? "true" : "false");
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* POST /api/net/dns/flush → 0xEC flush(写级:HTTP 层走 require_auth;IPC 层由 netmgrd 依身份判定)。 */
static int api_net_dns_flush(int sock)
{
m_net_dns_ctl_t q; memset(&q, 0, sizeof q);
strcpy(q.cmd, "flush");
uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX];
int n = net_ipc(MSG_NET_DNS_CTL, &q, sizeof q, &t, &code, &meta, txt, sizeof txt);
if (n <= 0 || t != MSG_NET_DNS_RESULT)
return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用");
if (code == (uint32_t)MEOW_ERR_PERM)
return send_err(sock, 403, "FORBIDDEN", txt[0] ? txt : "DNS flush 需系统身份");
if (code != MEOW_OK)
return send_err(sock, 503, "NET_NODEV", txt[0] ? txt : "DNS flush 失败");
/* txt: `dns cache flushed (N entries)` */
int entries = 0;
(void)sscanf(txt, "dns cache flushed (%d entries)", &entries);
sbuf_t s = {0};
sA(&s, "{\"flushed\":true,\"entries\":%d}", entries);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
#define NET_LOG_TAIL_MAX (256 * 1024) /* net 日志 tail 读取上限(内存约束:不整读 64MB 滚动文件) */
/* GET /api/net/log → 读 TF MeowOS/logs/ 最新的 net_<bootts>.log 尾部(同日志页模式)。 */
static int api_net_log(int sock)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
DIR *d = opendir(LOGS_DIR);
if (!d) return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}");
char best[512] = ""; long best_mt = -1;
struct dirent *e;
while ((e = readdir(d))) {
if (strncmp(e->d_name, "net_", 4) != 0) continue;
if (e->d_name[0] == '.') continue;
char full[1024];
snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name);
struct stat st;
if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue;
if (st.st_mtime > best_mt) { best_mt = st.st_mtime; snprintf(best, sizeof best, "%s", e->d_name); }
}
closedir(d);
if (!best[0])
return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}");
char full[1024];
snprintf(full, sizeof full, "%s/%s", LOGS_DIR, best);
int fd = open(full, O_RDONLY);
struct stat st;
if (fd < 0 || fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) {
if (fd >= 0) close(fd);
return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}");
}
off_t off = st.st_size > NET_LOG_TAIL_MAX ? st.st_size - NET_LOG_TAIL_MAX : 0;
size_t buflen = (size_t)(st.st_size - off);
if (buflen == 0) { close(fd); return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); }
if (off) (void)lseek(fd, off, SEEK_SET);
char *buf = malloc(buflen + 1);
if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); }
ssize_t n = read(fd, buf, buflen);
close(fd);
if (n <= 0) { free(buf); return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); }
sbuf_t s = {0};
sA(&s, "{\"file\":");
sA_str(&s, best);
sA(&s, ",\"lines\":[");
const char *p = buf, *end = buf + n;
int linecnt = 0;
for (const char *c = buf; c < end; c++) if (*c == '\n') linecnt++;
if (linecnt > 0 && end[-1] != '\n') linecnt++;
int maxlines = LOG_MAX_LINES;
int startrow = linecnt - maxlines;
if (startrow < 0) startrow = 0;
int truncated = (off > 0 || startrow > 0) ? 1 : 0;
int rowidx = 0, gotlines = 0;
p = buf;
while (p < end) {
const char *nl = memchr(p, '\n', (size_t)(end - p));
const char *line_end = nl ? nl : end;
if (rowidx >= startrow) {
if (gotlines) sA(&s, ",");
sbuf_t ln = {0};
for (const char *c = p; c < line_end; c++) {
unsigned char cc = (unsigned char)*c;
if (cc == '"' || cc == '\\') sA(&ln, "\\%c", (int)cc);
else if (cc < 0x20 && cc != '\t') sA(&ln, "\\u%04x", cc);
else sA(&ln, "%c", (int)cc);
}
sA(&s, "\"");
for (const char *c = ln.b ? ln.b : ""; *c; c++) sA(&s, "%c", (int)(unsigned char)*c);
sA(&s, "\"");
sbuf_free(&ln);
gotlines++;
}
rowidx++;
if (!nl) break;
p = nl + 1;
}
free(buf);
sA(&s, "],\"total\":%d,\"truncated\":%s}", linecnt, truncated ? "true" : "false");
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* ---------------- 路径安全 ---------------- */
static int tf_present(void)
{
@@ -4456,6 +4696,12 @@ static int handle(int sock, req_t *r)
/* ---- 音频状态(meow_audiod 快照,只读) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/audio")) return api_audio(sock);
/* ---- 网络管理(meow_netmgrd 期2 透传:读级任意调用者;flush 写级走 require_auth) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/status")) return api_net_status(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/conns")) return api_net_conns(sock, r->query);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/dns")) return api_net_dns(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/log")) return api_net_log(sock);
/* ---- 实时画面(阶段 C) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg"))
return api_screen_jpeg(sock, r);
@@ -4473,6 +4719,7 @@ static int handle(int sock, req_t *r)
if (!strcmp(p, "/api/files/rename")) return api_files_rename(sock, r);
if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r);
if (!strcmp(p, "/api/notifications/dismiss_all") || !strcmp(p, "/api/notifications/clear")) return api_notification_dismiss_all(sock);
if (!strcmp(p, "/api/net/dns/flush")) return api_net_dns_flush(sock);
if (!strcmp(p, "/api/key")) return api_key_batch(sock, r);
if (!strcmp(p, "/api/input/tap")) return api_input_tap(sock, r);
char seg[256];
@@ -4585,10 +4832,11 @@ int main(int argc, char **argv)
else if (!strcmp(argv[i], "--sock-power") && i + 1 < argc) g_sock_power = argv[++i];
else if (!strcmp(argv[i], "--sock-launcher") && i + 1 < argc) g_sock_launcher = argv[++i];
else if (!strcmp(argv[i], "--sock-audio") && i + 1 < argc) g_sock_audio = argv[++i];
else if (!strcmp(argv[i], "--sock-net") && i + 1 < argc) g_sock_net = argv[++i];
else if (!strcmp(argv[i], "--sock") && i + 1 < argc) { g_sock_perf = argv[++i]; g_sock_power = g_sock_perf; }
else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) {
printf("MeowPanel backend %s\n", PANEL_VERSION);
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P]\n");
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P] [--sock-net P]\n");
return 0;
}
}
@@ -4601,6 +4849,8 @@ int main(int argc, char **argv)
if (env && env[0]) g_sock_launcher = env;
env = getenv("MEOW_AUDIO_SOCK");
if (env && env[0]) g_sock_audio = env; /* 与 audiod CLI 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_NET_SOCK");
if (env && env[0]) g_sock_net = env; /* 与 netmgrd/meow_net 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_SETTINGS_FILE");
if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */
Binary file not shown.
+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();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用