mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
8 Commits
@@ -104,8 +104,12 @@ WOC_SPOOF_OS=1
|
||||
# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500
|
||||
# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog
|
||||
# WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启实例;默认 0 = 关闭该探测(仅保留内存自愈)。
|
||||
# 健康实例探测约 1ms,但宿主级 CPU/IO 偶发争用会让探测超时被误判为卡死而重启正常实例,故默认关闭;
|
||||
# 想要"卡在正在连接桌面"时自动兜底,可设为正整数(如 3 = 连续 3 次≈15 分钟无响应才重启)。
|
||||
#
|
||||
# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。
|
||||
WOC_INSTANCE_MEM_SOFT_MB=1500
|
||||
WOC_INSTANCE_MEM_HARD_MB=2500
|
||||
WOC_WATCHDOG_INTERVAL_SEC=300
|
||||
# WOC_WATCHDOG_HEALTH_FAILS=0
|
||||
|
||||
@@ -62,6 +62,16 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 解析烤进面板镜像的版本号:打 tag / 发 Release 时用 tag 名(vX.Y.Z),手动触发则用 dev-<短SHA>。
|
||||
- name: Resolve build version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Docker metadata (tags + labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -85,6 +95,8 @@ jobs:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# 仅面板镜像消费 WOC_VERSION(实例镜像不展示版本号),避免「未使用的 build-arg」告警。
|
||||
build-args: ${{ matrix.name == 'panel' && format('WOC_VERSION={0}', steps.ver.outputs.version) || '' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
<h1>云微 · WechatOnCloud</h1>
|
||||
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一会话</b></p>
|
||||
|
||||
<p>不止微信——还能开 <b>Chromium 浏览器实例</b>,登录 Telegram / X / Instagram 等网页版社媒,常驻云端、多端同步</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
|
||||
@@ -19,6 +21,7 @@
|
||||
<p>
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#核心特性">核心特性</a> ·
|
||||
<a href="#浏览器实例登录网页版社媒">浏览器实例</a> ·
|
||||
<a href="doc/运行原理.md">运行原理</a> ·
|
||||
<a href="#安全须知必读">安全须知</a> ·
|
||||
<a href="doc/技术方案.md">技术方案</a>
|
||||
@@ -33,9 +36,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:面板可管理**多个**实例,每个实例都是一个独立容器——可以是一个**微信**会话,也可以是一个 **Chromium 浏览器**(用来登录 Telegram / X / Instagram 等网页版应用)。多个 web 用户通过浏览器访问被授权的实例,实现跨设备同步、多端共享。**不修改微信客户端。**
|
||||
|
||||
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
**一句话原理**:每个实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 一个应用(官方原版微信,或 Chromium 浏览器),KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
|
||||
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
|
||||
|
||||
@@ -43,12 +46,13 @@
|
||||
|
||||
## 核心特性
|
||||
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立实例,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 🌐 **多应用(微信 + 浏览器)** — 新建实例时可选**微信**或 **Chromium 浏览器**;浏览器实例用来登录 Telegram / X / Instagram 等网页版社媒,登录态写进数据卷、常驻云端、多端共享。
|
||||
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
|
||||
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
|
||||
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
|
||||
- 🖥️ **PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉;实例图标可自定义(内置图标 / 上传裁剪)。
|
||||
- 📦 **微信按需下载 · 浏览器开箱即用** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条、按架构取包;Chromium 已烤进镜像,创建即用、无需下载。
|
||||
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
|
||||
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`;文本可经剪贴板中转送进微信(局域网 http 下也可用)。
|
||||
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达实例桌面 `~/Desktop`;文本可经剪贴板中转送进实例(局域网 http 下也可用)。
|
||||
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
|
||||
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
|
||||
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
|
||||
@@ -102,46 +106,58 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
|
||||
> **改配置(强烈建议至少改密码)**:默认管理员 **admin / wechat**。登录后在「修改密码」里改;或部署前在 `docker-compose.yml` 旁放一个 `.env`(从 [.env.example](.env.example) 下载改名),又或在 NAS 的 Compose 环境变量里填 `WOC_PASSWORD`、`WOC_HTTP_PORT`、`WOC_IMAGE_PREFIX` 等(全部可配置项见 [.env.example](.env.example))。
|
||||
|
||||
> **为什么默认 Docker Hub**:国内 / 国际通用、免登录即可拉公开镜像,**飞牛 OS(fnOS)等 NAS 系统还内置了 Docker Hub 拉取加速**,通常比 GHCR 更快更稳。`ghcr.io` 拉不动时设 `WOC_IMAGE_PREFIX` 切源:
|
||||
>
|
||||
> ```bash
|
||||
> WOC_IMAGE_PREFIX=ghcr.io/gloridust # 备用:GitHub Container Registry
|
||||
> WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 备用:南京大学反代 ghcr.io(国内较稳)
|
||||
> ```
|
||||
>
|
||||
> 更多源(自建阿里云 ACR / 腾讯 TCR 等)见 [.env.example](.env.example)。报错 `denied`?说明该源上还没有镜像(或为私有),换个源或用方式 A 本地构建。
|
||||
> **镜像源**:默认 Docker Hub(国内外通用、免登录,**飞牛等 NAS 还内置了 Docker Hub 加速**,通常比 GHCR 更稳)。拉不动时设 `WOC_IMAGE_PREFIX` 切到备用源 `ghcr.io/gloridust` 或国内反代 `ghcr.nju.edu.cn/gloridust`(更多源见 [.env.example](.env.example))。报错 `denied` = 该源上还没有镜像,换源或用方式 A 本地构建。
|
||||
|
||||
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`:
|
||||
|
||||
1. 用 `.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
|
||||
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从镜像源拉取);
|
||||
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
|
||||
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可。
|
||||
2. 在「实例」页点「**新建实例**」,选应用类型(**微信** 或 **Chromium 浏览器**)、命名、勾选可访问的子账号 → 面板自动 `docker run` 起一个实例容器(镜像本地没有时才会从镜像源拉取);
|
||||
3. **微信实例**:进入后点「**下载并安装**」微信(约 190~210MB,带进度条,仅管理员);**浏览器实例**:随镜像就绪,跳过这步;
|
||||
4. 点「**进入实例**」→ 微信扫码登录即可收发消息;浏览器则直接打开网页登录 Telegram / X / Instagram 等。
|
||||
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**会话。
|
||||
|
||||
> **🛠️ NAS / 飞牛(fnOS) 用户必看——首次新建实例若卡住报 `创建容器失败:… registry-1.docker.io … timeout`**:
|
||||
> 这是 Docker **守护进程**拉取微信镜像超时。NAS 自带的「Docker Hub 加速」一般只作用于你在 NAS 界面**手动拉镜像**,不覆盖面板(经 docker.sock)触发的拉取,于是直连 `docker.io` 超时。
|
||||
> 这是 Docker **守护进程**拉取实例镜像超时。NAS 自带的「Docker Hub 加速」一般只作用于你在 NAS 界面**手动拉镜像**,不覆盖面板(经 docker.sock)触发的拉取,于是直连 `docker.io` 超时。
|
||||
> **最省事的解法**:先在 NAS 的 **Docker → 镜像 → 拉取** 里手动拉一次 `gloridust/wechat-on-cloud:latest`(和你拉 `woc-panel` 同样的方式)。镜像到本地后,面板新建实例会直接复用、不再联网拉取 → 立即成功。
|
||||
> 想一劳永逸:给 Docker 守护进程配「镜像加速器」(`/etc/docker/daemon.json` 的 `registry-mirrors`,改完重启 Docker),或把 `WOC_IMAGE_PREFIX` 换成国内可达源(如 `ghcr.nju.edu.cn/gloridust`)后重建面板。
|
||||
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
|
||||
|
||||
### 面板能做什么
|
||||
|
||||
| 功能 | 谁可用 | 说明 |
|
||||
|------|--------|------|
|
||||
| 新建 / 删除微信实例 | 管理员 | 一键创建独立微信会话容器;新建时勾选可访问的子账号。删除默认保留数据卷(聊天记录),可选彻底清除 |
|
||||
| 新建 / 删除实例 | 管理员 | 一键创建独立实例容器(微信 / Chromium 浏览器);新建时勾选可访问的子账号、可自定义图标。删除默认保留数据卷,可选彻底清除 |
|
||||
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
|
||||
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
|
||||
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
|
||||
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送进微信 |
|
||||
| 下载并安装 / 更新微信 | 管理员 | 微信实例一键下载官方 Linux 版到数据卷、解压安装、带进度条,后续可「更新到最新版」(浏览器实例无需此步) |
|
||||
| 进入实例 | 被授权用户 | 在浏览器里操作对应实例:微信扫码收发消息,或在 Chromium 里登录网页应用 |
|
||||
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送入实例 |
|
||||
| 实例日志 | 管理员 | 查看实例日志,含**持久化历史**(重启原因 + 上一容器日志快照,跨容器重建保留) |
|
||||
| 修改密码 | 所有人 | 改自己的登录密码 |
|
||||
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
|
||||
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 App(PWA) |
|
||||
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快。
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信 / 账号。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
> 微信本体**不打进镜像**,新建微信实例后在面板点「下载并安装」时才下载到该实例的数据卷(浏览器实例则已随镜像就绪),所以镜像小、构建快。
|
||||
|
||||
---
|
||||
|
||||
## 浏览器实例(登录网页版社媒)
|
||||
|
||||
云微是个**多应用**平台:除了微信,新建实例时还可以选 **Chromium 浏览器**——相当于一台**常驻云端、多端共享的浏览器**,专门用来登录各种**网页版**应用:
|
||||
|
||||
- **社媒 / IM**:Telegram Web、X(Twitter)、Instagram、WhatsApp Web、Discord、Slack、微博、知乎…… 凡是有网页版的都行;
|
||||
- **邮箱 / 后台 / 工具**:Gmail、各类管理后台、需要长期保持登录的网页应用。
|
||||
|
||||
和微信实例同一套体验与好处:
|
||||
|
||||
- **随镜像就绪、免下载** — Chromium 已烤进镜像,创建后点「进入实例」直接用(amd64 / arm64 均可)。
|
||||
- **登录态常驻、重启不掉** — 浏览器配置与 Cookie 写在实例数据卷 `/config`,容器重启 / 升级都保留登录。
|
||||
- **多端共享 + 同步** — 多设备打开同一实例看到的是**同一个**浏览器画面,跨设备无缝接力;多人操作有软锁保护。
|
||||
- **中文输入 / 文件 / 剪贴板** — 与微信实例共用一套:本地输入法直接打字,工具栏拖拽传文件、剪贴板中转文本。
|
||||
|
||||
> ⚠️ 浏览器实例登录着你的社媒账号,同样受[安全须知](#安全须知必读)约束——**切勿把面板暴露公网**。
|
||||
|
||||
---
|
||||
|
||||
@@ -159,15 +175,15 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
|
||||
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
|
||||
|
||||
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。
|
||||
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。**Chromium 浏览器实例**的占用与微信实例同量级(取决于开的标签页数),可套用上表。
|
||||
|
||||
---
|
||||
|
||||
## 安全须知(必读)
|
||||
|
||||
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
|
||||
> ⚠️ **这套系统暴露的是已登录的微信 / 社媒账号,请务必认真阅读本节。**
|
||||
|
||||
能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
能登录面板的人就能看你的聊天记录、以你身份发消息(浏览器实例则能用你登录的 Telegram / X / Instagram 等账号)。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
|
||||
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
|
||||
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
|
||||
@@ -180,10 +196,10 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
|
||||
## 中文输入
|
||||
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。直接在微信输入框打字即可。
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。在微信或浏览器的输入框直接打字即可(对所有实例通用)。
|
||||
|
||||
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
|
||||
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**。
|
||||
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信 / 网页里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**。
|
||||
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载。
|
||||
|
||||
---
|
||||
@@ -194,8 +210,9 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
|
||||
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
|
||||
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
|
||||
- [x] 多应用平台:微信 + Chromium 浏览器实例(登录 Telegram / X / Instagram 等网页版社媒)+ 自定义实例图标
|
||||
- [x] 预构建多架构镜像发布到 Docker Hub / GHCR + GitHub Actions 自动化
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志持久化(跨容器重建保留重启原因与日志快照)
|
||||
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
|
||||
+5
-2
@@ -11,9 +11,12 @@ woc_app_def() {
|
||||
APP_NAME=Telegram
|
||||
;;
|
||||
chromium)
|
||||
# 容器内无 user namespace / GPU:--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗
|
||||
# 容器内无 user namespace / GPU:--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗。
|
||||
# --disable-background-networking:关掉 Chromium 后台 phone-home(GCM 推送 / 组件更新 / 变体下载),
|
||||
# 在受限网络(NAS / 被墙)下这些会反复失败刷屏 "gcm ConnectionHandler failed net error: -2"。
|
||||
# 只影响后台流量,不影响前台网页加载与真实网络错误提示。
|
||||
APP_BIN=/usr/bin/chromium
|
||||
APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --user-data-dir=/config/chromium"
|
||||
APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --disable-background-networking --user-data-dir=/config/chromium"
|
||||
APP_NAME=Chromium
|
||||
;;
|
||||
custom)
|
||||
|
||||
+5
-1
@@ -17,7 +17,11 @@ RUN npm install
|
||||
COPY server/ ./
|
||||
COPY --from=web /web/dist ./web-dist
|
||||
|
||||
ENV STATIC_DIR=/app/web-dist \
|
||||
# 构建版本号:CI 用 git tag 注入(vX.Y.Z),本地构建默认 dev。烤进镜像 → 面板运行时显示真实版本并据此检测更新。
|
||||
# 放在末尾:改版本号不会破坏上面的依赖安装缓存。
|
||||
ARG WOC_VERSION=dev
|
||||
ENV WOC_VERSION=${WOC_VERSION} \
|
||||
STATIC_DIR=/app/web-dist \
|
||||
PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
+154
-1
@@ -1,5 +1,6 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { appendInstanceLog, deleteInstanceLog, appendPanelLog, readInstanceLog, readPanelLog, filterSince } from './logs.js';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import Docker from 'dockerode';
|
||||
@@ -130,7 +131,17 @@ async function ensureImage(): Promise<void> {
|
||||
} catch {
|
||||
/* 本地没有,下面拉取 */
|
||||
}
|
||||
await pullImage();
|
||||
// 首次新建实例常卡在这一步(NAS 直连 docker.io 拉取超时,见 README)。这里前后都打日志:
|
||||
// 若诊断包里只见"开始拉取"而无"完成/失败",即可定位为拉取卡死。
|
||||
appendPanelLog('INFO', `本地无实例镜像 ${WECHAT_IMAGE},开始拉取(首次较慢;NAS 直连 docker.io 可能超时)…`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await pullImage();
|
||||
appendPanelLog('INFO', `实例镜像拉取完成 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s)`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例镜像拉取失败 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s):${e?.message || e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
|
||||
@@ -140,6 +151,8 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
const existing = docker.getContainer(inst.containerName);
|
||||
await existing.inspect();
|
||||
// 删除前先把旧容器最后日志快照进持久日志,否则随容器删除就看不到"上次为何停/崩"。
|
||||
await snapshotContainerLog(inst, '容器重建(重启/升级/自愈),保留上一容器最后日志');
|
||||
await existing.remove({ force: true });
|
||||
} catch {
|
||||
/* 不存在,正常 */
|
||||
@@ -183,6 +196,7 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
const container = await docker.createContainer(createOpts);
|
||||
try {
|
||||
await container.start();
|
||||
appendInstanceLog(inst.id, '容器已启动');
|
||||
} catch (e) {
|
||||
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
|
||||
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
|
||||
@@ -241,6 +255,7 @@ export async function regenInstanceMachineId(inst: Instance): Promise<void> {
|
||||
export async function stopInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
|
||||
appendInstanceLog(inst.id, '容器已停止');
|
||||
} catch {
|
||||
/* 已停止或不存在 */
|
||||
}
|
||||
@@ -259,6 +274,7 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
|
||||
} catch {
|
||||
/* 卷可能不存在 */
|
||||
}
|
||||
deleteInstanceLog(inst.id); // 彻底删除时一并清掉持久日志
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +507,129 @@ function tarSingleFile(name: string, content: Buffer): Buffer {
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
|
||||
}
|
||||
|
||||
// ---------- 诊断包 ----------
|
||||
// 单个 tar entry(USTAR header + 内容 + 512 对齐填充),复用与 tarSingleFile 相同的格式。
|
||||
function tarEntry(name: string, content: Buffer): Buffer {
|
||||
const h = Buffer.alloc(512, 0);
|
||||
h.write(name.slice(0, 100), 0, 'utf8');
|
||||
h.write('0000644\0', 100);
|
||||
h.write('0001750\0', 108);
|
||||
h.write('0001750\0', 116);
|
||||
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
h.write('00000000000\0', 136);
|
||||
h.write(' ', 148); // checksum 占位
|
||||
h.write('0', 156); // typeflag 普通文件
|
||||
h.write('ustar\0', 257);
|
||||
h.write('00', 263);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
const pad = (512 - (content.length % 512)) % 512;
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0)]);
|
||||
}
|
||||
|
||||
// 多文件 tar.gz(内存构建;诊断包通常仅数 MB)。文件名用 ASCII 路径避免 utf8 超 100 字节。
|
||||
function buildTarGz(entries: { name: string; content: string | Buffer }[]): Buffer {
|
||||
const parts = entries.map((e) => tarEntry(e.name, Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content, 'utf8')));
|
||||
parts.push(Buffer.alloc(1024, 0)); // 两个空块标记归档结束
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
// 汇总诊断包:系统信息 + 面板全局日志 + 每个实例(容器状态 + 持久日志 + 实时日志)+ 全部 woc-* 容器清单。
|
||||
// 日志按 sinceMs 时间裁剪。给排查"首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败"等问题用。
|
||||
export async function buildDiagnostics(instances: Instance[], sinceMs: number, meta: Record<string, string>): Promise<Buffer> {
|
||||
const entries: { name: string; content: string | Buffer }[] = [];
|
||||
const stamp = new Date().toISOString();
|
||||
|
||||
entries.push({
|
||||
name: 'README.txt',
|
||||
content: [
|
||||
'云微 · WechatOnCloud 诊断包',
|
||||
`生成时间: ${stamp}`,
|
||||
`时间范围: 最近 ${meta.range || '24h'}`,
|
||||
'',
|
||||
'内容:',
|
||||
' system.txt 系统/Docker/镜像信息',
|
||||
' panel.log 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误)',
|
||||
' containers.txt 所有 woc-* 容器清单(含残留/未登记)',
|
||||
' instances/<id>.log 每个实例:容器状态 + 持久日志 + 实时容器日志',
|
||||
'',
|
||||
'把本压缩包发给维护者即可协助排查(不含密码/密钥等敏感信息)。',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// 系统信息
|
||||
let sys = `生成时间: ${stamp}\n时间范围: 最近 ${meta.range || '24h'}\n\n`;
|
||||
for (const [k, v] of Object.entries(meta)) sys += `${k}: ${v}\n`;
|
||||
try {
|
||||
const ver: any = await docker.version();
|
||||
sys += `\nDocker 版本: ${ver.Version} (API ${ver.ApiVersion}, ${ver.Os}/${ver.Arch})\n`;
|
||||
} catch (e: any) {
|
||||
sys += `\nDocker 版本: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const info: any = await docker.info();
|
||||
sys += `容器: ${info.Containers}(运行 ${info.ContainersRunning}) · 镜像: ${info.Images}\n`;
|
||||
sys += `内核: ${info.KernelVersion} · OS: ${info.OperatingSystem} · 架构: ${info.Architecture}\n`;
|
||||
sys += `CPU: ${info.NCPU} 核 · 内存: ${(info.MemTotal / 1073741824).toFixed(1)} GiB\n`;
|
||||
if (Array.isArray(info.Warnings) && info.Warnings.length) sys += `Docker 警告: ${info.Warnings.join('; ')}\n`;
|
||||
} catch (e: any) {
|
||||
sys += `Docker info: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const img: any = await docker.getImage(WECHAT_IMAGE).inspect();
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: ${String(img.Id).slice(0, 19)} · 创建 ${img.Created}\n`;
|
||||
} catch {
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: 本地不存在(首次新建实例需联网拉取,可能在此卡住)\n`;
|
||||
}
|
||||
sys += `\n实例数: ${instances.length}\n`;
|
||||
entries.push({ name: 'system.txt', content: sys });
|
||||
|
||||
// 面板全局日志(按范围裁剪)
|
||||
entries.push({ name: 'panel.log', content: filterSince(readPanelLog(), sinceMs) || '(无面板日志)' });
|
||||
|
||||
// 每个实例
|
||||
for (const inst of instances) {
|
||||
let c = `实例: ${inst.name}\nID: ${inst.id}\n容器: ${inst.containerName}\n类型: ${instanceAppType(inst)}\n数据卷: ${inst.volumeName}\n创建: ${inst.createdAt}\n\n`;
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
const s = info.State || {};
|
||||
c += `===== 容器状态 =====\n运行: ${s.Running} · 状态: ${s.Status} · 退出码: ${s.ExitCode}\n`;
|
||||
c += `OOMKilled: ${s.OOMKilled} · 重启次数: ${info.RestartCount} · 启动于: ${s.StartedAt}\n`;
|
||||
if (s.Error) c += `错误: ${s.Error}\n`;
|
||||
c += `镜像: ${String(info.Image).slice(0, 19)} · 健康: ${s.Health?.Status ?? 'n/a'}\n\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 容器状态 =====\n无法读取(容器可能未创建/已删除):${e?.message || e}\n\n`;
|
||||
}
|
||||
c += `===== 持久化日志(最近 ${meta.range || '24h'}) =====\n${filterSince(readInstanceLog(inst.id), sinceMs) || '(无)'}\n\n`;
|
||||
try {
|
||||
c += `===== 本次容器日志(实时 tail 300) =====\n${(await instanceLogs(inst, 300)).trimEnd() || '(无)'}\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 本次容器日志 =====\n获取失败:${e?.message || e}\n`;
|
||||
}
|
||||
entries.push({ name: `instances/${inst.id}.log`, content: c });
|
||||
}
|
||||
|
||||
// 全部 woc-* 容器清单(含未登记/残留,用于诊断"首次创建失败遗留")
|
||||
try {
|
||||
const all = await docker.listContainers({ all: true });
|
||||
const known = new Set(instances.map((i) => i.containerName));
|
||||
let txt = '所有 woc-* 容器:\n\n';
|
||||
for (const ct of all) {
|
||||
const names = (ct.Names || []).map((n: string) => n.replace(/^\//, ''));
|
||||
if (!names.some((n) => n.startsWith('woc-'))) continue;
|
||||
const nm = names.join(',');
|
||||
const tag = nm.includes('woc-panel') ? '面板' : known.has(nm) ? '已登记实例' : '未登记/残留';
|
||||
txt += `[${tag}] ${nm} · ${ct.State}/${ct.Status} · ${ct.Image}\n`;
|
||||
}
|
||||
entries.push({ name: 'containers.txt', content: txt });
|
||||
} catch (e: any) {
|
||||
entries.push({ name: 'containers.txt', content: '获取失败:' + (e?.message || e) });
|
||||
}
|
||||
|
||||
return buildTarGz(entries);
|
||||
}
|
||||
|
||||
// 校验文件名为安全 basename(防路径穿越)。
|
||||
function safeName(name: string): boolean {
|
||||
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
|
||||
@@ -580,6 +719,20 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
|
||||
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
|
||||
}
|
||||
|
||||
// ---------- 持久化日志 ----------
|
||||
// 日志原语(appendInstanceLog / readInstanceLog / deleteInstanceLog / appendPanelLog 等)已抽到 logs.ts
|
||||
// (无 docker 依赖,避免循环)。这里只保留需要 docker 的快照能力。
|
||||
|
||||
// 把"即将被删/重建"的容器最后日志快照进持久日志(否则随容器删除丢失)。
|
||||
export async function snapshotContainerLog(inst: Instance, reason: string): Promise<void> {
|
||||
try {
|
||||
const logs = (await instanceLogs(inst, 200)).trimEnd();
|
||||
appendInstanceLog(inst.id, `──── ${reason} ────\n${logs}\n──── 上一容器日志快照结束 ────`);
|
||||
} catch {
|
||||
/* 容器可能已不可读,忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
|
||||
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
|
||||
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
|
||||
|
||||
+106
-20
@@ -50,6 +50,7 @@ import {
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
buildDiagnostics,
|
||||
typeInInstance,
|
||||
keyInInstance,
|
||||
listOrphanVolumes,
|
||||
@@ -71,6 +72,8 @@ import {
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
|
||||
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
|
||||
import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -170,6 +173,19 @@ app.get('/api/auth/me', async (req, reply) => {
|
||||
return { user: publicUser(u) };
|
||||
});
|
||||
|
||||
// ---------- 版本与更新检测 ----------
|
||||
// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。
|
||||
app.get('/api/version', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应)
|
||||
return versionInfo();
|
||||
});
|
||||
// 立即重新检查(管理员,用于「检查更新」按钮)。
|
||||
app.post('/api/admin/version/check', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return await checkForUpdate();
|
||||
});
|
||||
|
||||
// ---------- 自助改密 ----------
|
||||
app.post('/api/account/password', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
@@ -291,12 +307,19 @@ app.post('/api/admin/instances', async (req, reply) => {
|
||||
reuseVolumeName = reuseVolume;
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
|
||||
appendPanelLog(
|
||||
'INFO',
|
||||
`创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`,
|
||||
);
|
||||
appendInstanceLog(inst.id, `实例创建(${type})by ${admin.username}`);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
} catch (e: any) {
|
||||
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
|
||||
appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
|
||||
}
|
||||
appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`);
|
||||
return { instance: publicInstance(inst) };
|
||||
});
|
||||
|
||||
@@ -433,6 +456,7 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true';
|
||||
const inst = findInstance(id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`);
|
||||
await removeInstanceContainer(inst, purge);
|
||||
removeInstanceRecord(id);
|
||||
controlHolders.delete(id);
|
||||
@@ -468,8 +492,10 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await ensureRunning(inst);
|
||||
appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -481,8 +507,10 @@ app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -493,9 +521,11 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -507,9 +537,12 @@ app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`);
|
||||
await upgradeInstance(inst);
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -669,13 +702,50 @@ app.get('/api/admin/instances/:id/logs', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
// 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。
|
||||
const history = readInstanceLog(inst.id).trimEnd();
|
||||
let live = '';
|
||||
try {
|
||||
const text = await instanceLogs(inst);
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send(text || '(暂无日志)');
|
||||
live = (await instanceLogs(inst)).trimEnd();
|
||||
} catch (e: any) {
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send('获取日志失败:' + (e?.message || e));
|
||||
live = '获取本次容器日志失败:' + (e?.message || e);
|
||||
}
|
||||
if (!history && !live) return reply.send('(暂无日志)');
|
||||
if (!history) return reply.send(live);
|
||||
return reply.send(
|
||||
`═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 全局日志 / 诊断包(仅管理员)----------
|
||||
// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。
|
||||
app.get('/api/admin/panel-log', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
const since = Date.now() - rangeToMs((req.query as any)?.range);
|
||||
const text = filterSince(readPanelLog(), since).trimEnd();
|
||||
return reply.send(text || '(暂无面板日志)');
|
||||
});
|
||||
|
||||
// 一键导出诊断包(tar.gz):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
|
||||
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
|
||||
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range:24h(默认)/7d/30d/1y。
|
||||
app.get('/api/admin/diagnostics', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const range = ((req.query as any)?.range as string) || '24h';
|
||||
if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法(24h/7d/30d/1y)' });
|
||||
const since = Date.now() - rangeToMs(range);
|
||||
try {
|
||||
const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION });
|
||||
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||||
reply.header('content-type', 'application/gzip');
|
||||
reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`);
|
||||
appendPanelLog('INFO', `导出诊断包(范围 ${range},${buf.length} 字节)`);
|
||||
return reply.send(buf);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -832,8 +902,10 @@ async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', repl
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await triggerWechat(inst, cmd);
|
||||
appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
|
||||
}
|
||||
}
|
||||
@@ -979,12 +1051,16 @@ for (const pub of listInstances()) {
|
||||
// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500
|
||||
// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog
|
||||
// WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启;默认 0=关闭该探测(仅保留内存自愈)
|
||||
const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500));
|
||||
const DEFAULT_HARD_MB = Math.max(
|
||||
0,
|
||||
Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500),
|
||||
);
|
||||
const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300));
|
||||
// VNC 响应性探测默认关闭(=0)。实测健康实例 ~1ms 响应,但偶发宿主级 CPU/IO 争用(如同机重 docker build)
|
||||
// 会让探测超时被误判为 stall 而重启正常实例,故默认不启用;需要时设为正整数 N(连续 N 次无响应才重启)开启。
|
||||
const HEALTH_FAIL_LIMIT = Math.max(0, Number(process.env.WOC_WATCHDOG_HEALTH_FAILS ?? 0));
|
||||
const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0);
|
||||
|
||||
// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。
|
||||
@@ -1005,18 +1081,20 @@ function hasActiveSession(id: string): boolean {
|
||||
|
||||
if (WATCHDOG_ENABLED) {
|
||||
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
|
||||
const healthFails = new Map<string, number>(); // id → 连续无响应次数
|
||||
const HEALTH_FAIL_LIMIT = 2; // 连续 N 次无响应才重启,避免误杀刚启动/瞬时抖动
|
||||
const healthFails = new Map<string, number>(); // id → 连续无响应次数(仅 HEALTH_FAIL_LIMIT>0 时启用)
|
||||
|
||||
const recover = async (inst: Instance, reason: string, detail: string) => {
|
||||
recovering.add(inst.id);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
|
||||
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`);
|
||||
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`);
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
healthFails.delete(inst.id);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}):${e?.message || e}`);
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
@@ -1049,18 +1127,21 @@ if (WATCHDOG_ENABLED) {
|
||||
app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`);
|
||||
}
|
||||
}
|
||||
// 2) 响应性自愈(新):探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启
|
||||
// 2) 响应性自愈:探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启。
|
||||
// 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。
|
||||
const healthy = await instanceHttpHealthy(inst);
|
||||
if (healthy) {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
const fails = (healthFails.get(inst.id) || 0) + 1;
|
||||
healthFails.set(inst.id, fails);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`);
|
||||
if (fails >= HEALTH_FAIL_LIMIT) {
|
||||
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
|
||||
// 默认关闭(HEALTH_FAIL_LIMIT=0):偶发宿主级争用会误判健康实例为 stall;需要时用 env 开启。
|
||||
if (HEALTH_FAIL_LIMIT > 0) {
|
||||
const healthy = await instanceHttpHealthy(inst);
|
||||
if (healthy) {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
const fails = (healthFails.get(inst.id) || 0) + 1;
|
||||
healthFails.set(inst.id, fails);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`);
|
||||
if (fails >= HEALTH_FAIL_LIMIT) {
|
||||
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
|
||||
@@ -1069,9 +1150,14 @@ if (WATCHDOG_ENABLED) {
|
||||
};
|
||||
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
|
||||
console.log(
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · 含响应性探测`,
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · VNC响应性探测=${HEALTH_FAIL_LIMIT > 0 ? `连续${HEALTH_FAIL_LIMIT}次` : '关闭'}`,
|
||||
);
|
||||
}
|
||||
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
|
||||
appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`);
|
||||
startUpdateChecker(); // 后台检测新版(best-effort,失败静默)
|
||||
// 日志保留期清理:启动后跑一次 + 每 24h 一次,删除超过一年的日志行(unref 不阻止退出)。
|
||||
pruneOldLogs();
|
||||
setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 全局持久化日志(存在面板数据卷 /…/logs/,宿主 ./data-panel 持久保留,跨容器/面板重建不丢)。
|
||||
// 两类日志:
|
||||
// _panel.log 面板级运维事件(实例创建/删除/升级/启停、镜像拉取、错误等跨实例的全局动作)
|
||||
// <实例id>.log 单实例生命周期 + 重启原因 + 重建前容器日志快照
|
||||
// 单文件按大小封顶(超限截掉前半保留最近),并按「一年保留」定期清理过期行。
|
||||
// 本模块不依赖 docker,避免与 docker.ts 形成循环依赖(docker.ts/index.ts 反过来引用本模块)。
|
||||
|
||||
import { appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
// 与 store.ts 的 accounts.json 同目录。fallback 须与 store.ts 一致。
|
||||
export const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
|
||||
const PER_FILE_CAP = 512 * 1024; // 单文件 ~512KB,超限截掉前半保留最近
|
||||
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000; // 日志保留一年,更早的自动清理
|
||||
const PANEL_LOG = `${LOG_DIR}/_panel.log`;
|
||||
const INSTANCE_ID_RE = /^[0-9a-f]{1,32}$/; // 实例 id 为十六进制;校验防路径注入
|
||||
|
||||
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function appendTo(file: string, line: string): void {
|
||||
try {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
appendFileSync(file, line.endsWith('\n') ? line : line + '\n');
|
||||
const sz = statSync(file).size;
|
||||
if (sz > PER_FILE_CAP) writeFileSync(file, readFileSync(file).subarray(sz - Math.floor(PER_FILE_CAP / 2)));
|
||||
} catch {
|
||||
/* 写日志失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
function instanceLogPath(id: string): string {
|
||||
return `${LOG_DIR}/${id}.log`;
|
||||
}
|
||||
|
||||
// ---------- 单实例日志 ----------
|
||||
export function appendInstanceLog(id: string, line: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
appendTo(instanceLogPath(id), `[${nowIso()}] ${line}`);
|
||||
}
|
||||
|
||||
export function readInstanceLog(id: string): string {
|
||||
if (!INSTANCE_ID_RE.test(id)) return '';
|
||||
try {
|
||||
const p = instanceLogPath(id);
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
|
||||
export function deleteInstanceLog(id: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
try {
|
||||
rmSync(instanceLogPath(id), { force: true });
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 面板级全局日志 ----------
|
||||
// 同时写入持久文件并回显 stdout(docker logs woc-panel 仍可见)。运维动作统一走这里,便于诊断包汇总。
|
||||
export function appendPanelLog(level: LogLevel, message: string): void {
|
||||
appendTo(PANEL_LOG, `[${nowIso()}] [${level}] ${message}`);
|
||||
const c = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log;
|
||||
c(`[panel] ${message}`);
|
||||
}
|
||||
|
||||
export function readPanelLog(): string {
|
||||
try {
|
||||
return existsSync(PANEL_LOG) ? readFileSync(PANEL_LOG, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 时间裁剪 / 保留期清理 ----------
|
||||
// 保留行首 [ISO时间] >= sinceMs 的行;无法解析时间戳的行跟随上一条的保留状态(多行块整体保留)。
|
||||
export function filterSince(text: string, sinceMs: number): string {
|
||||
if (!text) return '';
|
||||
const out: string[] = [];
|
||||
let keeping = false;
|
||||
for (const ln of text.split('\n')) {
|
||||
const m = /^\[(\d{4}-\d\d-\d\dT[\d:.]+Z)\]/.exec(ln);
|
||||
if (m) {
|
||||
const t = Date.parse(m[1]);
|
||||
if (Number.isFinite(t)) keeping = t >= sinceMs;
|
||||
}
|
||||
if (keeping) out.push(ln);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// 清理所有日志文件中早于一年的行;整文件均过期则删除。每日定时调用。
|
||||
export function pruneOldLogs(): void {
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) return;
|
||||
for (const f of readdirSync(LOG_DIR)) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const p = `${LOG_DIR}/${f}`;
|
||||
try {
|
||||
const kept = filterSince(readFileSync(p, 'utf8'), cutoff);
|
||||
if (kept.trim()) writeFileSync(p, kept.endsWith('\n') ? kept : kept + '\n');
|
||||
else rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* 单文件失败不影响其它 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断包可选时间范围(→ 毫秒)。默认 24h。
|
||||
export const DIAG_RANGES: Record<string, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
export function rangeToMs(range: string | undefined): number {
|
||||
return DIAG_RANGES[range ?? '24h'] ?? DIAG_RANGES['24h'];
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 面板版本与更新检测。
|
||||
// 构建时把版本号烤进镜像(Dockerfile: ARG/ENV WOC_VERSION,由 CI 用 git tag 注入,本地构建为 dev);
|
||||
// 运行时查询 Docker Hub 与 GHCR 上 woc-panel 的最新语义化标签,比对后给前端「有新版」红点。
|
||||
// 全程 best-effort:离线 / 被墙 / 私有源拉取失败时不报错、不打扰,仅不显示红点(记 error 供「上次检查」提示)。
|
||||
|
||||
export const CURRENT_VERSION = (process.env.WOC_VERSION || 'dev').trim();
|
||||
|
||||
export interface VersionInfo {
|
||||
current: string; // 当前构建版本(如 v1.2.0 / dev)
|
||||
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
|
||||
hasUpdate: boolean; // 当前能解析为语义化版本且 latest > current 时为 true
|
||||
checkedAt: number; // 上次检查时间戳(ms);0 = 尚未检查
|
||||
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
|
||||
error: string | null; // 检查失败原因(两个源都拉不到时)
|
||||
}
|
||||
|
||||
// 镜像命名空间:从 WOC_WECHAT_IMAGE 推断(面板与实例镜像同账号)。
|
||||
// 例 docker.io/gloridust/wechat-on-cloud:latest → gloridust;ghcr.io/gloridust/... 同理。
|
||||
function imageOwner(): string {
|
||||
const img = (process.env.WOC_WECHAT_IMAGE || 'docker.io/gloridust/wechat-on-cloud').split('@')[0];
|
||||
const segs = img.split('/'); // [registry?, owner, image[:tag]]
|
||||
return segs.length >= 2 ? segs[segs.length - 2] : 'gloridust';
|
||||
}
|
||||
const PANEL_REPO = process.env.WOC_PANEL_REPO || 'woc-panel';
|
||||
|
||||
function parseSemver(s: string): [number, number, number] | null {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
function cmpSemver(a: [number, number, number], b: [number, number, number]): number {
|
||||
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
||||
}
|
||||
// 从一堆标签里挑出最大的 x.y.z(忽略 latest / x / x.y 等非完整语义化标签)。
|
||||
function maxSemver(tags: string[]): string | null {
|
||||
let best: [number, number, number] | null = null;
|
||||
for (const t of tags) {
|
||||
const v = parseSemver(t);
|
||||
if (v && (!best || cmpSemver(v, best) > 0)) best = v;
|
||||
}
|
||||
return best ? `${best[0]}.${best[1]}.${best[2]}` : null;
|
||||
}
|
||||
|
||||
async function getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<any> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'user-agent': 'woc-panel-update-check', ...headers }, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub 公共 API:免鉴权列标签。
|
||||
async function dockerHubTags(owner: string): Promise<string[]> {
|
||||
const d = await getJson(`https://hub.docker.com/v2/repositories/${owner}/${PANEL_REPO}/tags?page_size=100`, {}, 8000);
|
||||
return Array.isArray(d?.results) ? d.results.map((r: any) => String(r?.name || '')) : [];
|
||||
}
|
||||
// GHCR 公共镜像:先取匿名 pull token,再走 registry v2 tags/list。
|
||||
async function ghcrTags(owner: string): Promise<string[]> {
|
||||
const tok = await getJson(`https://ghcr.io/token?scope=repository:${owner}/${PANEL_REPO}:pull&service=ghcr.io`, {}, 8000);
|
||||
const d = await getJson(`https://ghcr.io/v2/${owner}/${PANEL_REPO}/tags/list`, { authorization: `Bearer ${tok?.token || ''}` }, 8000);
|
||||
return Array.isArray(d?.tags) ? d.tags.map((t: any) => String(t)) : [];
|
||||
}
|
||||
|
||||
let cache: VersionInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: 0, source: null, error: null };
|
||||
let inflight: Promise<VersionInfo> | null = null;
|
||||
|
||||
export function versionInfo(): VersionInfo {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// 查询两个仓库(并行、互不阻塞),取全局最大语义化版本与当前版本比对。失败静默写入 error。
|
||||
export function checkForUpdate(): Promise<VersionInfo> {
|
||||
if (inflight) return inflight; // 合并并发请求,避免重复外呼
|
||||
inflight = (async () => {
|
||||
const owner = imageOwner();
|
||||
const sources: string[] = [];
|
||||
const tags: string[] = [];
|
||||
const [hub, ghcr] = await Promise.allSettled([dockerHubTags(owner), ghcrTags(owner)]);
|
||||
if (hub.status === 'fulfilled' && hub.value.length) {
|
||||
tags.push(...hub.value);
|
||||
sources.push('dockerhub');
|
||||
}
|
||||
if (ghcr.status === 'fulfilled' && ghcr.value.length) {
|
||||
tags.push(...ghcr.value);
|
||||
sources.push('ghcr');
|
||||
}
|
||||
const latestBare = maxSemver(tags);
|
||||
const cur = parseSemver(CURRENT_VERSION);
|
||||
const latestV = latestBare ? parseSemver(latestBare) : null;
|
||||
const hasUpdate = !!(latestV && cur && cmpSemver(latestV, cur) > 0);
|
||||
cache = {
|
||||
current: CURRENT_VERSION,
|
||||
latest: latestBare ? `v${latestBare}` : null,
|
||||
hasUpdate,
|
||||
checkedAt: Date.now(),
|
||||
source: sources.join('+') || null,
|
||||
error: tags.length ? null : '无法连接镜像仓库(Docker Hub / GHCR)',
|
||||
};
|
||||
return cache;
|
||||
})().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// 尚未检查过则触发一次后台检查(不等待)。GET /api/version 调用,保证刚启动也能尽快填上缓存。
|
||||
export function ensureChecked(): void {
|
||||
if (!cache.checkedAt && !inflight) void checkForUpdate().catch(() => {});
|
||||
}
|
||||
|
||||
// 启动后延迟首检(让监听就绪)+ 每 6 小时复检;定时器 unref,不阻止进程退出。
|
||||
export function startUpdateChecker(): void {
|
||||
setTimeout(() => void checkForUpdate().catch(() => {}), 4_000).unref();
|
||||
setInterval(() => void checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000).unref();
|
||||
}
|
||||
@@ -157,6 +157,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const go = (p: string) => nav(p);
|
||||
|
||||
// 有新版时在「管理」入口点个红点(仅管理员,因为升级面板需管理员在宿主操作)。
|
||||
// 依赖 loc.pathname:导航时复查一次(服务端有缓存、开销极小),保证刚启动时首检完成后红点能及时出现。
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api
|
||||
.getVersion()
|
||||
.then((v) => setHasUpdate(!!v.hasUpdate))
|
||||
.catch(() => {});
|
||||
}, [isAdmin, loc.pathname]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sb-top">
|
||||
@@ -196,9 +207,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
</div>
|
||||
|
||||
<div className="sb-footer">
|
||||
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
|
||||
<span className="sb-ic">{Icon.gear}</span>
|
||||
<button
|
||||
className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')}
|
||||
onClick={() => go('/admin')}
|
||||
title={isAdmin && hasUpdate ? '管理 · 有新版本可用' : isAdmin ? '管理' : '设置'}
|
||||
>
|
||||
<span className="sb-ic">
|
||||
{Icon.gear}
|
||||
{isAdmin && hasUpdate && <span className="sb-updot" />}
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
|
||||
{!collapsed && isAdmin && hasUpdate && <span className="sb-updot-text">新版</span>}
|
||||
</button>
|
||||
<button
|
||||
className="sb-item"
|
||||
|
||||
@@ -75,6 +75,15 @@ export interface VolEntry {
|
||||
mtime: number; // epoch ms
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
current: string; // 当前构建版本(如 v1.2.0 / dev)
|
||||
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
|
||||
hasUpdate: boolean; // 有更高的语义化版本可用
|
||||
checkedAt: number; // 上次检查时间戳(ms);0=尚未检查
|
||||
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
|
||||
error: string | null; // 检查失败原因
|
||||
}
|
||||
|
||||
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
|
||||
async function rawUpload(url: string, file: File): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
@@ -116,6 +125,10 @@ export const api = {
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
|
||||
|
||||
// 版本与更新检测
|
||||
getVersion: () => req<VersionInfo>('/api/version'),
|
||||
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
|
||||
|
||||
// 子账号
|
||||
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
|
||||
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
|
||||
@@ -171,6 +184,9 @@ export const api = {
|
||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
||||
// 全局日志 / 诊断包(范围 24h/7d/30d/1y)
|
||||
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
|
||||
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
|
||||
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType, type VersionInfo } from '../api';
|
||||
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
|
||||
import { useUI, PasswordInput } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
@@ -80,6 +80,145 @@ function EmptyState({ icon, title, sub, action }: { icon: string; title: string;
|
||||
);
|
||||
}
|
||||
|
||||
const RELEASES_URL = 'https://github.com/Gloridust/WechatOnCloud/releases';
|
||||
|
||||
const DIAG_RANGE_OPTIONS = [
|
||||
{ key: '24h', label: '24 小时' },
|
||||
{ key: '7d', label: '7 天' },
|
||||
{ key: '30d', label: '30 天' },
|
||||
{ key: '1y', label: '1 年' },
|
||||
];
|
||||
|
||||
// 「诊断与日志」(仅管理员):单实例「日志」只记录该实例日志;这里一键打包全局——系统信息 +
|
||||
// 面板运维日志 + 全部实例容器状态/日志 + 容器清单,便于排查部署/创建卡死/黑屏不可用等问题。
|
||||
function DiagnosticsSection() {
|
||||
const [range, setRange] = useState('24h');
|
||||
const exportBundle = () => {
|
||||
// tar.gz 带 content-disposition: attachment,用隐藏 <a> 触发下载(带同源 cookie),不离开页面。
|
||||
const a = document.createElement('a');
|
||||
a.href = api.diagnosticsUrl(range);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">诊断与日志</span>
|
||||
</div>
|
||||
<div className="settings-block">
|
||||
<p className="s-desc">打包系统/Docker 信息 + 面板全局日志 + 各实例容器状态与日志 + 容器清单,用于排查部署、创建卡死、黑屏不可用、升级失败等问题。</p>
|
||||
<div className="s-field">
|
||||
<span className="field-label">时间范围</span>
|
||||
<div className="chip-row">
|
||||
{DIAG_RANGE_OPTIONS.map((r) => (
|
||||
<button key={r.key} className={'chip chip-toggle' + (range === r.key ? ' on' : '')} onClick={() => setRange(r.key)}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-primary s-btn" onClick={exportBundle}>
|
||||
导出诊断包
|
||||
</button>
|
||||
<a className="btn-text" href={api.panelLogUrl(range)} target="_blank" rel="noreferrer">
|
||||
查看面板日志 ›
|
||||
</a>
|
||||
</div>
|
||||
<p className="s-foot">导出当前选定范围内的日志(.tar.gz)。超过一年的日志自动清理;诊断包不含密码 / 密钥等敏感信息。</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。
|
||||
function AboutSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { toast } = useUI();
|
||||
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getVersion().then(setInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 当前版本是否为正式发布版(语义化 vX.Y.Z)。dev / dev-<sha> 等本地构建无法与发布版比较,
|
||||
// 既不显示「已是最新」也不显示红点,只把最新发布版作为信息展示。
|
||||
const isRelease = !!info && /^v?\d+\.\d+\.\d+$/.test(info.current);
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const r = await api.checkUpdate();
|
||||
setInfo(r);
|
||||
const rel = /^v?\d+\.\d+\.\d+$/.test(r.current);
|
||||
if (r.error) toast('检查失败:' + r.error, 'error');
|
||||
else if (r.hasUpdate) toast(`发现新版本 ${r.latest}`, 'ok');
|
||||
else if (!rel) toast(`最新发布 ${r.latest ?? '未知'}(当前为开发版)`, 'ok');
|
||||
else toast('已是最新版本', 'ok');
|
||||
} catch (e: any) {
|
||||
toast(e.message || '检查失败', 'error');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">关于</span>
|
||||
</div>
|
||||
<div className="settings-block">
|
||||
<div className="s-title-row">
|
||||
<span className="s-app">云微 · WechatOnCloud</span>
|
||||
{info?.hasUpdate ? <span className="tag tag-warn">有新版</span> : info && !isRelease ? <span className="tag">开发版</span> : null}
|
||||
</div>
|
||||
<p className="s-line">
|
||||
当前版本 <b>{info?.current ?? '…'}</b>
|
||||
{info?.hasUpdate && info.latest && (
|
||||
<>
|
||||
{' · '}最新 <b>{info.latest}</b>
|
||||
</>
|
||||
)}
|
||||
{isRelease && info && !info.hasUpdate && info.latest && !info.error && <>{' · '}已是最新</>}
|
||||
{!isRelease && info?.latest && !info.error && (
|
||||
<>
|
||||
{' · '}最新发布 <b>{info.latest}</b>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{info?.hasUpdate && (
|
||||
<div className="ver-hint">
|
||||
在宿主执行 <code>docker compose pull && docker compose up -d</code> 升级面板;各实例镜像可在「管理 → 升级」单独更新。
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-actions">
|
||||
{info?.hasUpdate && (
|
||||
<a className="btn btn-primary s-btn" href={RELEASES_URL + '/latest'} target="_blank" rel="noreferrer">
|
||||
查看新版
|
||||
</a>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" disabled={checking} onClick={check}>
|
||||
{checking ? '检查中…' : '检查更新'}
|
||||
</button>
|
||||
)}
|
||||
<a className="btn-text" href={RELEASES_URL} target="_blank" rel="noreferrer">
|
||||
发布日志 ›
|
||||
</a>
|
||||
</div>
|
||||
{info && (
|
||||
<p className="s-foot">
|
||||
{info.checkedAt ? `上次检查 ${fmtDate(info.checkedAt)}` : '尚未检查'}
|
||||
{info.source && ` · 来源 ${info.source}`}
|
||||
{info.error && ` · ${info.error}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -446,6 +585,9 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && <DiagnosticsSection />}
|
||||
<AboutSection isAdmin={isAdmin} />
|
||||
</main>
|
||||
|
||||
{creatingUser && (
|
||||
@@ -1007,7 +1149,7 @@ function InstanceAdminCard({
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志">
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例日志(含历史:重启原因 + 上一容器日志快照,跨重启保留)">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
|
||||
|
||||
@@ -970,6 +970,31 @@ button {
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
/* 「关于」版本卡:有新版时的升级提示 + 上次检查脚注 */
|
||||
.ver-hint {
|
||||
margin-top: 10px;
|
||||
padding: 9px 11px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: rgba(245 158 11 / 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ver-hint code {
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(var(--shadow) / 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ver-checked {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.inst-actions a.btn {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.inst-enter {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1022,6 +1047,68 @@ button {
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 扁平设置区块(诊断与日志 / 关于):无卡片,直接排在分区标题下,避免单卡片的厚重感 */
|
||||
.settings-block {
|
||||
padding: 2px 2px 4px;
|
||||
}
|
||||
.settings-block .s-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
max-width: 660px;
|
||||
}
|
||||
.s-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.s-field .field-label {
|
||||
display: block;
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
/* 实心主按钮:固定内边距 + 不换行(之前作为 flex 项被压窄导致按钮内文字断行) */
|
||||
.settings-actions .s-btn {
|
||||
height: 42px;
|
||||
padding: 0 22px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-actions .btn-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.s-foot {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
max-width: 660px;
|
||||
}
|
||||
.s-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.s-app {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.s-line {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.s-line b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-row-pick {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1371,6 +1458,7 @@ button {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.sb-ic {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
@@ -1378,6 +1466,23 @@ button {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* 「有新版」红点:贴在「管理」齿轮图标右上角,折叠/展开都可见 */
|
||||
.sb-updot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -1px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f5483b;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
.sb-updot-text {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #f5483b;
|
||||
}
|
||||
.sb-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -11,12 +11,15 @@ set -euo pipefail
|
||||
OWNER="${WOC_IMAGE_OWNER:-gloridust}"
|
||||
TAG="${WOC_VERSION:-latest}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# 烤进面板镜像的版本号:设了 WOC_VERSION 就用它(如 v1.2.0),否则用 dev-<短SHA>(本地构建标识)。
|
||||
# 开发版不是正式发布版,面板「关于」会标「开发版」、不会触发「有新版」红点。
|
||||
VER="${WOC_VERSION:-dev-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo local)}"
|
||||
|
||||
PANEL_IMAGE="ghcr.io/${OWNER}/woc-panel:${TAG}"
|
||||
WECHAT_IMAGE="ghcr.io/${OWNER}/wechat-on-cloud:${TAG}"
|
||||
|
||||
echo "==> 构建面板镜像 ${PANEL_IMAGE}"
|
||||
docker build -t "${PANEL_IMAGE}" "${ROOT}/panel"
|
||||
echo "==> 构建面板镜像 ${PANEL_IMAGE} (版本号 ${VER})"
|
||||
docker build --build-arg "WOC_VERSION=${VER}" -t "${PANEL_IMAGE}" "${ROOT}/panel"
|
||||
|
||||
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
|
||||
docker build -t "${WECHAT_IMAGE}" "${ROOT}/docker"
|
||||
|
||||
Reference in New Issue
Block a user