Repository navigation
[Feature] 加一个 Web 可视化管理面板:无头 / 服务器 Docker 部署时也能看额度、切套餐、看日志 #58
Description
Activity
可以提个issue,但是需要注意非回环请求直接 403在VPS部署上可能会出问题,可以考虑ssh进去然后用tui?
感谢回复,也谢谢这个项目。
我这边的场景大概正好是你说的 VPS 情况,说明一下为什么 TUI 这条路走不通:
- 部署是 Ubuntu 云服务器 + Docker Compose(
ghcr.io/tridefender/zcode-proxy:latest),容器 CMD 就是bun run src/index.ts serve,serve 模式没有 TUI 代码路径,容器也没分配 TTY,所以 SSH 进去只能看到日志,docker exec -it也起不了面板; - 它是个常驻的共享服务:前面有 nginx 收发入口,
auth.proxyApiKey给多个客户端(Claude Code / Codex / 本机 harness 等)共用。管理动作——切服务商/套餐、开关 claim 和 async、看日志和额度——现在只能docker exec或改config.yaml重启; docker logs -f能看日志但拿不到额度,quota子命令每次都要手动 exec 一次。
所以我不主张放宽
isLoopback()的默认行为,那个默认是对的。我的想法是默认关闭、显式开启:- 面板 / 控制面在
serve模式下加一个开关(配置项或环境变量,比如ZCODE_PANEL_ENABLED、或者让ZCODE_CONTROL_BIND可配),不设就是现在的行为,保持 127.0.0.1 + 非回环 403; - 一旦开启就必须带 token 校验(直接复用
auth.proxyApiKey就行),并且别沿用/webui那条豁免。我实测过:4.7.2 时GET /webui直接返回 200——它在 src/server/server.ts#L80 里排在 proxyApiKey 校验(L87-90)之前,而/health、/quota、/v1/models、/mcp都是 401。聊天页本身不含凭据,问题不大;但管理接口要是也这样匿名可访问就危险了; - 文档里补一句"建议放在 nginx 反代 + IP 白名单后面",把暴露风险交给用户显式承担。
功能上其实是把已有的东西接到一个页面上:
POST /control(status/setConfig/startProxy/stopProxy/getLogs/startOAuth/logout)已经齐了,GET /quota也有了,config/edit.ts能保留注释写回 YAML——缺的只是"在 serve 模式可选启动控制监听器"加上一个像webui.txt那样内嵌的单页。另外看到 v4.7.5 给终端面板加了套餐用量卡(进度条 + 重置倒计时),已经升级用上了,做得挺好看——但它仍然需要一个交互式终端才能看到;安卓端那块同理,服务器上拿不到。
如果方向定为 Web 面板,并且你愿意定个契约(路由前缀 + 鉴权方式),前端我可以直接提 PR,
GET /quota+POST /control已经够支撑了。上面 cjjdaq 贴的那张 UI,方向我觉得也可以借鉴。- 部署是 Ubuntu 云服务器 + Docker Compose(
按前面的讨论,把面板整理成一份可实现的接口约定。同时我在自己那台 Ubuntu 服务器上用官方镜像把 serve 模式下的
POST /control全链路跑通了(原型只加了约 170 行 harness + 一个单页,没有改动上游任何文件)。下面第 4、8 节的返回结构都是实测输出,不是设计稿。ZCode Proxy 面板接口约定(草案 v1)
针对 issue #58。目标:在 serve 模式(Docker / 无头服务器)下也有一套可用的可视化管理面,不改动 Android 既有的
ControlCommand协议,只把启动条件放宽 + 前面套一个受鉴权的单页。1. 开关与作用域
环境变量 默认值 说明 ZCODE_PANEL_ENABLEDfalse仅 serve模式生效;false时行为与现在完全一致(不监听、不注册路由、不占端口)ZCODE_PANEL_PORT8090面板 HTTP 端口 ZCODE_PANEL_CONTROL_PORT8091复用 startControlListener()的控制端口ZCODE_PANEL_TOKEN无 必填。未设置时面板不启动并在日志写明原因;不静默降级成无鉴权 - 两个监听器都只绑
127.0.0.1(沿用server.listen(port, "127.0.0.1")+isLoopback()双重校验),远端通过 SSH 隧道访问,不新增公网面。 - 面板与代理进程同生命周期;
serve退出即释放端口。
2. 鉴权
- 面板所有路由(含
GET /)都要Authorization: Bearer <ZCODE_PANEL_TOKEN>或X-Panel-Token: <ZCODE_PANEL_TOKEN>;不通过 →401 {"ok":false,"error":"unauthorized: missing or bad panel token"}。 - 明确不要沿用
/webui的豁免:src/server/server.ts里/webui的返回排在auth.proxyApiKey校验之前(v4.7.2 blob 的 L80 附近),面板若照抄就会变成"公网可匿名打开的管理面"。 - 浏览器端 token 只存
localStorage,不接受?token=查询参数(会进 nginx/access log)。
3. 路由
方法 路径 说明 GET/或/panel返回内嵌单页(无外部 CDN / 无构建步骤, bun build --compile单文件二进制也能带上)POST/api/control请求体 = 既有 ControlCommand,逐字转发给控制监听器(同进程可直接调handleControlRequest,不必绕 HTTP),响应 = 既有ControlResponseGET/api/panel/health可选: {ok:true,version,provider,plan},给反代/探活用关键点:
/api/control不新增协议。 面板只是把浏览器请求转成 Android 壳已经在用的那些命令,因此不需要动ControlCommand/ControlOk/LogBuffer的任何一个类型。4. 命令与返回结构(v4.7.5 实测,来自
src/android/control.ts)type PlanTier = "coding-plan" | "start-plan" ControlCommand = | { cmd: "status" } | { cmd: "startOAuth"; provider: ProviderId } | { cmd: "deliverOAuthCode"; provider: ProviderId; code: string; state: string } | { cmd: "logout" } | { cmd: "setConfig"; provider?: ProviderId; plan?: PlanTier } | { cmd: "startProxy" } | { cmd: "stopProxy" } | { cmd: "getLogs"; since?: number } | { cmd: "quota" } | { cmd: "shutdown" }成功返回(
ok:true):命令 返回 status{state:"running", provider, plan, proxyPort, loggedIn}—loggedIn由loadCredential()决定startProxy{event:"proxyStarted", port}stopProxy{event:"proxyStopped"}setConfig{event:"configUpdated", provider, plan};代理运行中先返回{ok:false,error:"stop_proxy_first"}getLogs{event:"logs", nextSince:number, lines:string[]}—nextSince是增量游标,LogBuffer已实现quota{event:"quota", quota:QuotaSnapshot}(结构与GET /quota完全一致)startOAuth{event:"oauthUrl", authorizeUrl, callbackPort};deliverOAuthCode完成登录logout{event:"loggedOut"}shutdown{event:"shuttingDown"}失败一律
{ok:false, error:string},HTTP 仍是 200(handleControlRequest的行为,面板需要照实透出,不要改成 4xx/5xx)。
未接线时:setConfig→config_update_unavailable、startProxy/stopProxy→proxy_lifecycle_unavailable、quota→quota_unavailable。
传输层错误:非回环 → 403forbidden: non-loopback remote address;路径/方法不对 → 404not_found: ...;body 非法 → 400invalid_json。QuotaSnapshot(src/server/routes-quota.ts):{ provider, serverTime, jwt: {ageHours, issuedAt} | null, balances: [{showName, remainingUnits, totalUnits, usedUnits, unitType?, expiresAt?}], claimablePlans: [...], codingPlan: {level: string|null, limits: [{type, unit?, total?, used?, remaining?, percentage?, nextResetTime?}]} | null, errors: string[] }5. 前端刷新约定
数据 建议间隔 理由 getLogs2s,带 since游标增量日志缓冲 500 行,增量拉取没有成本 status15s 变化少(proxyPort / loggedIn) quota手动按钮,且 ≥60s 去重 src/tui/app.ts:249-259的注释:"billing balance windows; manual refresh only — the billing gateway rate-limits frequent queries, so no polling timer",TUI 已经这么做了6. 边界与容错(实测发现)
loggedIn:false→ 面板显示"未登录 + 去登录",不要显示成错误;startProxy会回not_logged_in。- start-plan 账号的
codingPlan是null:实测这台是体验套餐,monitor 面返回
errors: ["coding: 500 当前用户不存在coding plan"]。面板必须容忍codingPlan === null,并把errors[]展示成一句提示(否则体验套餐用户会看到一片空白或"面板坏了")。 quota无凭据时{ok:false,error:"not logged in (run: zcode-proxy auth login)"}。
7. 复用清单(几乎不需要新代码)
startControlListener()/handleControlRequest/LogBuffer——src/android/control.tscollectQuotaSnapshot()——src/server/routes-quota.ts(GET /quota用的同一个函数)updateConfigYaml()——src/config/edit.ts(保留注释写回,TUI 的p/t就用它)- hook 接线范本 ——
src/index.ts:322-333(Android 模式已经全部接好,serve 模式照抄即可) - 唯一需要放的"权":
src/index.ts:316的
const controlPort = Number(process.env.ZCODE_CONTROL_PORT ?? 0) || 0;
目前只在 android 分支启动;serve 模式在ZCODE_PANEL_ENABLED=true时开同一个监听器即可。
8. 实测(v4.7.5 Docker,Ubuntu 云服务器,2026-10-01)
原型 = 官方镜像里的源码 + 一个约 170 行的 harness + 一个单页;跑在 8090/8091/8099(不碰生产的 8080/8081)。
POST 127.0.0.1:8091/control {"cmd":"status"} → {"ok":true,"state":"running","provider":"zai","plan":"start-plan","proxyPort":0,"loggedIn":true} POST /control {"cmd":"quota"} # 真实上游,两次请求 → {"ok":true,"event":"quota","quota":{"provider":"zai","serverTime":1790846966, "jwt":{"ageHours":53.76,...}, "balances":[{"showName":"GLM-5.3-Flash","remainingUnits":100000000,"totalUnits":100000000,"unitType":"token",...}, {"showName":"GLM-5.3","remainingUnits":3000000,"totalUnits":3000000,...}, {"showName":"GLM-5.3-Flash","remainingUnits":4996505,"totalUnits":5000000,"usedUnits":3495,...}], "claimablePlans":[],"codingPlan":null, "errors":["coding: 500 当前用户不存在coding plan"]}} POST /control {"cmd":"getLogs","since":0} → {"ok":true,"event":"logs","nextSince":3,"lines":[...]} POST /control {"cmd":"getLogs","since":3} # 增量游标 → {"ok":true,"event":"logs","nextSince":7,"lines":["zcode-proxy listening on http://127.0.0.1:8099", "| # | Time | Fmt | Model | Mode | Stat | TTFB | Tok | tok/s | ...", "| #001 | 09:29:38 | OAI | glm-5.3-flash | batch | 200 | 5452ms | 86 | ... | ..."]} POST /control {"cmd":"startProxy"} → {"ok":true,"event":"proxyStarted","port":8099} POST /control {"cmd":"status"} → {...,"proxyPort":8099,...} POST 127.0.0.1:8099/v1/chat/completions (glm-5.3-flash) → 200,usage 1787 POST /control {"cmd":"stopProxy"} → {"ok":true,"event":"proxyStopped"}安全路径:
POST 127.0.0.1:8091/nope → 404 not_found POST 127.0.0.1:8091/control '{oops' → 400 invalid_json POST 127.0.0.1:8090/api/control(无 token) → 401 POST 127.0.0.1:8090/api/control(错 token) → 401 POST 127.0.0.1:8090/api/control(对 token) → 200 + 上面那些响应 GET 127.0.0.1:8090/ → 200,10759 bytes 单页结论:
POST /control的 10 个命令在 serve 模式下原样可用,quota/getLogs/startProxy/stopProxy都在真实凭据上跑通;缺的只有"serve 模式开监听器 + 内嵌单页 + 一个 token"。- 两个监听器都只绑
补一条订正 + 实现进展。
订正:上面第 2 节写的是"面板所有路由(含
GET /)都要 token",实际做下来这条要放宽 —— 浏览器的地址栏导航请求发不出自定义头,面板外壳如果强制 token,用户第一次打开页面只会拿到 401,体验是坏的。所以最终口径是:GET /(/panel)与GET /healthz免 token:只返回静态外壳与{"ok":true},不含任何账号、额度、日志数据,且只绑127.0.0.1(远端只能经 SSH 隧道访问)。POST /api/control必须带Authorization: Bearer <token>或X-Panel-Token: <token>,否则 401,且不会触达控制面。
其余按上面草案不变:默认关闭、必须有
ZCODE_PANEL_TOKEN才启动(空 token 直接不启动并写明原因)、/v1/*与proxyApiKey逻辑不动、不沿用/webui那条鉴权豁免。实现已提 PR:#59 feat(serve): 可选内置 Web 面板 —— 无头/Docker 部署也能看额度、看日志、切套餐
- 新增
src/server/panel.ts+src/server/panel-page.txt+src/server/panel.test.ts(17 个用例),src/index.ts的serve()接线;Android 路径、/v1/*、ControlCommand协议都未改。 - 额度是手动刷新(billing 网关会限流,与 TUI 同一条理由,不加轮询定时器);日志用
getLogs的since游标做增量。 - 本机
bun x tsc --noEmit通过、bun test928 pass / 1 fail(唯一失败是 Windows 本机既有的 captcha worker 用例);VPS 上用 Docker 做了端到端:状态、真实额度快照、增量日志、stop_proxy_first、启停代理、/v1/chat/completions200、docker stop0.15s 优雅退出,不设ZCODE_PANEL_ENABLED时行为与上游一致。
- added 2 commits that reference this issue
on Oct 1, 2026

背景
我在云服务器的 Docker 里跑 zcode-proxy(
docker compose,镜像ghcr.io/tridefender/zcode-proxy:latest,容器CMD就是serve),日常用得很顺,先感谢作者的维护 🙏不过管理面只有 TUI 和 CLI,而服务器上没有 TTY,于是这些事都得进容器敲命令或改 YAML 重启:
docker exec zcode-proxy bun run src/index.ts quotaconfig.yaml的provider/plan再重启容器(TUI 里按 t 能写回,但容器里没有面板)docker logs -fclaim/asyncauth login zai("链接在任何设备打开即可"这点很好,无头可用,但还是要敲命令)/webui只能聊天(README 里也写明是 make-shift),没有任何管理能力。期望
希望有一个内嵌的 Web 可视化管理面板(例如
GET /admin,或在配置项 / 环境变量里显式开启),浏览器打开即可:Logged in: zai/bigmodel、当前 provider / plan、代理 running / stopped、监听端口、进程运行时长GET /quota,把 5 小时 / 周窗口的剩余·总量·重置时间,以及体验套餐的积分桶(剩余 / 总额 / 到期)展示出来;给个手动刷新按钮就行(避免上游限速)server.port、provider、plan、claim.enabled、async.enabled、auth.proxyApiKey、日志格式),保存后能热加载的项直接生效,需要重启的项明确提示GET /mcp的 MCP 列表可视化也许能省很多事的实现建议
服务端能力看起来已经齐了,缺的主要是"把控制面在桌面 / 服务端模式下打开 + 一个静态单页":
src/android/control.ts已经有一个完整的控制协议POST /control(status/startOAuth/deliverOAuthCode/logout/setConfig{provider,plan}/startProxy/stopProxy/getLogs{since}/shutdown),还带日志环形缓冲LogBuffer;目前只在 Android 模式下由ZCODE_CONTROL_PORT开启(src/index.ts:316附近)。src/config/edit.ts已经能在保留注释的前提下写回config.yaml。src/server/routes-quota.ts就是GET /quota的实现。所以如果能做成 控制监听器在
serve模式下也可选开启(默认关闭,配置项或ZCODE_WEB_ADMIN=1)+ 一个像webui.txt那样内嵌的单页前端,感觉不用动核心转发逻辑。前端部分如果有人愿意做,我可以帮忙出一版。安全
这个面板必须鉴权:建议复用
auth.proxyApiKey(或单独的 admin token),并延续control.ts里isLoopback()那种"默认只监听127.0.0.1、非回环请求直接 403"的思路。顺带一个观察(不是 bug,只是提醒别踩):
src/server/server.ts:80中/webui的响应排在鉴权检查(87–90 行)之前,所以设了ZCODE_PROXY_API_KEY时页面本身是匿名可访问的——页面里的/v1/*调用仍然会 401,因此危害有限(只是能被匿名探到),但如果以后把管理能力挂进/webui,建议不要沿用这条豁免。环境
--cli serve后台静默运行时同样没有 TUI,应该也会碰到一样的问题再次感谢,这个项目帮我省了很多事 🙏