Skip to content

[Feature] 加一个 Web 可视化管理面板:无头 / 服务器 Docker 部署时也能看额度、切套餐、看日志 #58

Description

@Ma6302

背景

我在云服务器的 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 quota
切服务商 / 套餐 改 config.yaml 的 provider / plan 再重启容器(TUI 里按 t 能写回,但容器里没有面板)
看日志 docker logs -f
开关 claim / async 改 YAML 再重启
登录 / 换号 进容器跑 auth login zai("链接在任何设备打开即可"这点很好,无头可用,但还是要敲命令)

/webui 只能聊天(README 里也写明是 make-shift),没有任何管理能力。

期望

希望有一个内嵌的 Web 可视化管理面板(例如 GET /admin,或在配置项 / 环境变量里显式开启),浏览器打开即可:

  1. 状态总览:版本、Logged in: zai/bigmodel、当前 provider / plan、代理 running / stopped、监听端口、进程运行时长
  2. 额度:复用 GET /quota,把 5 小时 / 周窗口的剩余·总量·重置时间,以及体验套餐的积分桶(剩余 / 总额 / 到期)展示出来;给个手动刷新按钮就行(避免上游限速)
  3. 配置:可视化编辑常用项(server.port、provider、plan、claim.enabled、async.enabled、auth.proxyApiKey、日志格式),保存后能热加载的项直接生效,需要重启的项明确提示
  4. 日志:实时滚动(SSE 或 WebSocket 都行)+ 按关键字 / 状态码过滤;再加一个简单的请求统计(状态码分布、模型分布、TTFT、token)
  5. 登录 / 登出:面板里直接给出授权链接(无头场景最有用),保留现在的粘贴模式
  6. 启停代理,以及 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,建议不要沿用这条豁免。

环境

  • zcode-proxy 4.7.2(Docker,Ubuntu 云服务器 + docker compose + nginx 前置)
  • 桌面端用 --cli serve 后台静默运行时同样没有 TUI,应该也会碰到一样的问题

再次感谢,这个项目帮我省了很多事 🙏

Activity

  1. TriDefender commented on Sep 30, 2026

    @TriDefender
    Owner

    可以提个issue,但是需要注意非回环请求直接 403在VPS部署上可能会出问题,可以考虑ssh进去然后用tui?

  2. cjjdaq commented on Sep 30, 2026

    @cjjdaq

    可以提个issue,但是需要注意非回环请求直接 403在VPS部署上可能会出问题,可以考虑ssh进去然后用tui?

    Image希望集成个类似的ui,这是zcode送的鸡蛋在本项目基础上搓的。

  3. Ma6302 commented on Oct 1, 2026

    @Ma6302
    ContributorAuthor

    感谢回复,也谢谢这个项目。

    我这边的场景大概正好是你说的 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,方向我觉得也可以借鉴。

  4. Ma6302 commented on Oct 1, 2026

    @Ma6302
    ContributorAuthor

    按前面的讨论,把面板整理成一份可实现的接口约定。同时我在自己那台 Ubuntu 服务器上用官方镜像把 serve 模式下的 POST /control 全链路跑通了(原型只加了约 170 行 harness + 一个单页,没有改动上游任何文件)。下面第 4、8 节的返回结构都是实测输出,不是设计稿。

    ZCode Proxy 面板接口约定(草案 v1)

    针对 issue #58。目标:在 serve 模式(Docker / 无头服务器)下也有一套可用的可视化管理面,不改动 Android 既有的 ControlCommand 协议,只把启动条件放宽 + 前面套一个受鉴权的单页。

    1. 开关与作用域

    环境变量 默认值 说明
    ZCODE_PANEL_ENABLED false 仅 serve 模式生效;false 时行为与现在完全一致(不监听、不注册路由、不占端口)
    ZCODE_PANEL_PORT 8090 面板 HTTP 端口
    ZCODE_PANEL_CONTROL_PORT 8091 复用 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),响应 = 既有 ControlResponse
    GET /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。
    传输层错误:非回环 → 403 forbidden: non-loopback remote address;路径/方法不对 → 404 not_found: ...;body 非法 → 400 invalid_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. 前端刷新约定

    数据 建议间隔 理由
    getLogs 2s,带 since 游标增量 日志缓冲 500 行,增量拉取没有成本
    status 15s 变化少(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.ts
    • collectQuotaSnapshot() —— 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"。

  5. Ma6302 commented on Oct 1, 2026

    @Ma6302
    ContributorAuthor

    补一条订正 + 实现进展。

    订正:上面第 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 test 928 pass / 1 fail(唯一失败是 Windows 本机既有的 captcha worker 用例);VPS 上用 Docker 做了端到端:状态、真实额度快照、增量日志、stop_proxy_first、启停代理、/v1/chat/completions 200、docker stop 0.15s 优雅退出,不设 ZCODE_PANEL_ENABLED 时行为与上游一致。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions