feat(panel, PANEL_ENHANCE_PLAN 批2): 崩溃/日志中心 + 音频写 + 后端补缺口

后端(meowpanel.c 0-warning,-Werror 通过):
- /api/system 补 reboot_reason/upgrade_status(svc_power 读级 POWER_REBOOT_REASON(8)/UPGRADE_STATUS(9))
- /api/overview net 块并入 DNS 统计(首页严格单请求;DNS 不可用省略 dns 键不谎报)
- /api/audio 补 vol_max(读 settings audio.volume_max)
- POST /api/audio/route(仅 0=auto/2=3.5/3=4.4,拒 1/4/5;写级 PIN 已护)
- POST /api/audio/volume(clamp 到 vol_max,防炸耳红线;写级 PIN 已护)
- GET /api/logs/crash(崩溃汇总:每实体崩溃数+最近时间/信号 + dmesg 计数;256KB/64 实体/5 行上限)
- /api/logs 文件列表加 group 来源分组
- 顺带补回 批1-B 前端读取的 boot.mode /tmp/meow_boot_mode(此前遗留未提交)

前端(web/meowpanel.js 双主题):
- 音频页:路由选择器(自动/3.5/4.4)+ 音量滑条(上限 vol_max、平方锥度非线性标注、确认弹窗)
- 日志页:崩溃汇总卡 + 崩溃尾巴一键看 stdout 尾部 + 按来源分组 + 前缀过滤
- 首页:netCard 改读 overview net.dns,去掉单独 /api/net/dns

验证:宿主编译 0 warning + -Werror;自建桩测试 24/24(crash 解析/路由 1|4|5 拒 422/音量 clamp 到 50/
reboot_reason+upgrade_status 值/overview net.dns);headless Chromium 渲染 audio/logs/home/system/
light 主题均正确。md5(meowpanel)=aed2ea17a34614acaa26dcbc8965ebb1。
This commit is contained in:
MeowOS committed 2026-09-01 23:15:52 +08:00
1 parent 34f2834704
commit 33d3059038
5 files changed
+547 -59

No files matched your search

+34 -4
View File
@@ -28,7 +28,7 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
- 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
体积约 **236 KB**(固件阈值内)。md5:`3696e85b5eaf111f530c5c39fa68886e`(批1-A:访问级 **4 位 PIN 鉴权** + `/api/{power,overview,ams,svcd}` + `/api/audio` 补 `rms/peak/lvlseq`。
体积约 **256 KB**(固件阈值内)。md5:`aed2ea17a34614acaa26dcbc8965ebb1`(批2:崩溃/日志中心 `/api/logs/crash` + 日志分组/`group`;`/api/system` 补 `reboot_reason/upgrade_status`;`/api/overview` net 块并入 DNS 统计;音频**写级** `/api/audio/route`(仅 0/2/3,拒 1/4/5)+ `/api/audio/volume`(clamp 到 vol_max)+ `/api/audio` 补 `vol_max`)。
**重防火墙注意**:运行中二进制先 `.new` 再 `mv`,切勿直接覆盖运行中的 `meowpanel`)。
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **148 KB**。
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
@@ -115,7 +115,9 @@ http://<host>.local:8080/
| GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts/<family>/<weight>/` 三件套齐) | ✅(无卡→503) |
| POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) |
| GET | `/api/fonts/export` | 导出 `<family>/<weight>/` 三件套为 `.tar.gz`(`?family=&weight=`) | ✅(缺件→404) |
| GET | `/api/audio` | 音频状态快照:meow_audiod IPC `MSG_AUDIO_QUERY→STATE` 透传(当前流/路由/音量/underrun + **`rms/peak/lvlseq` 电平**,见 §5.12) | ✅(audiod 不在→`available:false`,不报错) |
| GET | `/api/audio` | 音频状态快照:meow_audiod IPC `MSG_AUDIO_QUERY→STATE` 透传(当前流/路由/音量/underrun + **`rms/peak/lvlseq` 电平 + `vol_max`**,见 §5.12) | ✅(audiod 不在→`available:false`,不报错) |
| POST | `/api/audio/route` | 手动切输出路由(**写级**,PIN 已护):`MSG_AUDIO_ROUTE`,仅接受 `{port: 0\|2\|3}`,**绝不接受 1/4/5**(4=SPDIF、5=audiod 内部强制重烧值);前端切换前确认弹窗(选错口会无声) | ✅(非法端口→422;audiod 不在→503) |
| POST | `/api/audio/volume` | 写音量(**写级**,PIN 已护):`MSG_AUDIO_VOLUME`,**clamp 到系统 vol_max**(读 settings.conf `audio.volume_max`,缺省 100;设备当前 50);audiod 仅 clamp 0-100 无 vol_max 防护,面板是把守方(防炸耳红线) | ✅(clamp 后写;audiod 不在→503) |
| GET | `/api/power` | 功耗管理:meow_powermgr IPC `MSG_PM_QUERY→RESULT`(battery/estimate/breakdown,confidence 原样透传) | ✅(powermgr 不在→503 `POWER_UNAVAILABLE`) |
| GET | `/api/ams` | App 运行表:`am list`(pkg/name/state/pid/launches/crashes/last_kind/last_code) | ✅(amsd 不在→`available:false`) |
| GET | `/api/svcd` | 服务表:`meow_svcd status all`(name/state/pid/restart/backoff_ms/trigger/src/exit/exec) | ✅(svcd 不在→`available:false`) |
@@ -127,7 +129,8 @@ http://<host>.local:8080/
| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ |
| POST | `/api/key/:code` | 按键注入(tap/long) | ✅(物理键红线内) |
| POST | `/api/key` | 批量/顺序注入 | ✅ |
| GET | `/api/logs` | `MeowOS/logs` 文件列表 | ✅(无卡→503) |
| GET | `/api/logs` | `MeowOS/logs` 文件列表(每文件含 `group` 来源分组) | ✅(无卡→503) |
| GET | `/api/logs/crash` | **崩溃汇总**:解析最新 `crash_<bootts>.log`(meow_crash 落盘格式)→ 每实体崩溃数 + 最近崩溃时间/信号 + dmesg 计数 | ✅(无卡→503;无 crash 日志→空汇总) |
| GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ |
| GET | `/api/notifications` | 通知列表(**优先 launcher IPC 内存活跃集** `source:"ipc"`;IPC 不可达回退 TF 转储日志 `source:"tf"`;见 §5.8) | ✅(无源→空态 `source:none`,不 503) |
| DELETE | `/api/notifications/:id` | 单条删除(**优先 IPC 撤下 launcher 内存**;IPC 不可达回退重写日志移除该 id) | ✅(不存在→404) |
@@ -366,6 +369,33 @@ http://<host>.local:8080/
- **`GET /api/ams`** / **`GET /api/svcd`**:即 overview 的 `ams`/`svcd` 子块;源不在 → `{"available":false}`。
- **`GET /api/audio`**:overview 的 `audio` 子块 + rms/peak/lvlseq 消费说明见 §5.12。
### 5.15 批2:崩溃/日志中心 + 音频写 + 后端补缺口(PANEL_ENHANCE_PLAN 批2)
- **崩溃/日志中心(升级 `#/logs`)**:
- **`GET /api/logs/crash`**:读最新 `crash_<bootts>.log`(meow_crash 落盘格式:`<epoch> <uptime> <svc|dmesg> <detail>`;
svc 的 `detail=<name> exit=<code>`/`<name> signal=<sig>`)。聚合出 `{file,count,dmesg_count,dmesg_tail,entities:[
{name,source:"svc",count,last_epoch,last_kind,last_code,last_time,last_detail}]}`;只读、无副作用。
读取有界 `CRASH_TAIL_MAX`=256KB、实体 `CRASH_ENT_MAX`=64、dmesg 保留最近 `CRASH_DMESG_TAIL`=5 行(行数上限保护)。
- **`/api/logs` 文件列表**每项带 `group`(`meow_*→system`/`crash_*→crash`/`ams_*→ams`/`audio_monitor_*→audio_monitor`/
`net_*→net`/`power_*→power`/其余→`app`),供前端按来源分组 + 前缀过滤;`group` 为追加键,向后兼容。
- **崩溃尾巴前置**:崩溃汇总实体行「stdout 尾部」按钮复用批1-B 读取路径(`/api/files?path=/MeowOS/logs/<name>/`
找最新 `stdout_*.log` → `/api/logs/read?file=<name>/<file>&lines=200` 读尾部)。
- **音频写(写级,PIN 已护;前端切换/调高前确认弹窗)**:
- **`POST /api/audio/route`**:`body {port}`。**仅 0=auto/2=3.5/3=4.4**,其余(含 1=lineout/4=SPDIF、5=audiod 内部
强制重烧 FPGA 值)→ 422。audiod 对非法 port 也回 MEOW_OK(内部仅记录不应用),故面板在**发出前**自行校验。
应答 `{accepted,port}`;audiod 不在 → 503 `AUDIO_UNAVAILABLE`。
- **`POST /api/audio/volume`**:`body {value}`。**clamp 到系统 vol_max**(`settings.audio.volume_max`,缺省
`PANEL_VOL_MAX_DEFAULT`=100;设备当前 50)。audiod 内部仅 clamp 0-100、无 vol_max 防护——面板是唯一把守方
(防炸耳红线:平方锥度 `dac=(100-pct)²×192/10000`,接近 100 极响)。应答 `{accepted,value(clamped),applied,vol_max}`。
- **`/api/audio` 补 `vol_max`**(读 settings `audio.volume_max`),前端滑条上限同步 + 「平方锥度非线性」标注 +
「当前值只读显示锥度后百分比」。
- **后端补缺口**:
- **`/api/system` 补 `reboot_reason`/`upgrade_status`**:svc_power 读级查询 `POWER_REBOOT_REASON(8)`/
`POWER_UPGRADE_STATUS(9)`(`st.msg` 分别为 `coldboot=reboot|shutdown|unknown`、`upgrade=yes|no`);查询失败回空串不谎报。
前端批1-B 已留位(读 `d.reboot_reason`/`d.upgrade_status`),此处点亮。
- **`/api/overview` net 块并入 DNS 统计**:netmgrd 状态 JSON 剥外层括号拼入 `{"net":{…,"dns":{…}}}`(复用 `/api/net/dns`
同款统计);DNS 不可用则省略 `dns` 键仅保留网络状态,**绝不假数据**。首页 netCard 改读 `net.dns`,去掉单独 `/api/net/dns`
(首页严格单请求)。
---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
@@ -456,7 +486,7 @@ http://<host>.local:8080/
```
patch_v2/meowpanel/
├── meowpanel.c # 后端源码(约 4650 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照)
├── meowpanel.c # 后端源码(约 5590 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照 + 批2 崩溃/日志中心 + 音频写 + overview DNS 聚合)
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning)
├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c)
Binary file not shown.
+325 -22
View File
@@ -115,6 +115,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notificat
#define LOG_MAX_LINES 1000
#define NOTIF_PREVIEW_MAX 200 /* 通知列表返回上限(每条约 0.2KB,总 <50KB) */
#define TEXT_PREVIEW_MAX (1024 * 1024) /* 文本预览上限(超出截断,truncated=true;14MB 约束下 1MB 缓冲安全) */
#define PANEL_VOL_MAX_DEFAULT 100 /* 音频音量上限默认(对齐 meow_launcher MEOW_VOL_MAX_DEFAULT;设备可经 settings.conf audio.volume_max 覆盖) */
/* 白名单子目录(TF 卡根下) */
static const char *g_allowed_dirs[] = { "MeowOS", "Music", "MeowData", "MeowAPPs", "book" };
@@ -926,6 +927,17 @@ done:
return rc;
}
/* 从 svc_power 状态文本取 `k=v` 的 v(如 "coldboot=reboot" → "reboot";key 须精确匹配带 '=')。
* 读级命令(POWER_REBOOT_REASON/UPGRADE_STATUS)的 st.msg 即此形态。 */
static void svc_msg_kv(const char *msg, const char *key, char *out, size_t cap)
{
out[0] = 0;
if (!msg || !key) return;
size_t kl = strlen(key);
if (strncmp(msg, key, kl) == 0 && msg[kl] == '=' && msg[kl + 1])
snprintf(out, cap, "%s", msg + kl + 1);
}
/* ---------------- 音频状态:meow_audiod IPC(MSG_AUDIO_QUERY→MSG_AUDIO_STATE) ----------------
* audiod 无 MSG_HELLO 握手(见 patch_v2/meow_audiod/meow_audio.c CLI:直连发空负载 QUERY,
* 收 STATE 文本帧——"k=v" 空格分隔、NUL 结尾)。audiod 不在/超时/应答异常一律回
@@ -957,6 +969,16 @@ static long long audio_kv_ll(const char *txt, const char *key, long long defv)
return strtoll(b, NULL, 10);
}
/* 音频音量上限:读 settings.conf 的 audio.volume_max(权威键,与 launcher/watchdog/svc_power/sdk 同用);
* 缺省回 PANEL_VOL_MAX_DEFAULT(=MEOW_VOL_MAX_DEFAULT 100)。面板写音量时据此 clamp(防炸耳红线)。 */
static int audio_vol_max(void)
{
int v = settings_get_int("audio.volume_max", PANEL_VOL_MAX_DEFAULT);
if (v < 0) v = 0;
if (v > 100) v = 100;
return v;
}
/* 音频快照填充:把 `{"available":...}` 追加进 sbuf(供 /api/audio 单发与 /api/overview 聚合共用)。
* audiod 不在/超时/应答异常一律 `{"available":false}`(HTTP 200,不报错)——面板/首页据此降级,绝不假数据。 */
static void audio_fill(sbuf_t *s)
@@ -988,8 +1010,8 @@ static void audio_fill(sbuf_t *s)
sA(s, ",\"port\":%lld,\"vol\":%lld",
audio_kv_ll(txt, "port", -1), audio_kv_ll(txt, "vol", -1));
/* 电平监控(261):rms/peak deci-dB(-990=-inf),lvlseq=快照代际;透传时别漏(ABI 追加键)。 */
sA(s, ",\"rms\":%lld,\"peak\":%lld,\"lvlseq\":%lld}",
audio_kv_ll(txt, "rms", -990), audio_kv_ll(txt, "peak", -990), audio_kv_ll(txt, "lvlseq", 0));
sA(s, ",\"rms\":%lld,\"peak\":%lld,\"lvlseq\":%lld,\"vol_max\":%d}",
audio_kv_ll(txt, "rms", -990), audio_kv_ll(txt, "peak", -990), audio_kv_ll(txt, "lvlseq", 0), audio_vol_max());
}
static int api_audio(int sock)
@@ -1001,6 +1023,74 @@ static int api_audio(int sock)
return rc;
}
/* ---- 音频写命令(写级,PIN 已护):route/volume 直连 audiod(无 MSG_HELLO 握手),发帧→收 MSG_ACK。
* audiod 对非法 port 也回 MEOW_OK(内部仅记录不应用,见 meow_audiod.c MSG_AUDIO_ROUTE),
* 故面板在**发出前**自行校验,绝不放 1(lineout)/4(SPDIF)/5(audiod 内部强制重烧值)。 */
static int audio_write_cmd(uint16_t type, const void *pl, uint32_t len, m_ack_t *out)
{
int fd = ipc_connect_tmo(g_sock_audio, IPC_TIMEOUT_MS);
if (fd < 0) return -1;
uint8_t buf[64];
uint16_t rtype = 0; uint32_t rid = 0;
int n = -1;
if (send_frame(fd, type, 0x22, pl, len) == 0)
n = recv_frame(fd, &rtype, &rid, buf, sizeof buf);
close(fd);
if (n < (int)sizeof(m_ack_t) || rtype != MSG_ACK) return -1;
if (out) memcpy(out, buf, sizeof *out);
return 0;
}
/* POST /api/audio/route → 手动切输出路由。仅接受 0=auto/2=3.5/3=4.4;
* **绝不接受 1/4/5**(4=SPDIF、5=audiod 内部强制重烧 FPGA 值)。写级:前端切换前确认弹窗(选错口→无声)。 */
static int api_audio_route(int sock, const char *body, size_t body_len)
{
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *jp = jv_obj_get(root, "port");
if (!jp || jp->type != J_NUM) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "缺 port 数字"); }
long port = (long)jp->inum;
jv_free(root);
if (port != 0 && port != 2 && port != 3)
return send_err(sock, 422, "UNSUPPORTED", "非法路由口(仅允许 0=auto/2=3.5/3=4.4;4=SPDIF、5=audiod 内部值禁止)");
m_audio_route_t a; a.port = (uint32_t)port;
m_ack_t st;
if (audio_write_cmd(MSG_AUDIO_ROUTE, &a, sizeof a, &st) < 0)
return send_err(sock, 503, "AUDIO_UNAVAILABLE", "音频服务不可用(audiod 未运行/IPC 不可达)");
sbuf_t s = {0};
sA(&s, "{\"accepted\":true,\"port\":%ld}", port);
int out = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return out;
}
/* POST /api/audio/volume → 写音量。**必须 clamp 到系统 vol_max**(读 settings.conf `audio.volume_max`,
* 缺省 100;设备当前按 plan 为 50)。audiod 内部仅 clamp 0-100、无 vol_max 防护——面板此处是唯一把守方
* (防炸耳红线:平方锥度下靠近 100 极响,M2.96 曾有 dac 写 0=满幅事故)。 */
static int api_audio_volume(int sock, const char *body, size_t body_len)
{
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *jv = jv_obj_get(root, "value");
if (!jv || jv->type != J_NUM) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "缺 value 数字"); }
long v = (long)jv->inum;
jv_free(root);
int vmax = audio_vol_max();
if (v < 0) v = 0;
if (v > vmax) v = vmax; /* 关键 clamp 红线 */
m_audio_volume_t a; a.pct = (int32_t)v;
m_ack_t st;
if (audio_write_cmd(MSG_AUDIO_VOLUME, &a, sizeof a, &st) < 0)
return send_err(sock, 503, "AUDIO_UNAVAILABLE", "音频服务不可用(audiod 未运行/IPC 不可达)");
sbuf_t s = {0};
sA(&s, "{\"accepted\":true,\"value\":%ld,\"applied\":%u,\"vol_max\":%d}", v, st.status, vmax);
int out = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return out;
}
/* ---------------- 功耗管理:meow_powermgr IPC(MSG_PM_QUERY→MSG_PM_RESULT) ----------------
* 客户端序列同 perf/power:connect → MSG_HELLO → 读 ACK → MSG_PM_QUERY(subset) → MSG_PM_RESULT(JSON)。
* powermgr 不在/超时/应答异常 → 负错误码(调用方转 503 POWER_UNAVAILABLE;overview 里转 available:false)。 */
@@ -1162,33 +1252,64 @@ static int api_net_conns(int sock, const char *query)
return rc;
}
/* GET /api/net/dns → 0xEC status(netmgrd 对 status/stats 同输出)→ DNS 代理统计 JSON。 */
static int api_net_dns(int sock)
/* DNS 代理统计对象 JSON 到 s(即 `{...}` 本体,不含外层 "dns" 键)。
* 返回 0=成功;-1=传输不可达(netmgrd 不在);-2=code!=OK(DNS 代理不可用)。
* 供 /api/net/dns 单发与 /api/overview 的 net 块复用(首页严格单请求)。 */
static int net_dns_obj_fill(sbuf_t *s)
{
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 代理不可用");
if (n <= 0 || t != MSG_NET_DNS_RESULT) return -1;
if (code != MEOW_OK) return -2;
/* 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}",
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 : "");
return 0;
}
/* GET /api/net/dns → 0xEC status(netmgrd 对 status/stats 同输出)→ DNS 代理统计 JSON。 */
static int api_net_dns(int sock)
{
sbuf_t s = {0};
int rc = net_dns_obj_fill(&s);
if (rc == -1) { sbuf_free(&s); return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); }
if (rc == -2) { sbuf_free(&s); return send_err(sock, 503, "NET_NODEV", "DNS 代理不可用"); }
int out = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
return out;
}
/* overview 用的 net 块:netmgrd 状态 JSON + 本面板 DNS 统计(并入 "dns" 子键),首页可严格单请求。
* 返回 0=成功(已追加 `,"net":{...}`);-1=netmgrd 状态不可用(调用方回 available:false,绝不假数据)。 */
static int net_block_fill(sbuf_t *s)
{
uint16_t nt; uint32_t ncode; char njson[MEOW_IPC_MAX];
int nn = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &nt, &ncode, NULL, njson, sizeof njson);
if (nn <= 0 || nt != MSG_NET_STATUS_RESULT || ncode != MEOW_OK || !njson[0]) return -1;
/* 状态 JSON 为对象:剥外层大括号,拼入 DNS 子键(DNS 不可用则省略该键,仍回 net 状态,不谎报)。 */
size_t l = strlen(njson);
while (l > 0 && (njson[l - 1] == ' ' || njson[l - 1] == '\t' || njson[l - 1] == '\n' || njson[l - 1] == '\r')) njson[--l] = 0;
if (l < 2 || njson[0] != '{' || njson[l - 1] != '}') return -1;
njson[l - 1] = 0; /* 去尾部 } */
sbuf_t dns = {0};
int drc = net_dns_obj_fill(&dns);
sA(s, ",\"net\":{");
sA(s, "%s", njson + 1); /* 去首 { 的状态内层 */
if (drc == 0 && dns.b) sA(s, ",\"dns\":%s", dns.b);
sA(s, "}}");
sbuf_free(&dns);
return 0;
}
/* POST /api/net/dns/flush → 0xEC flush(写级:HTTP 层走 require_auth;IPC 层由 netmgrd 依身份判定)。 */
@@ -1473,10 +1594,8 @@ static int api_overview(int sock)
else sA(&s, "\"power\":{\"available\":false},");
sA(&s, "\"audio\":");
audio_fill(&s);
uint16_t nt; uint32_t ncode; char njson[MEOW_IPC_MAX];
int nn = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &nt, &ncode, NULL, njson, sizeof njson);
if (nn > 0 && nt == MSG_NET_STATUS_RESULT && ncode == MEOW_OK && njson[0])
sA(&s, ",\"net\":%s", njson);
if (net_block_fill(&s) == 0)
; /* net 块已追加(状态 + 合并的 dns 子键) */
else
sA(&s, ",\"net\":{\"available\":false}");
sA(&s, ",\"ams\":");
@@ -3287,6 +3406,19 @@ static int api_system(int sock)
if (f) { if (fgets(b, sizeof b, f)) bdf = atoi(b); fclose(f); }
}
const char *bootname = bdf < 0 ? "meow" : (bdf ? "hiby" : "meow");
/* 当前运行模式:读 /tmp/meow_boot_mode(meow/hiby),缺失回退 default(PANEL_ENHANCE_PLAN 批1 前端读 boot.mode) */
char modebuf[16] = {0};
{
FILE *f = fopen("/tmp/meow_boot_mode", "r");
if (f) {
if (fgets(modebuf, sizeof modebuf, f)) {
char *p = modebuf;
while (*p) { if (*p == '\n' || *p == '\r' || *p == ' ') { *p = 0; break; } p++; }
}
fclose(f);
}
}
const char *bootmode = (modebuf[0] == 'm' || modebuf[0] == 'h') ? modebuf : bootname;
char host_local[80];
snprintf(host_local, sizeof host_local, "%s.local", host);
sbuf_t s = {0};
@@ -3300,6 +3432,8 @@ static int api_system(int sock)
sA_str(&s, arch);
sA(&s, ",\"uptime_sec\":%lld,\"boot\":{\"default\":", upt);
sA_str(&s, bootname);
sA(&s, ",\"mode\":");
sA_str(&s, bootmode);
sA(&s, "},\"network\":");
char netbuf[1024];
if (perf_extract_network(json, netbuf, sizeof netbuf) == 0) {
@@ -3334,7 +3468,25 @@ static int api_system(int sock)
sA_str(&s, ver);
sA(&s, ",\"latest\":");
sA_str(&s, ver);
sA(&s, ",\"available\":false,\"url\":null}}");
sA(&s, ",\"available\":false,\"url\":null}");
/* 重启原因 / 升级状态:svc_power 读级查询(POWER_REBOOT_REASON(8)/POWER_UPGRADE_STATUS(9),只读、
* 任意调用者)。st.msg 分别为 "coldboot=reboot|shutdown|unknown" 与 "upgrade=yes|no"。
* 前端批1-B 已留位(读 d.reboot_reason / d.upgrade_status),此处点亮。查询失败保持空串(不谎报)。 */
char rreason[48] = "", ustatus[32] = "";
{
m_power_status_t st;
memset(&st, 0, sizeof st);
if (ipc_power_action(POWER_REBOOT_REASON, 0, NULL, &st) == MEOW_OK)
svc_msg_kv(st.msg, "coldboot", rreason, sizeof rreason);
memset(&st, 0, sizeof st);
if (ipc_power_action(POWER_UPGRADE_STATUS, 0, NULL, &st) == MEOW_OK)
svc_msg_kv(st.msg, "upgrade", ustatus, sizeof ustatus);
}
sA(&s, ",\"reboot_reason\":");
sA_str(&s, rreason);
sA(&s, ",\"upgrade_status\":");
sA_str(&s, ustatus);
sA(&s, "}");
jv_free(root);
int ret = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
@@ -3437,6 +3589,21 @@ static int api_key_batch(int sock, const req_t *r)
}
/* ---------------- 日志 ---------------- */
/* 日志来源分组(批2 崩溃/日志中心):按文件名前缀归组,供前端分组展示 + 前缀过滤。
* meow_* → system(MeowOS 服务); crash_* → crash; ams_* → ams; audio_monitor_* → audio_monitor;
* net_* → net; power_* → power; 其余 → app(App/stdout 兜底)。 */
static const char *log_group(const char *name)
{
if (!strncmp(name, "crash_", 6)) return "crash";
if (!strncmp(name, "ams_", 4)) return "ams";
if (!strncmp(name, "audio_monitor_", 14)) return "audio_monitor";
if (!strncmp(name, "net_", 4)) return "net";
if (!strncmp(name, "power_", 6)) return "power";
if (!strncmp(name, "meow_", 5)) return "system";
return "app";
}
static int api_logs_list(int sock)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
@@ -3459,7 +3626,9 @@ static int api_logs_list(int sock)
first = 0;
sA(&s, "{\"name\":");
sA_str(&s, e->d_name);
sA(&s, ",\"size\":%lld,\"mtime\":%lld}", (long long)st.st_size, (long long)st.st_mtime);
sA(&s, ",\"size\":%lld,\"mtime\":%lld,\"group\":", (long long)st.st_size, (long long)st.st_mtime);
sA_str(&s, log_group(e->d_name));
sA(&s, "}");
}
closedir(d);
sA(&s, "]}");
@@ -3544,6 +3713,137 @@ static int api_logs_read(int sock, const req_t *r)
return rc;
}
/* ---- 崩溃汇总(批2 崩溃/日志中心):解析最新 crash_<bootts>.log(meow_crash 落盘格式)
* 一行一条:`<epoch_sec> <uptime_sec> <svc|dmesg> <detail>`;svc 的 detail=`<name> exit=<code>`/`<name> signal=<sig>`,
* dmesg 的 detail=内核行原文。聚合出「每实体崩溃数 + 最近崩溃时间/信号」。只读、无副作用。 */
#define CRASH_TAIL_MAX (256u * 1024u) /* 崩溃日志单次读取上限(内存约束,不整读大文件) */
#define CRASH_ENT_MAX 64 /* 实体汇总上限 */
#define CRASH_DMESG_TAIL 5 /* dmesg 保留最近行数 */
static int api_logs_crash(int sock)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
/* 找最新 crash_*.log(按 mtime);无 → 空汇总(不 404,不谎报)。 */
DIR *d = opendir(LOGS_DIR);
if (!d) return send_json(sock, 200, "{\"file\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}");
char best[512] = ""; long best_mt = -1;
struct dirent *e;
while ((e = readdir(d))) {
if (strncmp(e->d_name, "crash_", 6) != 0 || 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\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}");
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_err(sock, 500, "INTERNAL", "崩溃日志不可读"); }
off_t off = st.st_size > (off_t)CRASH_TAIL_MAX ? st.st_size - (off_t)CRASH_TAIL_MAX : 0;
size_t buflen = (size_t)(st.st_size - off);
if (buflen == 0) { close(fd); return send_json(sock, 200, "{\"file\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}"); }
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_err(sock, 500, "INTERNAL", "读崩溃日志失败"); }
buf[n] = 0;
/* 每实体聚合(svc 行);dmesg 单独计数 + 留最近几行。
* 有界缓冲(det ≤ 127 字符)保证 snprintf 到 name/detail 不越界且 -Wformat-truncation 零告警。 */
struct { char name[128]; int count; long long last_epoch; char kind[8]; int code; char detail[160]; } ents[CRASH_ENT_MAX];
int entn = 0, svc_count = 0, dmesg_count = 0;
char dmesg_tail[CRASH_DMESG_TAIL][128]; int dmesg_tail_n = 0;
char *ln = buf;
while (*ln && (*ln == '\n')) ln++;
while (*ln) {
char *nl = strchr(ln, '\n');
if (nl) *nl = 0;
long long epoch = 0, up = 0; char src[16] = "", det[128] = "";
int f = sscanf(ln, "%lld %lld %15s %127[^\n]", &epoch, &up, src, det);
if (f >= 3) {
if (!strcmp(src, "svc")) {
svc_count++;
/* det = `<name> exit=<code>` 或 `<name> signal=<sig>`;name 为第一 token(就地切分) */
char name[128] = "", kind[8] = ""; int code = 0, k2 = 0;
char *sp = strchr(det, ' ');
if (sp) {
*sp = 0;
snprintf(name, sizeof name, "%s", det); /* det ≤127 → name[128] 不截断 */
char *kp = sp + 1;
if (!strncmp(kp, "exit=", 5)) { snprintf(kind, sizeof kind, "exit"); code = atoi(kp + 5); k2 = 1; }
else if (!strncmp(kp, "signal=", 7)) { snprintf(kind, sizeof kind, "sig"); code = atoi(kp + 7); k2 = 1; }
else { snprintf(kind, sizeof kind, "crash"); k2 = 0; }
} else {
snprintf(name, sizeof name, "%s", det);
snprintf(kind, sizeof kind, "crash");
}
/* 聚合到 ents(按 name 匹配;超限则丢弃剩余陌生实体,仅计入总数) */
int found = -1;
for (int i = 0; i < entn; i++) if (!strcmp(ents[i].name, name)) { found = i; break; }
if (found < 0 && entn < CRASH_ENT_MAX) {
found = entn++;
snprintf(ents[found].name, sizeof ents[found].name, "%s", name);
ents[found].count = 0; ents[found].last_epoch = 0;
ents[found].kind[0] = 0; ents[found].code = 0; ents[found].detail[0] = 0;
}
if (found >= 0) {
ents[found].count++;
if (epoch >= ents[found].last_epoch) {
ents[found].last_epoch = epoch;
snprintf(ents[found].kind, sizeof ents[found].kind, "%s", k2 ? kind : "crash");
ents[found].code = code;
snprintf(ents[found].detail, sizeof ents[found].detail, "%s", det);
}
}
} else if (!strcmp(src, "dmesg")) {
dmesg_count++;
if (dmesg_tail_n < CRASH_DMESG_TAIL) snprintf(dmesg_tail[dmesg_tail_n++], sizeof dmesg_tail[0], "%s", det);
else { /* 前移一行,末尾放最新 */ for (int i = 0; i < dmesg_tail_n - 1; i++) memcpy(dmesg_tail[i], dmesg_tail[i + 1], sizeof dmesg_tail[0]); snprintf(dmesg_tail[dmesg_tail_n - 1], sizeof dmesg_tail[0], "%s", det); }
}
}
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
while (*ln == '\n') ln++;
}
free(buf);
sbuf_t s = {0};
sA(&s, "{\"file\":");
sA_str(&s, best);
sA(&s, ",\"count\":%d,\"dmesg_count\":%d,\"dmesg_tail\":[", svc_count, dmesg_count);
for (int i = 0; i < dmesg_tail_n; i++) { if (i) sA(&s, ","); sA_str(&s, dmesg_tail[i]); }
sA(&s, "],\"entities\":[");
int first = 1;
for (int i = 0; i < entn; i++) {
if (!ents[i].count) continue;
if (!first) sA(&s, ",");
first = 0;
sA(&s, "{\"name\":");
sA_str(&s, ents[i].name);
sA(&s, ",\"source\":\"svc\",\"count\":%d,\"last_epoch\":%lld,\"last_kind\":", ents[i].count, ents[i].last_epoch);
sA_str(&s, ents[i].kind[0] ? ents[i].kind : "crash");
sA(&s, ",\"last_code\":%d,\"last_detail\":", ents[i].code);
sA_str(&s, ents[i].detail);
/* 最近崩溃时间(epoch → 可读);strftime 本地时区,失败回退 epoch */
char tbuf[32] = ""; time_t t = (time_t)ents[i].last_epoch;
struct tm tmv;
if (ents[i].last_epoch > 0 && localtime_r(&t, &tmv)) strftime(tbuf, sizeof tbuf, "%Y-%m-%d %H:%M:%S", &tmv);
sA(&s, ",\"last_time\":");
sA_str(&s, tbuf);
sA(&s, "}");
}
sA(&s, "]}");
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* ---------------- 通知(数据源 = 优先 launcher 内存活跃集,回退 TF 归档) ----------------
* 数据源优先级(MEOWPANEL_PROTOCOL §3.8 / 任务):
* 1) launcher IPC 内存活跃集(实时)—— `MSG_NOTIFY_QUERY`(0x25)→`MSG_NOTIFY_QUERY_RESULT`(0x27)
@@ -5106,6 +5406,8 @@ static int handle(int sock, req_t *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/audio/route")) return api_audio_route(sock, r->body, r->body_len);
if (!strcmp(p, "/api/audio/volume")) return api_audio_volume(sock, r->body, r->body_len);
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];
@@ -5153,6 +5455,7 @@ static int handle(int sock, req_t *r)
/* ---- 日志 ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs")) return api_logs_list(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs/crash")) return api_logs_crash(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs/read")) return api_logs_read(sock, r);
/* ---- 通知 ---- */
Binary file not shown.
+188 -33
View File
@@ -503,6 +503,7 @@
* 电平:rms(青绿) + peak(黄) 双条 dBFS 表(-60..0 刻度)+ deci-dB 数值 + 近 30 采样 sparkline(客户端自累积)。
* 短流时显示「无流」;audiod 不在 → available:false。 */
let audioTimer = null;
let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互)
function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } }
function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5)
const m = { 0: '空闲', 1: 'Line Out', 2: '3.5mm', 3: '4.4mm 平衡', 4: 'Type-C SPDIF' };
@@ -510,6 +511,35 @@
return m[p] || ('端口 ' + p);
}
function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); }
/* 路由口 → 中文(写选择器用;0=auto/2=3.5/3=4.4,绝不暴露 4=SPDIF/5=audiod 内部值)。 */
function audioRouteLabel(p) {
const m = { 0: '自动', 2: '3.5mm', 3: '4.4mm' };
return m[p] || ('端口 ' + p);
}
/* 路由与音量写控件(批2):路由分段选择 + 音量滑条(上限 vol_max、平方锥度非线性标注)。
* 写级——前端切换/调高前 confirmDlg(选错口会无声)。当前值只读显示锥度后百分比(上限以内)。 */
function audioCtlHtml(d) {
const vmax = (d && d.vol_max != null) ? d.vol_max : 100;
const vol = (d && d.vol != null && d.vol >= 0) ? Math.min(d.vol, vmax) : 0;
const cur = d.port;
let s = '<div class="card"><h3 class="card-title">路由与音量</h3><table class="table"><tbody>' +
row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) +
row('输出口', audioPortName(d.port)) +
row('音量(只读)', (d.vol != null && d.vol >= 0 ? '<span class="' + (d.vol > vmax ? 'urgency-hi' : '') + '">' + esc(d.vol) + ' %</span>' : '—') +
' <span class="muted">(上限 ' + esc(vmax) + ' · 平方锥度非线性)</span>') +
'</tbody></table>';
s += '<div class="field-row" style="flex-wrap:wrap;gap:8px;margin-top:6px"><span class="muted" style="width:auto">输出路由</span>';
[0, 2, 3].forEach(p => {
s += '<button class="btn ' + (p === cur ? 'btn-primary' : 'btn-ghost') + '" data-audio-route="' + p + '">' + audioRouteLabel(p) + '</button>';
});
s += '</div>';
s += '<div class="field-row" style="margin-top:8px"><span class="muted" style="width:44px">' + vol + '</span>' +
'<input type="range" min="0" max="' + vmax + '" value="' + vol + '" data-audio-vol>' +
'<span class="muted" style="width:44px;text-align:right">' + vmax + '</span></div>';
s += '<p class="muted">切换/调节前会确认。选错输出口会<b>无声</b>;音量走<b>平方锥度</b>(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。</p>';
s += '</div>';
return s;
}
function audioFlowHtml(d) {
let h = '';
if (!d || !d.available) {
@@ -532,11 +562,7 @@
h += '<p class="muted">当前无活动音频流。</p>';
}
h += '</div>';
h += '<div class="card"><h3 class="card-title">路由与音量</h3><table class="table"><tbody>' +
row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) +
row('输出口', audioPortName(d.port)) +
row('音量', (d.vol != null && d.vol >= 0 ? esc(d.vol) + ' %' : '—')) +
'</tbody></table></div>';
h += audioCtlHtml(d);
}
return h;
}
@@ -550,13 +576,14 @@
return;
}
if (state.route !== 'audio') return;
if (audioVolDrag) return; // 音量滑条拖拽中:暂停重渲染防打断(电平/控件保留当前交互态)
flowEl.innerHTML = audioFlowHtml(d);
updateLevelMeter($('#audio-level'), d);
const note = $('#audio-level-note');
if (note) {
if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新 · 只读展示。';
else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。';
else note.innerHTML = '每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。';
if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新(路由/音量写端点不可用)。';
else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。路由/音量为写级,需登录会话。';
else note.innerHTML = '每 1 秒自动刷新 · 流信息只读;路由与音量为写级(确认后生效,不放测试音)。';
}
}
function renderAudio() {
@@ -573,12 +600,56 @@
fetchAudioOnce();
audioTimer = setInterval(fetchAudioOnce, 1000);
}
/* 音频页交互(委托 #content,元素随 1s 轮询重建亦不失效):路由切换/音量滑条均写级 + confirm。 */
function bindAudio() {
$('#content').addEventListener('click', async function (e) {
const rb = e.target.closest('[data-audio-route]');
if (rb) {
const port = parseInt(rb.dataset.audioRoute, 10);
if (!(port === 0 || port === 2 || port === 3)) { toast('非法路由口(仅自动/3.5/4.4)'); return; }
const ok = await confirmDlg('切换输出路由',
'确认切换到「' + audioRouteLabel(port) + (port === 0 ? '(跟随插孔)' : '') + '」?\n选错输出口会无声,请确认当前插孔。', false);
if (!ok) return;
try { await api('POST', '/api/audio/route', { port: port }); toast('路由已切换:' + audioRouteLabel(port)); }
catch (err) { toast('切换失败:' + errText(err)); }
return;
}
});
$('#content').addEventListener('pointerdown', function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (sl) { audioVolDrag = true; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; }
});
$('#content').addEventListener('pointerup', function (e) {
// 释放即复位;若值已提交 change 会再走确认,此处仅保证「点击未改动」时也不卡住重渲染
if (e.target.closest('[data-audio-vol]')) setTimeout(function () { audioVolDrag = false; }, 250);
});
$('#content').addEventListener('input', function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (!sl) return;
const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value;
});
$('#content').addEventListener('change', async function (e) {
const sl = e.target.closest('[data-audio-vol]');
if (!sl) return;
audioVolDrag = false;
const v = parseInt(sl.value, 10);
const vmax = parseInt(sl.max, 10) || 100;
const ok = await confirmDlg('确认音量',
'确认将音量设为 ' + v + ' %?(系统上限 ' + vmax + ',平方锥度非线性——越接近上限越响且非等比)', false);
if (!ok) { fetchAudioOnce(); return; } // 取消 → 立即拉一次复位滑条
try {
await api('POST', '/api/audio/volume', { value: v });
toast('音量已设为 ' + v + ' %');
fetchAudioOnce();
} catch (err) { toast('设置失败:' + errText(err)); fetchAudioOnce(); }
});
}
/* ---------------- 2d. 首页仪表盘(#/home,默认路由;/api/overview 聚合,5s 节拍 + 电平 1s 子节拍) ----------------
* 单请求 /api/overview 供全部卡片;另低频补充 /api/system(boot/默认启动,静态)+ /api/net/dns(DNS 上游/缓存)。
* 任一源 available:false / 缺失 → 该卡显示「—」,绝不拖垮其它卡。 */
let homeTimer = null, homeLevelTimer = null;
let homeData = null, homeDns = null, homeSys = null;
let homeData = null, homeSys = null;
function stopHome() {
if (homeTimer) { clearInterval(homeTimer); homeTimer = null; }
if (homeLevelTimer) { clearInterval(homeLevelTimer); homeLevelTimer = null; }
@@ -643,7 +714,7 @@
const nd = homeData && homeData.net;
if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable());
const w = nd.wifi || {};
const dn = homeDns;
const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求)
let rows = '';
rows += homeKv('WiFi', w.connected ? ('<span class="home-chip ok">已连接</span> ' + esc(w.ssid || '')) : '<span class="home-chip warn">未连接</span>');
if (w.connected) {
@@ -733,24 +804,23 @@
}
async function fetchOverviewOnce() {
if (state.route !== 'home') return;
const r = await Promise.allSettled([api('GET', '/api/net/dns'), api('GET', '/api/overview')]);
if (r[1].status !== 'fulfilled') {
const r = await Promise.allSettled([api('GET', '/api/overview')]);
if (r[0].status !== 'fulfilled') {
const b = $('#home-banner');
if (b) {
b.innerHTML = '<span class="ico">⚠️</span><span>总览加载失败:' + esc(errText(r[1].reason)) + '</span><button class="btn" id="home-retry">重试</button>';
b.innerHTML = '<span class="ico">⚠️</span><span>总览加载失败:' + esc(errText(r[0].reason)) + '</span><button class="btn" id="home-retry">重试</button>';
b.hidden = false;
}
return;
}
const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; }
homeDns = (r[0].status === 'fulfilled') ? r[0].value : null;
homeData = r[1].value;
homeData = r[0].value; // 首页严格单请求:DNS 已并入 overview 的 net 块
renderHomeCards();
}
function renderHome() {
stopHome();
resetSpark(); // 进入首页重置 sparkline 累积
homeData = null; homeDns = null; homeSys = null;
homeData = null; homeSys = null;
$('#content').innerHTML =
'<div class="page" id="home-page">' +
'<div id="home-banner" class="page-banner" hidden></div>' +
@@ -1863,27 +1933,86 @@
});
}
/* ---------------- 8. 日志页 ---------------- */
async function renderLogs() {
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
let d;
try { d = await api('GET', '/api/logs'); }
catch (e) { if (state.route === 'logs') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route !== 'logs') return;
let h = '<div class="page" id="logs-page">';
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
h += '<div class="field-row" style="margin-bottom:12px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
h += '<table class="table"><thead><tr><th>文件</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!d.files.length) h += '<tr><td colspan="4" class="empty">暂无日志</td></tr>';
d.files.forEach(f => {
h += '<tr><td class="mono">' + esc(f.name) + '</td><td class="muted">' + fmtBytes(f.size) + '</td><td class="muted">' + fmtTime(f.mtime) + '</td>' +
/* ---------------- 8. 日志页(批2 崩溃/日志中心:崩溃汇总 + 分组 + 前缀过滤 + 崩溃尾巴前置) ---------------- */
let logsGroup = 'all', logsPrefix = '';
const LOG_GROUP_NAME = { all: '全部', system: '系统服务', app: 'App', audio_monitor: 'audio_monitor', power: 'power', net: 'net', crash: 'crash', ams: 'ams' };
function groupName(g) { return LOG_GROUP_NAME[g] || (g || 'app'); }
function logsCrashCard(cr) {
const ents = (cr.entities || []).filter(e => e.name);
let h = '<div class="card"><h3 class="card-title">崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')</h3>';
if (!ents.length && !(cr.dmesg_count || 0)) { h += '<p class="muted">本 boot 无崩溃记录。</p></div>'; return h; }
h += '<table class="table"><thead><tr><th>实体</th><th>崩溃数</th><th>最近时间</th><th>信号/退出</th><th style="text-align:right">操作</th></tr></thead><tbody>';
ents.forEach(e => {
h += '<tr><td class="mono">' + esc(e.name) + '</td>' +
'<td class="' + ((e.count || 0) > 0 ? 'crash-hi' : 'muted') + '">' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + '</td>' +
'<td class="muted">' + esc(e.last_time || '—') + '</td>' +
'<td><span class="muted">' + esc(e.last_kind || '—') + '</span>' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-crash-tail="' + escSafeAttr(e.name) + '">stdout 尾部</button></div></td></tr>';
});
h += '</tbody></table>';
if (cr.dmesg_count) {
h += '<p class="muted">内核 oops / segfault 捕获:<b>' + esc(cr.dmesg_count) + '</b> 条' +
((cr.dmesg_tail || []).length ? '<pre class="log-view">' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '</pre>' : '') + '</p>';
}
h += '</div>';
return h;
}
function renderLogsFiles(files) {
const el = $('#logs-files');
if (!el) return;
const filtered = files.filter(f => {
if (logsGroup !== 'all' && (f.group || 'app') !== logsGroup) return false;
if (logsPrefix && !f.name.toLowerCase().startsWith(logsPrefix.toLowerCase())) return false;
return true;
});
let h = '<table class="table"><thead><tr><th>文件</th><th>分组</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!filtered.length) h += '<tr><td colspan="5" class="empty">暂无日志(或过滤后无结果)</td></tr>';
filtered.forEach(f => {
h += '<tr><td class="mono">' + esc(f.name) + '</td><td class="muted">' + groupName(f.group) + '</td><td class="muted">' + fmtBytes(f.size) + '</td><td class="muted">' + fmtTime(f.mtime) + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-log="' + esc(f.name) + '">查看</button></div></td></tr>';
});
h += '</tbody></table></div><pre class="log-view" id="log-view" hidden></pre></div>';
h += '</tbody></table>';
el.innerHTML = h;
}
async function renderLogs() {
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
const r = await Promise.allSettled([api('GET', '/api/logs'), api('GET', '/api/logs/crash')]);
if (state.route !== 'logs') return;
if (r[0].status !== 'fulfilled') {
$('#content').innerHTML = emptyState('加载失败:' + errText(r[0].reason), r[0].reason && r[0].reason.code);
return;
}
const d = r[0].value;
const cr = (r[1].status === 'fulfilled') ? r[1].value : null;
let h = '<div class="page" id="logs-page">';
if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr);
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
h += '<div class="field-row" style="margin-bottom:8px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
h += '<input class="log-prefix" data-log-prefix placeholder="按文件名前缀过滤" value="' + escSafeAttr(logsPrefix) + '" style="flex:1;min-width:140px">';
h += '</div>';
h += '<div class="field-row" style="flex-wrap:wrap;gap:6px;margin-bottom:12px">';
Object.keys(LOG_GROUP_NAME).forEach(g => {
h += '<button class="btn ' + (logsGroup === g ? 'btn-primary' : 'btn-ghost') + '" data-log-group="' + g + '">' + LOG_GROUP_NAME[g] + '</button>';
});
h += '</div>';
h += '<div id="logs-files"><table class="table"><tbody><tr><td class="empty">加载…</td></tr></tbody></table></div>';
h += '</div><pre class="log-view" id="log-view" hidden></pre></div>';
$('#content').innerHTML = h;
logsFiles = d.files || [];
renderLogsFiles(logsFiles);
}
function bindLogs() {
$('#content').addEventListener('click', async function (e) {
const gbtn = e.target.closest('[data-log-group]');
if (gbtn) {
logsGroup = gbtn.dataset.logGroup;
$$('[data-log-group]').forEach(b => b.classList.toggle('btn-primary', b === gbtn));
renderLogsFiles(logsFiles);
return;
}
const tb = e.target.closest('[data-crash-tail]');
if (tb) { await viewLogsCrashTail(tb.dataset.crashTail, tb); return; }
const t = e.target.closest('[data-log]'); if (!t) return;
const p = $('#log-view');
try {
@@ -1891,6 +2020,32 @@
p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : '');
} catch (err) { toast('查看失败:' + errText(err)); }
});
$('#content').addEventListener('input', function (e) {
const p = e.target.closest('[data-log-prefix]');
if (!p) return;
logsPrefix = p.value;
renderLogsFiles(logsFiles);
});
}
/* 崩溃实体 → stdout 尾部:复用批1-B 的读取路径(/api/files 列 logs/<pkg>/ → /api/logs/read 读尾部)。 */
let logsFiles = [];
async function viewLogsCrashTail(name, btn) {
btn.textContent = '加载中…';
let file;
try {
const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + name + '/'));
const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0);
if (!files.length) { btn.textContent = 'stdout 尾部'; toast('未找到 ' + name + ' 的 stdout 日志'); return; }
files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
file = files[0].name;
} catch (err) { btn.textContent = 'stdout 尾部'; toast('查找失败:' + errText(err)); return; }
try {
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(name + '/' + file) + '&lines=200');
const lines = (d.lines || []);
const p = $('#log-view');
if (p) { p.hidden = false; p.textContent = name + '/' + file + '\n' + (lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : ''); }
} catch (err) { toast('读取失败:' + errText(err)); }
btn.textContent = 'stdout 尾部';
}
/* ---------------- 9. 通知页 ---------------- */
@@ -2132,7 +2287,7 @@
home: { title: '首页仪表盘', render: renderHome, bind: bindHome },
perf: { title: '实时性能', render: renderPerf, bind: null },
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
audio: { title: '音频状态', render: renderAudio, bind: null },
audio: { title: '音频状态', render: renderAudio, bind: bindAudio },
net: { title: '网络管理', render: renderNet, bind: bindNet },
files: { title: '文件管理', render: renderFiles, bind: bindFiles },
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
@@ -2181,7 +2336,7 @@
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin();
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin(); bindAudio();
bindLogin();
bindPwDlg();
// 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录