feat: align online update flow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-01 15:54:43 +08:00
co-authored by Codex
parent ea0f3af9d7
commit 6c7dace082
18 changed files with 2329 additions and 1753 deletions
+10 -27
View File
@@ -12,7 +12,6 @@ permissions:
env:
REGISTRY: ghcr.io
IMAGE_NAME: zyycn/codex-proxy-rs
UPDATER_IMAGE_NAME: zyycn/codex-proxy-rs-updater
jobs:
release:
@@ -32,6 +31,10 @@ jobs:
echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT"
echo "git_sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Write VERSION file
shell: bash
run: printf '%s\n' '${{ steps.version.outputs.version }}' > VERSION
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -47,7 +50,7 @@ jobs:
run: pnpm --dir web build
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.95
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: Swatinem/rust-cache@v2
@@ -57,17 +60,17 @@ jobs:
CPR_VERSION: ${{ steps.version.outputs.version }}
CPR_GIT_SHA: ${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME: ${{ steps.version.outputs.build_time }}
run: cargo build --release --locked --bins
CPR_BUILD_TYPE: release
run: cargo build --release --locked --bin codex-proxy-rs
- name: Package release assets
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
PACKAGE_DIR="dist-package/codex-proxy-rs"
mkdir -p "${PACKAGE_DIR}"
mkdir -p "${PACKAGE_DIR}/web"
cp target/release/codex-proxy-rs "${PACKAGE_DIR}/"
cp target/release/codex-proxy-rs-updater "${PACKAGE_DIR}/"
cp -r web/dist "${PACKAGE_DIR}/web-dist"
cp -r web/dist "${PACKAGE_DIR}/web/dist"
tar -czf "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" -C dist-package codex-proxy-rs
sha256sum "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" > checksums.txt
@@ -95,6 +98,7 @@ jobs:
CPR_VERSION=${{ steps.version.outputs.version }}
CPR_GIT_SHA=${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME=${{ steps.version.outputs.build_time }}
CPR_BUILD_TYPE=release
labels: |
org.opencontainers.image.title=Codex Proxy RS
org.opencontainers.image.version=${{ steps.version.outputs.version }}
@@ -105,27 +109,6 @@ jobs:
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
- name: Build and push updater image
uses: docker/build-push-action@v6
with:
context: .
target: updater-runtime
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CPR_VERSION=${{ steps.version.outputs.version }}
CPR_GIT_SHA=${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME=${{ steps.version.outputs.build_time }}
labels: |
org.opencontainers.image.title=Codex Proxy RS Updater
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.revision=${{ steps.version.outputs.git_sha }}
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
tags: |
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:sha-${{ github.sha }}
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
Generated
+33
View File
@@ -325,6 +325,7 @@ dependencies = [
"chrono",
"config",
"dirs",
"flate2",
"futures",
"hex",
"indexmap",
@@ -342,6 +343,7 @@ dependencies = [
"sha2 0.11.0",
"sqlx",
"subtle",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -802,6 +804,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2653,6 +2665,17 @@ dependencies = [
"syn",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3646,6 +3669,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yaml-rust2"
version = "0.11.0"
+2
View File
@@ -16,6 +16,7 @@ bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde", "clock"] }
config = "0.15.23"
dirs = "5.0.1"
flate2 = "1.1.9"
futures = "0.3.32"
hex = "0.4.3"
indexmap = { version = "2.0", features = ["serde"] }
@@ -32,6 +33,7 @@ serde_json = "1.0.150"
sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
subtle = "2.6.1"
tar = "0.4.44"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
+8 -35
View File
@@ -11,60 +11,33 @@ RUN pnpm build
FROM rust:1.95-bookworm AS rust-builder
WORKDIR /app
ARG CPR_VERSION=dev
ARG CPR_VERSION=
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
ARG CPR_BUILD_TYPE=release
ENV CPR_VERSION=${CPR_VERSION}
ENV CPR_GIT_SHA=${CPR_GIT_SHA}
ENV CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
ENV CPR_BUILD_TYPE=${CPR_BUILD_TYPE}
COPY Cargo.toml Cargo.lock build.rs VERSION ./
COPY src ./src
COPY --from=web-builder /app/web/dist ./web/dist
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --locked --bins \
&& cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs \
&& cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater \
&& strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater
FROM debian:bookworm-slim AS updater-runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater
EXPOSE 8090
CMD ["codex-proxy-rs-updater"]
cargo build --release --locked --bin codex-proxy-rs \
&& cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs
FROM debian:bookworm-slim AS runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV CPR_DEPLOYMENT_MODE=docker \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
ENV CPR_DEPLOYMENT_MODE=docker
COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY --from=web-builder /app/web/dist ./web/dist
EXPOSE 8080
+1
View File
@@ -0,0 +1 @@
0.1.0
+41
View File
@@ -0,0 +1,41 @@
use std::{env, fs};
fn main() {
println!("cargo:rerun-if-changed=VERSION");
println!("cargo:rerun-if-env-changed=CPR_VERSION");
println!("cargo:rerun-if-env-changed=CPR_GIT_SHA");
println!("cargo:rerun-if-env-changed=CPR_BUILD_TIME");
println!("cargo:rerun-if-env-changed=CPR_BUILD_TYPE");
let version = version_env_value("CPR_VERSION")
.or_else(version_file)
.unwrap_or_else(|| {
env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0-dev".to_string())
});
let git_sha = env_value("CPR_GIT_SHA").unwrap_or_else(|| "unknown".to_string());
let build_time = env_value("CPR_BUILD_TIME").unwrap_or_else(|| "unknown".to_string());
let build_type = env_value("CPR_BUILD_TYPE").unwrap_or_else(|| "source".to_string());
println!("cargo:rustc-env=CPR_VERSION={version}");
println!("cargo:rustc-env=CPR_GIT_SHA={git_sha}");
println!("cargo:rustc-env=CPR_BUILD_TIME={build_time}");
println!("cargo:rustc-env=CPR_BUILD_TYPE={build_type}");
}
fn env_value(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn version_env_value(key: &str) -> Option<String> {
env_value(key).map(|value| value.trim_start_matches('v').to_string())
}
fn version_file() -> Option<String> {
fs::read_to_string("VERSION")
.ok()
.map(|value| value.trim().trim_start_matches('v').to_string())
.filter(|value| !value.is_empty())
}
+4 -24
View File
@@ -13,27 +13,7 @@ services:
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_UPDATE_CHANNEL: stable
CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_IMAGE_TAG: latest
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
depends_on:
- codex-proxy-rs-updater
codex-proxy-rs-updater:
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
container_name: codex-proxy-rs-updater
restart: unless-stopped
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_COMPOSE_SERVICE: codex-proxy-rs
CPR_DOCKER_COMPOSE_BIN: docker-compose
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
CPR_UPDATE_STATE_FILE: /app/data/update-state.json
CPR_UPDATE_LOCK_FILE: /app/data/update.lock
CPR_WEB_DIST_DIR: /app/web/dist
CPR_ENABLE_SELF_RESTART: "true"
-743
View File
@@ -1,743 +0,0 @@
# 在线更新方案
本文档定义 Codex Proxy RS 的应用包在线更新方案。目标是支持管理端检查更新,并在管理员点击按钮后完成一键更新。
## 目标
- 管理端可以显示当前版本、最新版本、更新说明和部署模式。
- 管理端可以手动检查更新。
- 管理端可以触发一键更新。
- Docker 部署是主要场景。
- 裸机二进制部署可以作为后续兼容场景。
- 更新过程必须保留数据卷、配置和日志,不覆盖运行数据。
## 非目标
- 不在运行中的 Rust 进程里热加载新代码。
- 不在 Docker 容器内部直接替换当前容器的二进制作为主方案。
- 不默认把宿主机 Docker socket 挂进主应用容器。
- 不在没有迁移机制前执行高风险数据库结构变更。
## 部署模式
### Docker 模式
Docker 模式下,一键更新应更新镜像并重建容器:
1. 管理端调用后端更新接口。
2. 后端完成管理员鉴权和并发锁控制。
3. 后端调用 updater。
4. updater 在宿主机或 sidecar 中执行镜像更新。
5. updater 拉取新镜像。
6. updater 重新创建 `codex-proxy-rs` 服务。
7. 数据卷继续挂载,SQLite、日志和配置保持不变。
推荐结构:
```text
browser
-> codex-proxy-rs admin api
-> updater sidecar / host updater
-> docker pull
-> docker compose up -d codex-proxy-rs
```
主应用容器不应直接拥有 Docker daemon 权限。updater 可以是一个独立容器,也可以是宿主机 systemd 服务。updater 只暴露给同一 Docker network 或 `127.0.0.1`,并使用共享 token 鉴权。
项目只保留生产用 `docker-compose.yml`,不维护本地 Docker 开发配置。compose 必须使用远端镜像,不能使用 `build: .`,否则管理端只能检查更新,无法安全地完成一键更新。
```yaml
services:
codex-proxy-rs:
image: ghcr.io/zyycn/codex-proxy-rs:latest
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./config.yaml:/app/config.yaml:ro
environment:
CPR_DEPLOYMENT_MODE: docker
CPR_VERSION: 0.1.0
CPR_UPDATE_CHANNEL: stable
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
```
### 二进制模式
二进制模式可以采用 Sub2API 类似方案:
1. 请求 GitHub Releases latest。
2. 匹配当前 OS/arch 的压缩包。
3. 下载 release asset。
4. 校验 `checksums.txt`。
5. 解压新二进制和 `web/dist`。
6. 备份旧二进制和旧 `web/dist`。
7. 原子替换。
8. 返回 `needRestart: true`。
9. 管理端触发重启,进程退出后由 systemd/supervisor 拉起。
二进制模式不是当前 Docker 部署的主路径,但可以复用检查更新、版本展示、更新锁和前端 UI。
## 版本与发布产物
发布时应同时产出 Docker 镜像和 release 元数据:
- Git tag:`v0.1.0`
- Docker image:`ghcr.io/zyycn/codex-proxy-rs:0.1.0`
- Docker image:`ghcr.io/zyycn/codex-proxy-rs:latest`
- Release archive:`codex-proxy-rs_0.1.0_linux_x86_64.tar.gz`
- Checksum:`checksums.txt`
后端应在构建时注入版本信息:
- `CPR_VERSION`
- `CPR_GIT_SHA`
- `CPR_BUILD_TIME`
- `CPR_DEPLOYMENT_MODE`
- `CPR_IMAGE_REPOSITORY`
- `CPR_IMAGE_TAG`
Docker 模式下,检查更新可以优先使用 GitHub Releases 的 tag 做语义版本比较;真正更新时由 updater 拉取指定镜像 tag 或 digest。
## 版本发布流程
版本发布以 Git tag 为唯一入口,tag、Docker 镜像、GitHub Release 和后端版本信息必须保持一致。
### 版本号规则
使用语义化版本:
```text
MAJOR.MINOR.PATCH
```
示例:
```text
0.2.0
0.2.1
1.0.0
```
Git tag 使用 `v` 前缀:
```text
v0.2.0
```
代码中的版本来源:
- `Cargo.toml` 的 `[package].version` 是本地构建默认版本。
- `web/package.json` 的 `version` 应与后端版本保持一致。
- CI 发布时以 Git tag 为准,并通过 build args 注入 `CPR_VERSION`。
发布前必须同步:
```text
Cargo.toml version = "0.2.0"
web/package.json "version": "0.2.0"
Git tag v0.2.0
Docker image tag 0.2.0
GitHub Release tag v0.2.0
```
如果后续希望避免手动同步,可以增加 `scripts/release-version`,统一更新 `Cargo.toml`、`Cargo.lock` 和 `web/package.json`。
### 发布通道
推荐先只维护 `stable` 通道:
```text
latest -> 最新 stable 版本
0.2.0 -> 固定版本
sha-* -> commit 固定构建
```
后续需要预发布时再增加:
```text
next
beta
rc
```
管理端检查更新默认只看 stable release。带 `prerelease: true` 的 GitHub Release 不应推送给普通更新检查,除非配置了 `CPR_UPDATE_CHANNEL=beta`。
### 发布前检查
发布前必须完成:
```bash
pnpm --dir web build
cargo fmt --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --test main
```
发布前还应确认:
- `Cargo.toml` release profile 已关闭 debug symbols。
- Dockerfile 不复制本地 `config.yaml`。
- Dockerfile 在容器内构建 `web/dist`。
- `.dockerignore` 排除了 `target`、`node_modules`、数据库和日志。
- `docker-compose.yml` 使用远端 `image`,不是 `build: .`。
- release note 写明数据库迁移风险。
### 发布步骤
标准发布步骤:
```bash
VERSION=0.2.0
# 1. 更新版本号
# Cargo.toml: version = "0.2.0"
# web/package.json: "version": "0.2.0"
# 2. 更新 Cargo.lock 中的本包版本
cargo check --locked
# 3. 提交版本变更
git add Cargo.toml Cargo.lock web/package.json
git commit -m "chore: release v${VERSION}"
# 4. 创建 tag
git tag "v${VERSION}"
git push origin HEAD
git push origin "v${VERSION}"
```
tag 推送后由 `.github/workflows/release.yml` 自动构建并发布:
1. 构建前端。
2. 构建 Rust release。
3. 生成二进制压缩包。
4. 生成 `checksums.txt`。
5. 构建并推送 Docker 多架构镜像。
6. 构建并推送 updater 多架构镜像。
7. 创建 GitHub Release。
8. 上传 release assets。
### Release Note 格式
GitHub Release body 建议固定结构,方便管理端展示:
```markdown
## Highlights
- ...
## Changes
- ...
## Upgrade Notes
- ...
## Database
- Migration: none
- Backup required: no
```
如果需要用户备份,必须写明:
```markdown
## Database
- Migration: required
- Backup required: yes
```
后端检查更新时可以从 release body 或额外 manifest 中解析 `requiresBackup`,并在管理端一键更新前要求确认。
### 发布后验证
发布完成后验证:
```bash
docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0
docker run --rm ghcr.io/zyycn/codex-proxy-rs:0.2.0 --version
```
如果暂时没有 `--version` 命令,则使用管理端接口验证:
```bash
curl http://127.0.0.1:8080/api/admin/system/version
```
还需要验证:
- GitHub Release latest 指向新版本。
- `latest` 镜像 tag 指向新版本。
- 固定版本 tag 可以拉取。
- 管理端检查更新能看到新版本。
- Docker 一键更新能从旧版本升级到新版本。
## Docker 构建方案
Docker 镜像应由 CI 直接完成前端构建、后端构建和 runtime 镜像组装。发布镜像不能依赖本地已经存在的 `web/dist`,否则 CI、开发机和发布机之间容易产出不一致。
推荐 Dockerfile 结构:
```dockerfile
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim AS web-builder
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY web .
RUN pnpm build
FROM rust:1.95-bookworm AS rust-builder
WORKDIR /app
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
ENV CPR_VERSION=${CPR_VERSION}
ENV CPR_GIT_SHA=${CPR_GIT_SHA}
ENV CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY src ./src
COPY --from=web-builder /app/web/dist ./web/dist
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --locked --bins && \
cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs && \
cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater && \
strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater
FROM debian:bookworm-slim AS updater-runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater
EXPOSE 8090
CMD ["codex-proxy-rs-updater"]
FROM debian:bookworm-slim AS runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV CPR_DEPLOYMENT_MODE=docker \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
EXPOSE 8080
CMD ["codex-proxy-rs"]
```
构建上下文必须增加 `.dockerignore`,避免把本地编译产物、数据库、日志和依赖目录传入 Docker daemon:
```gitignore
.git
target
node_modules
web/node_modules
.runtime
data
logs
config.yaml
.env
*.sqlite
*.sqlite-*
*.db
```
发布镜像不应复制本地 `config.yaml`。配置文件应由 compose volume 挂载,避免镜像更新时覆盖用户配置:
```yaml
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./data:/app/data
- ./logs:/app/logs
```
后端 release profile 应关闭 debug symbols,并 strip 符号,避免 Docker 应用层过大:
```toml
[profile.release]
debug = false
strip = "symbols"
lto = "thin"
codegen-units = 1
panic = "abort"
```
如果后续仍需要 debug symbols,应把 symbols 作为单独 CI artifact 上传,不放进 runtime 镜像。
## Docker 发布方案
发布以 Git tag 为入口,例如 `v0.2.0`。CI 应完成以下产物:
1. 多架构 Docker 镜像。
2. GitHub Release。
3. 二进制 tar.gz 包。
4. `checksums.txt`。
推荐镜像标签:
```text
ghcr.io/zyycn/codex-proxy-rs:0.2.0
ghcr.io/zyycn/codex-proxy-rs:latest
ghcr.io/zyycn/codex-proxy-rs:sha-<git-sha>
```
推荐多架构平台:
```text
linux/amd64
linux/arm64
```
CI 发布命令形态:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg CPR_VERSION="${VERSION}" \
--build-arg CPR_GIT_SHA="${GIT_SHA}" \
--build-arg CPR_BUILD_TIME="${BUILD_TIME}" \
--label org.opencontainers.image.title="Codex Proxy RS" \
--label org.opencontainers.image.version="${VERSION}" \
--label org.opencontainers.image.revision="${GIT_SHA}" \
--label org.opencontainers.image.source="https://github.com/zyycn/codex-proxy-rs" \
-t "ghcr.io/zyycn/codex-proxy-rs:${VERSION}" \
-t "ghcr.io/zyycn/codex-proxy-rs:latest" \
--push \
.
```
updater 镜像使用同一个 Dockerfile 的 `updater-runtime` target:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--target updater-runtime \
--build-arg CPR_VERSION="${VERSION}" \
--build-arg CPR_GIT_SHA="${GIT_SHA}" \
--build-arg CPR_BUILD_TIME="${BUILD_TIME}" \
-t "ghcr.io/zyycn/codex-proxy-rs-updater:${VERSION}" \
-t "ghcr.io/zyycn/codex-proxy-rs-updater:latest" \
--push \
.
```
GitHub Release 的 tag 是检查更新的权威来源。Release body 用于管理端展示更新说明。Docker 更新实际拉取的镜像应来自 release tag 对应的版本,不从用户输入拼接任意镜像地址。
生产 compose 使用仓库根目录的 `docker-compose.yml`。主服务镜像由 `.env` 的 `CPR_IMAGE` 控制,updater 更新时会改写这个变量;`.env` 是本机部署状态文件,不提交到仓库。
```dotenv
CPR_UPDATER_TOKEN=change-me
CPR_DEPLOY_DIR=/opt/codex-proxy-rs
CPR_IMAGE=ghcr.io/zyycn/codex-proxy-rs:latest
```
```yaml
services:
codex-proxy-rs:
image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest}
container_name: codex-proxy-rs
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./data:/app/data
- ./logs:/app/logs
environment:
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_IMAGE_TAG: latest
CPR_UPDATE_CHANNEL: stable
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
codex-proxy-rs-updater:
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
restart: unless-stopped
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_COMPOSE_SERVICE: codex-proxy-rs
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
```
`CPR_DEPLOY_DIR` 必须是宿主机上的绝对路径,并且 updater 容器内也挂载到同一个绝对路径。这样 updater 通过 Docker socket 调用 compose 时,`./config.yaml`、`./data`、`./logs` 等 bind mount 路径会被 host Docker 正确解析。
## Docker 在线一键更新流程
Docker 一键更新分为检查、触发、执行、恢复四个阶段。
### 1. 检查更新
后端调用 GitHub Releases latest:
```text
GET https://api.github.com/repos/zyycn/codex-proxy-rs/releases/latest
```
后端比较:
- 当前版本:构建时注入的 `CPR_VERSION`。
- 最新版本:GitHub Release tag。
- 当前部署模式:`CPR_DEPLOYMENT_MODE=docker`。
- 当前镜像仓库:`CPR_IMAGE_REPOSITORY`。
- updater 可用性:`CPR_UPDATER_URL` 和 `CPR_UPDATER_TOKEN` 是否存在。
只有满足以下条件时,`updateSupported` 才返回 `true`:
- 当前部署模式是 `docker`。
- 当前 compose 使用远端 `image`,不是 `build: .`。
- 已配置 updater URL 和 token。
- release tag 能映射到允许的镜像仓库。
### 2. 触发更新
管理员点击一键更新后,主应用后端:
1. 校验管理员会话或管理员 API Key。
2. 获取全局系统更新锁。
3. 重新检查 latest release,避免使用过期缓存。
4. 生成目标镜像,例如 `ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
5. 调用 updater 的 `/update`。
6. 立即返回 `operationId` 和 `needReconnect: true`。
主应用后端不能接收前端传入的任意镜像地址。前端最多传 `targetVersion`,后端根据 release 信息和仓库白名单推导目标镜像。
### 3. 执行更新
updater 收到请求后:
1. 校验 bearer token。
2. 校验目标镜像仓库必须等于 `CPR_ALLOWED_IMAGE_REPOSITORY`。
3. 立即返回 `update started`,让主应用可以先响应前端。
4. 后台执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
5. 将 `.env` 中的 `CPR_IMAGE` 改成目标镜像。
6. 执行 `docker compose up -d codex-proxy-rs`。
7. 记录当前和上一个镜像,供回滚使用。
updater 只允许执行固定动作,不提供通用 shell 执行能力。
### 4. 服务恢复
Docker 重建主应用容器时,前端连接会中断。管理端 UI 应:
1. 显示更新中。
2. 每 2-3 秒轮询 `/api/admin/system/version`。
3. 服务恢复后比较返回版本。
4. 版本等于目标版本时显示更新成功。
5. 超时后提示用户查看容器日志。
推荐超时时间:120 秒。
## 更新失败与回滚
Docker 模式下的回滚以镜像标签或 digest 为单位:
1. 更新前记录当前镜像 digest。
2. 更新失败时 updater 可以重新部署旧 digest。
3. 如果服务已启动但健康检查失败,也回滚到旧 digest。
4. 回滚只替换容器镜像,不回滚 SQLite 数据。
因此涉及数据库结构变更的版本必须谨慎发布。若 release 声明需要备份,管理端一键更新前应显示确认状态,后端可要求请求带上 `confirmBackup: true`。
## 后端 API
建议新增管理端接口:
```http
GET /api/admin/system/version
GET /api/admin/system/check-updates?force=true
POST /api/admin/system/update
POST /api/admin/system/rollback
POST /api/admin/system/restart
```
所有接口必须要求管理员会话或管理员 API Key。
`GET /api/admin/system/version` 返回:
```json
{
"version": "0.1.0",
"gitSha": "abc1234",
"buildTime": "2026-07-01T00:00:00Z",
"deploymentMode": "docker",
"image": "ghcr.io/zyycn/codex-proxy-rs:0.1.0"
}
```
`GET /api/admin/system/check-updates` 返回:
```json
{
"currentVersion": "0.1.0",
"latestVersion": "0.2.0",
"hasUpdate": true,
"deploymentMode": "docker",
"releaseUrl": "https://github.com/zyycn/codex-proxy-rs/releases/tag/v0.2.0",
"notes": "...",
"cached": false,
"updateSupported": true,
"unsupportedReason": null
}
```
`POST /api/admin/system/update` 在 Docker 模式下返回:
```json
{
"operationId": "sysop-...",
"deploymentMode": "docker",
"message": "Update started",
"needReconnect": true
}
```
如果当前部署不支持一键更新,应返回明确原因:
```json
{
"updateSupported": false,
"unsupportedReason": "Docker one-click update requires CPR_UPDATER_URL and a remote image deployment"
}
```
## Updater 服务
Docker 一键更新的关键是 updater。updater 负责真正操作 Docker:
```http
POST /update
Authorization: Bearer <CPR_UPDATER_TOKEN>
Content-Type: application/json
```
请求:
```json
{
"service": "codex-proxy-rs",
"image": "ghcr.io/zyycn/codex-proxy-rs:0.2.0",
"composeProject": "codex-proxy-rs"
}
```
updater 行为:
1. 校验 token。
2. 校验 image 仓库白名单。
3. 执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
4. 将 `CPR_COMPOSE_ENV_FILE` 中的 `CPR_COMPOSE_IMAGE_ENV` 更新为目标镜像。
5. 执行 `docker compose up -d codex-proxy-rs` 或 `docker-compose up -d codex-proxy-rs`。
6. 记录当前和上一个镜像,用于回滚。
updater 不应暴露到公网。若使用 sidecar 容器,建议挂载 Docker socket 到 updater,而不是主应用:
```yaml
services:
codex-proxy-rs-updater:
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
networks:
- default
```
如果后续不想维护自研 updater,可以评估 Watchtower 的 HTTP API 模式,但仍需要后端把“检查更新、按钮触发、操作结果展示”封装到管理端。
## 安全约束
- 所有更新接口必须走管理员鉴权。
- 更新任务必须有全局锁,避免并发更新。
- GitHub release 下载 URL 必须限制为可信 HTTPS host。
- Docker image 仓库必须白名单校验。
- 下载包必须限制大小。
- 二进制包必须校验 checksum。
- Docker 更新必须保留 `data`、`logs`、`config.yaml` 挂载。
- 不允许通过管理端传入任意 shell 命令。
- updater token 只能通过环境变量或密钥文件配置,不能写入前端。
## 前端交互
管理端系统页建议包含:
- 当前版本。
- 部署模式。
- 最新版本。
- 更新说明。
- 检查更新按钮。
- 一键更新按钮。
- 更新中状态。
- 更新后重连提示。
- 不支持一键更新时的明确原因。
Docker 更新会导致容器重建,前端连接会短暂断开。UI 应在触发更新后轮询 `/api/admin/system/version`,服务恢复后提示已更新。
## 数据库迁移约束
当前项目启动时执行 `schema.sql`,主要依赖 `create table if not exists`。在加入在线更新前,建议补充迁移版本表,例如:
```sql
create table if not exists schema_migrations (
version integer primary key,
applied_at text not null
);
```
发布新版本时:
- 向前兼容的字段新增可以自动迁移。
- 删除字段、重命名字段、不可逆数据迁移需要显式 release note。
- 一键更新前可以在检查结果里标注 `requiresBackup: true`。
## 推荐落地顺序
1. 建立 GitHub Release 和 GHCR 镜像发布流程。
2. 为后端注入版本、commit、构建时间和部署模式。
3. 增加系统版本与检查更新 API。
4. 增加管理端系统更新页。
5. 增加 Docker updater sidecar。
6. 接入一键更新按钮。
7. 补充数据库迁移机制。
8. 再考虑二进制模式的原子替换和 rollback。
+7
View File
@@ -7,6 +7,7 @@ use axum::{
Json,
};
use serde::Serialize;
use std::fmt;
pub(crate) const ADMIN_OK_CODE: u32 = 200;
pub(crate) const ADMIN_OK_MESSAGE: &str = "OK";
@@ -148,6 +149,12 @@ impl IntoResponse for AdminError {
}
}
impl fmt::Display for AdminError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
/// 管理端响应信封。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
+6 -1
View File
@@ -27,7 +27,10 @@ use super::{
admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings,
update_settings,
},
system::routes::{check_updates, perform_update, restart, rollback, version},
system::routes::{
check_updates, perform_update, restart, rollback, update_event_stream, update_status,
version,
},
};
/// 构造管理端路由。
@@ -47,7 +50,9 @@ pub fn router() -> Router<AppState> {
)
.route("/api/admin/system/version", get(version))
.route("/api/admin/system/check-updates", get(check_updates))
.route("/api/admin/system/update-events", get(update_event_stream))
.route("/api/admin/system/update", post(perform_update))
.route("/api/admin/system/update-status", get(update_status))
.route("/api/admin/system/rollback", post(rollback))
.route("/api/admin/system/restart", post(restart))
.route("/api/admin/dashboard/summary", get(dashboard_summary))
+1153 -215
View File
File diff suppressed because it is too large Load Diff
-461
View File
@@ -1,461 +0,0 @@
use std::{
collections::BTreeMap,
env, fs, io,
net::SocketAddr,
path::{Path, PathBuf},
process::Command,
};
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = UpdaterConfig::from_env()?;
let bind = config.bind;
let app = Router::new()
.route("/update", post(update))
.route("/rollback", post(rollback))
.with_state(config);
let listener = tokio::net::TcpListener::bind(bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
#[derive(Debug, Clone)]
struct UpdaterConfig {
bind: SocketAddr,
token: String,
allowed_image_repository: String,
docker_bin: String,
docker_compose_bin: Option<String>,
compose_file: PathBuf,
compose_project: Option<String>,
compose_env_file: Option<PathBuf>,
compose_image_env: String,
state_file: PathBuf,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateRequest {
service: String,
image: String,
compose_project: Option<String>,
target_version: Option<String>,
confirm_backup: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RollbackRequest {
service: String,
compose_project: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct UpdaterState {
previous_image: Option<String>,
current_image: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdaterResponse {
message: String,
service: String,
image: Option<String>,
}
#[derive(Debug)]
struct UpdaterError {
status: StatusCode,
message: String,
}
impl UpdaterConfig {
fn from_env() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let bind = env_string("CPR_UPDATER_BIND")
.unwrap_or_else(|| "0.0.0.0:8090".to_string())
.parse::<SocketAddr>()?;
let token = required_env("CPR_UPDATER_TOKEN")?;
let allowed_image_repository = required_env("CPR_ALLOWED_IMAGE_REPOSITORY")?;
let docker_bin = env_string("CPR_DOCKER_BIN").unwrap_or_else(|| "docker".to_string());
let docker_compose_bin = env_string("CPR_DOCKER_COMPOSE_BIN");
let compose_file = PathBuf::from(
env_string("CPR_COMPOSE_FILE")
.unwrap_or_else(|| "/workspace/docker-compose.yml".into()),
);
let compose_project = env_string("CPR_COMPOSE_PROJECT");
let compose_env_file = env_string("CPR_COMPOSE_ENV_FILE").map(PathBuf::from);
let compose_image_env =
env_string("CPR_COMPOSE_IMAGE_ENV").unwrap_or_else(|| "CPR_IMAGE".to_string());
let state_file = PathBuf::from(
env_string("CPR_UPDATER_STATE_FILE")
.unwrap_or_else(|| "/tmp/codex-proxy-rs-updater-state.json".to_string()),
);
Ok(Self {
bind,
token,
allowed_image_repository,
docker_bin,
docker_compose_bin,
compose_file,
compose_project,
compose_env_file,
compose_image_env,
state_file,
})
}
fn project_for(&self, requested: Option<&str>) -> Option<String> {
requested
.and_then(non_empty)
.map(ToString::to_string)
.or_else(|| self.compose_project.clone())
}
}
async fn update(
State(config): State<UpdaterConfig>,
headers: HeaderMap,
Json(payload): Json<UpdateRequest>,
) -> Result<impl IntoResponse, UpdaterError> {
require_token(&config, &headers)?;
let service = validate_name("service", &payload.service)?;
let image = validate_image(&config, &payload.image)?;
let _target_version = payload.target_version.as_deref().and_then(non_empty);
let _confirm_backup = payload.confirm_backup.unwrap_or(false);
let compose_project = payload.compose_project;
let task_config = config;
let task_service = service.clone();
let task_image = image.clone();
tokio::spawn(async move {
if let Err(error) = run_update(
&task_config,
&task_service,
&task_image,
compose_project.as_deref(),
)
.await
{
eprintln!("updater update failed: {}", error.message);
}
});
Ok(Json(UpdaterResponse {
message: "update started".to_string(),
service,
image: Some(image),
}))
}
async fn rollback(
State(config): State<UpdaterConfig>,
headers: HeaderMap,
Json(payload): Json<RollbackRequest>,
) -> Result<impl IntoResponse, UpdaterError> {
require_token(&config, &headers)?;
let service = validate_name("service", &payload.service)?;
let state = read_state(&config.state_file)?;
let image = state
.previous_image
.ok_or_else(|| UpdaterError::conflict("No previous image recorded for rollback"))?;
let image = validate_image(&config, &image)?;
let compose_project = payload.compose_project;
let task_config = config;
let task_service = service.clone();
let task_image = image.clone();
tokio::spawn(async move {
if let Err(error) = run_update(
&task_config,
&task_service,
&task_image,
compose_project.as_deref(),
)
.await
{
eprintln!("updater rollback failed: {}", error.message);
}
});
Ok(Json(UpdaterResponse {
message: "rollback started".to_string(),
service,
image: Some(image),
}))
}
async fn run_update(
config: &UpdaterConfig,
service: &str,
image: &str,
compose_project: Option<&str>,
) -> Result<(), UpdaterError> {
let previous_image = current_image(config)?;
run_command(config.docker_command(["pull", image]), "docker pull").await?;
if let Some(env_file) = &config.compose_env_file {
upsert_env_file(env_file, &config.compose_image_env, image)?;
}
let state = UpdaterState {
previous_image,
current_image: Some(image.to_string()),
};
write_state(&config.state_file, &state)?;
let mut args = vec![
"-f".to_string(),
config.compose_file.to_string_lossy().to_string(),
];
if let Some(project) = config.project_for(compose_project) {
args.push("-p".to_string());
args.push(project);
}
args.extend(["up".to_string(), "-d".to_string(), service.to_string()]);
run_command(config.compose_command(args), "docker compose up").await
}
impl UpdaterConfig {
fn docker_command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut command = Command::new(&self.docker_bin);
for arg in args {
command.arg(arg.as_ref());
}
command
}
fn compose_command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
if let Some(compose_bin) = &self.docker_compose_bin {
let mut command = Command::new(compose_bin);
for arg in args {
command.arg(arg.as_ref());
}
return command;
}
let mut command = Command::new(&self.docker_bin);
command.arg("compose");
for arg in args {
command.arg(arg.as_ref());
}
command
}
}
async fn run_command(mut command: Command, label: &'static str) -> Result<(), UpdaterError> {
let output = tokio::task::spawn_blocking(move || command.output())
.await
.map_err(|error| UpdaterError::internal(format!("{label} join error: {error}")))?
.map_err(|error| UpdaterError::internal(format!("{label} failed to start: {error}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(UpdaterError::internal(format!(
"{label} failed: {}{}",
stderr.trim(),
stdout.trim()
)))
}
fn current_image(config: &UpdaterConfig) -> Result<Option<String>, UpdaterError> {
let Some(env_file) = &config.compose_env_file else {
return Ok(read_state(&config.state_file)?.current_image);
};
if !env_file.exists() {
return Ok(read_state(&config.state_file)?.current_image);
}
let values = read_env_file(env_file)?;
Ok(values.get(&config.compose_image_env).cloned().or_else(|| {
read_state(&config.state_file)
.ok()
.and_then(|state| state.current_image)
}))
}
fn read_state(path: &Path) -> Result<UpdaterState, UpdaterError> {
if !path.exists() {
return Ok(UpdaterState::default());
}
let data = fs::read_to_string(path).map_err(|error| {
UpdaterError::internal(format!("Failed to read updater state: {error}"))
})?;
serde_json::from_str(&data)
.map_err(|error| UpdaterError::internal(format!("Invalid updater state: {error}")))
}
fn write_state(path: &Path, state: &UpdaterState) -> Result<(), UpdaterError> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
UpdaterError::internal(format!("Failed to create updater state directory: {error}"))
})?;
}
let data = serde_json::to_string_pretty(state).map_err(|error| {
UpdaterError::internal(format!("Failed to encode updater state: {error}"))
})?;
fs::write(path, data)
.map_err(|error| UpdaterError::internal(format!("Failed to write updater state: {error}")))
}
fn upsert_env_file(path: &Path, key: &str, value: &str) -> Result<(), UpdaterError> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
UpdaterError::internal(format!("Failed to create env dir: {error}"))
})?;
}
let mut values = if path.exists() {
read_env_file(path)?
} else {
BTreeMap::new()
};
values.insert(key.to_string(), value.to_string());
let data = values
.into_iter()
.map(|(key, value)| format!("{key}={value}\n"))
.collect::<String>();
fs::write(path, data)
.map_err(|error| UpdaterError::internal(format!("Failed to write env file: {error}")))
}
fn read_env_file(path: &Path) -> Result<BTreeMap<String, String>, UpdaterError> {
let data = fs::read_to_string(path)
.map_err(|error| UpdaterError::internal(format!("Failed to read env file: {error}")))?;
let mut values = BTreeMap::new();
for line in data.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.insert(key.trim().to_string(), value.trim().to_string());
}
Ok(values)
}
fn require_token(config: &UpdaterConfig, headers: &HeaderMap) -> Result<(), UpdaterError> {
let Some(header) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(UpdaterError::unauthorized("Missing authorization header"));
};
let Ok(value) = header.to_str() else {
return Err(UpdaterError::unauthorized("Invalid authorization header"));
};
let token = value.strip_prefix("Bearer ").unwrap_or_default().trim();
if token == config.token {
return Ok(());
}
Err(UpdaterError::unauthorized("Invalid updater token"))
}
fn validate_image(config: &UpdaterConfig, image: &str) -> Result<String, UpdaterError> {
let image = validate_name("image", image)?;
let allowed_prefix = format!("{}:", config.allowed_image_repository);
let allowed_digest_prefix = format!("{}@", config.allowed_image_repository);
if image.starts_with(&allowed_prefix) || image.starts_with(&allowed_digest_prefix) {
return Ok(image);
}
Err(UpdaterError::bad_request(
"Image repository is not allowed for update",
))
}
fn validate_name(field: &str, value: &str) -> Result<String, UpdaterError> {
let Some(value) = non_empty(value) else {
return Err(UpdaterError::bad_request(format!(
"{field} must not be empty"
)));
};
if value
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace())
{
return Err(UpdaterError::bad_request(format!(
"{field} must not contain whitespace or control characters"
)));
}
Ok(value.to_string())
}
fn env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn required_env(key: &str) -> Result<String, io::Error> {
env_string(key)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{key} is required")))
}
fn non_empty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
impl UpdaterError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.into(),
}
}
fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
impl IntoResponse for UpdaterError {
fn into_response(self) -> Response {
(
self.status,
Json(serde_json::json!({
"message": self.message
})),
)
.into_response()
}
}
+1
View File
@@ -4,3 +4,4 @@ pub(crate) mod keys;
pub(crate) mod monitoring;
pub(crate) mod response;
pub(crate) mod settings;
pub(crate) mod system;
+454
View File
@@ -0,0 +1,454 @@
use std::{io::Write, path::Path, sync::Arc};
use axum::{
body::Body,
http::{header::CONTENT_TYPE, Request, StatusCode},
};
use codex_proxy_rs::{
admin::auth::service::SqliteAdminSessionStore,
admin::keys::service::SqliteClientKeyStore,
admin::monitoring::usage_record_store::SqliteUsageRecordStore,
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
runtime::services::{BackgroundTaskStores, Services},
runtime::state::AppState,
upstream::accounts::{
cookies::SqliteCookieStore, store::SqliteAccountStore, token_refresh::RefreshLeaseStore,
},
upstream::fingerprint::FingerprintRepository,
};
use flate2::{write::GzEncoder, Compression};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use tar::{Builder, Header};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
use crate::support::{
admin::seed_admin_session, config::test_config, fingerprint::test_fingerprint,
http::response_json,
};
static SYSTEM_ENV_LOCK: Mutex<()> = Mutex::const_new(());
const VERSION_OLD: &str = "0.1.0";
const VERSION_NEW: &str = "0.2.0";
#[tokio::test]
async fn version_should_return_backend_build_metadata() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
set_system_update_env("zyycn/codex-proxy-rs-version-route", "http://127.0.0.1:9");
std::env::set_var("CPR_GIT_SHA", "version-test-sha");
std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z");
let (app, _dir) = admin_system_test_app("system-version.sqlite").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/system/version")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_system_version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
assert!(
body["data"]["version"] == VERSION_OLD
&& body["data"]["gitSha"] == "version-test-sha"
&& body["data"]["buildTime"] == "2026-07-01T00:00:00Z"
&& body["data"]["deploymentMode"] == "docker"
&& body["data"]["deploymentModeLabel"] == "Docker"
&& body["data"]["updateChannel"] == "stable"
);
}
#[tokio::test]
async fn check_updates_should_use_cached_release_when_not_forced() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-cache-route";
let success_server = MockServer::start().await;
mount_latest_release(&success_server, repository, "0.2.0").await;
set_system_update_env(repository, &success_server.uri());
let (app, _dir) = admin_system_test_app("system-cache.sqlite").await;
let initial = get_check_updates(&app, true, "req_system_cache_initial").await;
let failing_server = MockServer::start().await;
set_system_update_env(repository, &failing_server.uri());
let cached = get_check_updates(&app, false, "req_system_cache_cached").await;
assert!(
initial["hasUpdate"] == true
&& cached["cached"] == true
&& cached["latestVersion"] == "0.2.0"
);
}
#[tokio::test]
async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-cache-forced-route";
let success_server = MockServer::start().await;
mount_latest_release(&success_server, repository, "0.3.0").await;
set_system_update_env(repository, &success_server.uri());
let (app, _dir) = admin_system_test_app("system-cache-forced.sqlite").await;
let initial = get_check_updates(&app, true, "req_system_cache_forced_initial").await;
let failing_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/repos/{repository}/releases/latest")))
.respond_with(ResponseTemplate::new(500))
.mount(&failing_server)
.await;
set_system_update_env(repository, &failing_server.uri());
let fallback = get_check_updates(&app, true, "req_system_cache_forced_fallback").await;
assert!(
initial["hasUpdate"] == true
&& fallback["cached"] == true
&& fallback["latestVersion"] == "0.3.0"
&& fallback["warning"].is_null()
);
}
#[tokio::test]
async fn check_updates_should_report_source_build_as_unsupported() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-source-route";
let github = MockServer::start().await;
mount_latest_release(&github, repository, "0.2.0").await;
set_system_update_env(repository, &github.uri());
std::env::set_var("CPR_BUILD_TYPE", "source");
let (app, _dir) = admin_system_test_app("system-source-build.sqlite").await;
let info = get_check_updates(&app, true, "req_system_source_build").await;
assert!(
info["currentVersion"] == VERSION_OLD
&& info["latestVersion"] == VERSION_NEW
&& info["hasUpdate"] == true
&& info["buildType"] == "source"
&& info["buildTypeLabel"] == "源码构建"
&& info["updateSupported"] == false
&& info["unsupportedReason"] == "一键更新需要正式构建包"
);
}
#[tokio::test]
async fn update_should_replace_local_release_files_with_latest_asset() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-update-route";
let github = MockServer::start().await;
let deploy = tempfile::tempdir().unwrap();
let exe_path = deploy.path().join("codex-proxy-rs");
let web_dist = deploy.path().join("web/dist");
std::fs::create_dir_all(&web_dist).unwrap();
std::fs::write(&exe_path, "old-binary").unwrap();
std::fs::write(web_dist.join("index.html"), "old-web").unwrap();
mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await;
set_system_update_env(repository, &github.uri());
set_system_update_paths(deploy.path(), &exe_path, &web_dist);
let (app, _dir) = admin_system_test_app("system-update.sqlite").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/system/update")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_system_update_latest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
let status = wait_for_operation_status(&app, "succeeded").await;
assert!(
body["data"]["operationId"]
.as_str()
.is_some_and(|id| id.starts_with("sysop-update-"))
&& body["data"]["targetVersion"] == "0.4.0"
&& body["data"]["needRestart"] == true
&& status["operation"]["status"] == "succeeded"
&& status["currentVersion"] == "0.4.0"
&& std::fs::read_to_string(&exe_path).unwrap() == "new-binary"
&& std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "new-web"
&& std::fs::read_to_string(deploy.path().join("codex-proxy-rs.backup")).unwrap()
== "old-binary"
&& std::fs::read_to_string(deploy.path().join("web/dist.backup/index.html")).unwrap()
== "old-web"
);
}
#[tokio::test]
async fn update_status_should_read_local_update_state() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let deploy = tempfile::tempdir().unwrap();
set_system_update_env("zyycn/codex-proxy-rs-status-route", "http://127.0.0.1:9");
set_system_update_paths(
deploy.path(),
&deploy.path().join("codex-proxy-rs"),
&deploy.path().join("web/dist"),
);
std::fs::create_dir_all(deploy.path().join(".runtime")).unwrap();
std::fs::write(
deploy.path().join(".runtime/update-state.json"),
json!({
"previousVersion": VERSION_OLD,
"currentVersion": VERSION_NEW,
"operation": {
"operationId": "system-update-status",
"kind": "update",
"status": "running",
"targetVersion": VERSION_NEW,
"message": "operation running",
"error": null,
"startedAt": "2026-07-01T00:00:00Z",
"finishedAt": null
}
})
.to_string(),
)
.unwrap();
let (app, _dir) = admin_system_test_app("system-status.sqlite").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/system/update-status")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_system_update_status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
assert!(
body["data"]["operation"]["operationId"] == "system-update-status"
&& body["data"]["operation"]["status"] == "running"
&& body["data"]["currentVersion"] == VERSION_NEW
);
}
#[tokio::test]
async fn update_events_should_open_authenticated_sse_stream() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let deploy = tempfile::tempdir().unwrap();
set_system_update_env("zyycn/codex-proxy-rs-events-route", "http://127.0.0.1:9");
set_system_update_paths(
deploy.path(),
&deploy.path().join("codex-proxy-rs"),
&deploy.path().join("web/dist"),
);
let (app, _dir) = admin_system_test_app("system-events.sqlite").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/system/update-events")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_system_update_events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(response.status() == StatusCode::OK && content_type.starts_with("text/event-stream"));
}
async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> Value {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/admin/system/check-updates?force={force}"))
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", request_id)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_json(response).await["data"].clone()
}
async fn mount_latest_release(server: &MockServer, repository: &str, version: &str) {
Mock::given(method("GET"))
.and(path(format!("/repos/{repository}/releases/latest")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tag_name": format!("v{version}"),
"name": format!("v{version}"),
"body": "## Changes\n- Update package",
"html_url": format!("https://github.com/{repository}/releases/tag/v{version}"),
"prerelease": false,
"assets": []
})))
.mount(server)
.await;
}
async fn mount_latest_release_with_archive(
server: &MockServer,
repository: &str,
version: &str,
binary: &str,
index: &str,
) {
let archive_name = format!(
"codex-proxy-rs_{version}_{}_{}.tar.gz",
std::env::consts::OS,
std::env::consts::ARCH
);
let archive = release_archive(binary, index);
let checksum = format!(
"{} {archive_name}\n",
hex::encode(Sha256::digest(&archive))
);
Mock::given(method("GET"))
.and(path(format!("/repos/{repository}/releases/latest")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tag_name": format!("v{version}"),
"name": format!("v{version}"),
"body": "## Changes\n- Update package",
"html_url": format!("https://github.com/{repository}/releases/tag/v{version}"),
"prerelease": false,
"assets": [
{
"name": archive_name,
"browser_download_url": format!("{}/download/{archive_name}", server.uri()),
"size": archive.len()
},
{
"name": "checksums.txt",
"browser_download_url": format!("{}/download/checksums.txt", server.uri()),
"size": checksum.len()
}
]
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path(format!("/download/{archive_name}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/download/checksums.txt"))
.respond_with(ResponseTemplate::new(200).set_body_string(checksum))
.mount(server)
.await;
}
fn release_archive(binary: &str, index: &str) -> Vec<u8> {
let mut data = Vec::new();
{
let encoder = GzEncoder::new(&mut data, Compression::default());
let mut builder = Builder::new(encoder);
append_tar_file(&mut builder, "codex-proxy-rs", binary.as_bytes());
append_tar_file(&mut builder, "web/dist/index.html", index.as_bytes());
builder.finish().unwrap();
}
data
}
fn append_tar_file<W: Write>(builder: &mut Builder<W>, path: &str, bytes: &[u8]) {
let mut header = Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_cksum();
builder.append_data(&mut header, path, bytes).unwrap();
}
fn set_system_update_env(repository: &str, github_api_base: &str) {
std::env::set_var("CPR_VERSION", "0.1.0");
std::env::set_var("CPR_DEPLOYMENT_MODE", "docker");
std::env::set_var("CPR_BUILD_TYPE", "release");
std::env::set_var("CPR_UPDATE_CHANNEL", "stable");
std::env::set_var("CPR_UPDATE_REPOSITORY", repository);
std::env::set_var("CPR_GITHUB_API_BASE", format!("{github_api_base}/repos"));
std::env::set_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS", "true");
std::env::remove_var("CPR_UPDATE_EXE_PATH");
std::env::remove_var("CPR_WEB_DIST_DIR");
std::env::remove_var("CPR_UPDATE_STATE_FILE");
std::env::remove_var("CPR_UPDATE_LOCK_FILE");
}
fn set_system_update_paths(root: &Path, exe_path: &Path, web_dist: &Path) {
std::env::set_var("CPR_UPDATE_EXE_PATH", exe_path);
std::env::set_var("CPR_WEB_DIST_DIR", web_dist);
std::env::set_var(
"CPR_UPDATE_STATE_FILE",
root.join(".runtime/update-state.json"),
);
std::env::set_var("CPR_UPDATE_LOCK_FILE", root.join(".runtime/update.lock"));
}
async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/system/update-status")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_system_update_status_poll")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let data = response_json(response).await["data"].clone();
assert_eq!(data["operation"]["status"], expected);
data
}
async fn admin_system_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(db_name);
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let config = test_config(url);
let stores = stores(pool);
let services = Arc::new(Services::new(&config, stores, test_fingerprint()));
let state = AppState {
config,
services: (*services).clone(),
};
let app = codex_proxy_rs::http::router::router().with_state(state);
(app, dir)
}
fn stores(pool: SqlitePool) -> BackgroundTaskStores {
BackgroundTaskStores {
accounts: SqliteAccountStore::new(pool.clone()),
admin_sessions: SqliteAdminSessionStore::new(pool.clone()),
cookies: SqliteCookieStore::new(pool.clone()),
fingerprints: FingerprintRepository::new(pool.clone()),
session_affinity: SqliteSessionAffinityStore::new(pool.clone()),
refresh_leases: RefreshLeaseStore::new(pool.clone()),
client_keys: SqliteClientKeyStore::new(pool.clone()),
usage_records: SqliteUsageRecordStore::new(pool),
}
}
+17 -16
View File
@@ -1,11 +1,14 @@
import request, { type ApiPayload } from '../request'
import request from '../request'
import { API_BASE_URL } from '../constants'
export const SYSTEM_UPDATE_EVENTS_URL = `${API_BASE_URL}/api/admin/system/update-events`
export interface SystemVersion {
version: string
gitSha: string
buildTime: string
deploymentMode: string
image?: string
deploymentModeLabel: string
updateChannel: string
}
@@ -14,13 +17,14 @@ export interface SystemUpdateInfo {
latestVersion: string
hasUpdate: boolean
deploymentMode: string
deploymentModeLabel: string
buildType: string
buildTypeLabel: string
releaseUrl?: string
notes?: string
cached: boolean
updateSupported: boolean
unsupportedReason?: string
targetImage?: string
requiresBackup: boolean
warning?: string
}
@@ -28,9 +32,14 @@ export interface SystemUpdateStarted {
operationId: string
deploymentMode: string
message: string
needReconnect: boolean
needRestart: boolean
targetVersion: string
targetImage?: string
}
export interface SystemOperationStarted {
operationId?: string
message: string
needRestart?: boolean
}
export function getSystemVersion() {
@@ -48,23 +57,15 @@ export function checkSystemUpdates(force = false) {
})
}
export function performSystemUpdate(data: ApiPayload = {}) {
export function performSystemUpdate() {
return request<SystemUpdateStarted>({
url: '/api/admin/system/update',
method: 'POST',
data,
})
}
export function rollbackSystemUpdate() {
return request({
url: '/api/admin/system/rollback',
method: 'POST',
})
}
export function restartSystem() {
return request({
return request<SystemOperationStarted>({
url: '/api/admin/system/restart',
method: 'POST',
})
+256
View File
@@ -0,0 +1,256 @@
import { computed, ref, shallowRef } from 'vue'
import {
SYSTEM_UPDATE_EVENTS_URL,
checkSystemUpdates,
getSystemVersion,
performSystemUpdate,
restartSystem,
type SystemUpdateInfo,
type SystemVersion,
} from '@/api'
export type SystemUpdateLogLevel = 'info' | 'success' | 'warning' | 'error'
export interface SystemUpdateLog {
id: string
operationId?: string
level: SystemUpdateLogLevel
step?: string
message: string
at: string
}
const version = shallowRef<SystemVersion | null>(null)
const updateInfo = shallowRef<SystemUpdateInfo | null>(null)
const loading = shallowRef(false)
const checking = shallowRef(false)
const updating = shallowRef(false)
const restarting = shallowRef(false)
const restartCountdown = shallowRef(0)
const updateError = shallowRef('')
const updateSuccess = shallowRef(false)
const needRestart = shallowRef(false)
const loadedOnce = shallowRef(false)
const updateLogs = ref<SystemUpdateLog[]>([])
const updateStreaming = shallowRef(false)
const updateStreamError = shallowRef('')
const updateEventSource = shallowRef<EventSource | null>(null)
let restartTimer: number | undefined
const maxUpdateLogs = 200
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release')
const canUpdate = computed(
() =>
hasUpdate.value &&
isReleaseBuild.value &&
Boolean(updateInfo.value?.updateSupported) &&
!updating.value &&
!checking.value &&
!restarting.value,
)
function resetUpdateResult() {
updateError.value = ''
updateSuccess.value = false
needRestart.value = false
}
function isUpdateLog(value: unknown): value is SystemUpdateLog {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'level' in value &&
'message' in value &&
'at' in value
)
}
function appendUpdateLog(log: SystemUpdateLog) {
updateLogs.value = [...updateLogs.value, log].slice(-maxUpdateLogs)
}
function clearUpdateLogs() {
updateLogs.value = []
updateStreamError.value = ''
}
function connectUpdateEvents() {
if (updateEventSource.value) return
if (typeof EventSource === 'undefined') {
updateStreamError.value = '当前浏览器不支持实时更新日志'
return
}
updateStreamError.value = ''
const source = new EventSource(SYSTEM_UPDATE_EVENTS_URL, { withCredentials: true })
updateEventSource.value = source
source.onopen = () => {
updateStreaming.value = true
updateStreamError.value = ''
}
source.addEventListener('update', (event) => {
try {
const log = JSON.parse((event as MessageEvent<string>).data)
if (isUpdateLog(log)) {
appendUpdateLog(log)
}
} catch {
updateStreamError.value = '更新日志解析失败'
}
})
source.onerror = () => {
updateStreaming.value = false
updateStreamError.value = '更新日志连接中断'
if (source.readyState === EventSource.CLOSED) {
updateEventSource.value = null
}
}
}
function disconnectUpdateEvents() {
updateEventSource.value?.close()
updateEventSource.value = null
updateStreaming.value = false
}
async function loadSystem(force = false) {
if (loading.value) return
loading.value = true
try {
const [versionData, updateData] = await Promise.all([
getSystemVersion(),
checkSystemUpdates(force),
])
version.value = versionData
updateInfo.value = updateData
loadedOnce.value = true
} finally {
loading.value = false
}
}
async function checkUpdates(force = true) {
if (checking.value) return updateInfo.value
checking.value = true
resetUpdateResult()
try {
updateInfo.value = await checkSystemUpdates(force)
if (!version.value) {
version.value = await getSystemVersion()
}
loadedOnce.value = true
return updateInfo.value
} finally {
checking.value = false
}
}
async function updateNow() {
if (!canUpdate.value || !updateInfo.value || updating.value) return null
clearUpdateLogs()
connectUpdateEvents()
updating.value = true
updateError.value = ''
updateSuccess.value = false
try {
const result = await performSystemUpdate()
updateSuccess.value = true
needRestart.value = result.needRestart
return result
} catch (error: any) {
updateError.value = error.message || '更新失败'
appendUpdateLog({
id: `update-client-error-${Date.now()}`,
level: 'error',
message: updateError.value,
at: new Date().toISOString(),
})
throw error
} finally {
updating.value = false
}
}
function clearRestartTimer() {
if (restartTimer !== undefined) {
window.clearInterval(restartTimer)
restartTimer = undefined
}
}
async function waitForServiceAndReload() {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await getSystemVersion()
window.location.reload()
return
} catch {
await new Promise((resolve) => window.setTimeout(resolve, 1000))
}
}
window.location.reload()
}
async function restartNow() {
if (restarting.value) return
restarting.value = true
restartCountdown.value = 8
clearRestartTimer()
try {
await restartSystem()
} catch {
// The connection can drop immediately after the restart request is accepted.
}
restartTimer = window.setInterval(() => {
restartCountdown.value -= 1
if (restartCountdown.value <= 0) {
clearRestartTimer()
void waitForServiceAndReload()
}
}, 1000)
}
export function useSystemUpdate() {
return {
version,
updateInfo,
loading,
checking,
updating,
restarting,
restartCountdown,
updateError,
updateSuccess,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
hasUpdate,
isReleaseBuild,
canUpdate,
loadSystem,
checkUpdates,
updateNow,
restartNow,
clearRestartTimer,
connectUpdateEvents,
disconnectUpdateEvents,
clearUpdateLogs,
}
}
+37 -11
View File
@@ -7,19 +7,20 @@ import { storeToRefs } from 'pinia'
import {
Cat,
ChartNoAxesColumn,
ArrowUpCircle,
KeyRound,
LayoutDashboard,
LogOut,
Moon,
PanelLeftClose,
PanelLeftOpen,
PackageCheck,
Settings,
Sun,
Users,
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { useSystemUpdate } from '@/composables/useSystemUpdate'
import { useAuthStore } from '@/stores/modules/auth'
import { useUiStore } from '@/stores/modules/ui'
@@ -32,6 +33,7 @@ const uiStore = useUiStore()
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const preferredMotion = usePreferredReducedMotion()
const { version, hasUpdate, loadSystem } = useSystemUpdate()
const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
@@ -75,6 +77,10 @@ const systemUpdateOpen = shallowRef(false)
const themeToggleLabel = computed(() =>
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
)
const versionLabel = computed(() => `v${version.value?.version || '...'}`)
const updateButtonLabel = computed(() =>
hasUpdate.value ? '发现新版本,打开系统更新' : '打开系统更新',
)
function prefersReducedMotion() {
return preferredMotion.value === 'reduce'
@@ -194,6 +200,7 @@ onMounted(() => {
})
}
animateSidebarLabels(Boolean(props.collapsed))
void loadSystem(false).catch(() => undefined)
})
watch(
@@ -222,11 +229,11 @@ watch(
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
>
<div
class="mt-5 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="collapsed ? 'w-11 justify-start' : 'w-full gap-3'"
>
<span
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
>
<Cat :size="27" stroke-width="2" />
</span>
@@ -235,10 +242,28 @@ watch(
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-base leading-[1.1] font-[760] text-(--cp-text-primary)">Codex</strong>
<span class="mt-1 text-xs leading-[1.1] font-semibold text-(--cp-text-secondary)"
>Proxy RS · v0.1.0</span
>
<strong class="text-base leading-[1.1] font-[760] text-(--cp-text-primary)">
Codex Proxy
</strong>
<span class="mt-1.5 flex min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-[650] text-(--cp-text-secondary)">
Rust build
</span>
<button
type="button"
class="inline-flex h-4.5 min-w-0 items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-[720] cursor-pointer transition-colors"
:class="
hasUpdate
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)'
"
:title="updateButtonLabel"
@click="systemUpdateOpen = true"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
</button>
</span>
</span>
</div>
@@ -284,13 +309,14 @@ watch(
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
<BaseButton
v-if="collapsed && hasUpdate"
icon-only
variant="ghost"
:size="collapsed ? 'default' : 'sm'"
label="系统更新"
:variant="hasUpdate ? 'success' : 'ghost'"
size="default"
:label="updateButtonLabel"
@click="systemUpdateOpen = true"
>
<PackageCheck :size="collapsed ? 19 : 18" />
<ArrowUpCircle :size="19" />
</BaseButton>
<BaseButton
+299 -220
View File
@@ -1,178 +1,229 @@
<script setup lang="ts">
import { computed, onUnmounted, shallowRef, watch } from 'vue'
import { ExternalLink, PackageCheck, Power, RefreshCw, RotateCcw } from '@lucide/vue'
import { computed, onUnmounted, watch } from 'vue'
import {
checkSystemUpdates,
getSystemVersion,
performSystemUpdate,
restartSystem,
rollbackSystemUpdate,
type SystemUpdateInfo,
type SystemVersion,
} from '@/api'
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
GitBranch,
Power,
RefreshCw,
Terminal,
XCircle,
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate'
const open = defineModel<boolean>({ default: false })
const version = shallowRef<SystemVersion | null>(null)
const updateInfo = shallowRef<SystemUpdateInfo | null>(null)
const loading = shallowRef(false)
const checking = shallowRef(false)
const updating = shallowRef(false)
const rollingBack = shallowRef(false)
const restarting = shallowRef(false)
const reconnecting = shallowRef(false)
const reconnectMessage = shallowRef('')
let disposed = false
let loadedOnce = false
const {
version,
updateInfo,
loading,
checking,
updating,
restarting,
restartCountdown,
updateError,
updateSuccess,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
hasUpdate,
isReleaseBuild,
canUpdate,
loadSystem,
checkUpdates,
updateNow,
restartNow,
clearRestartTimer,
connectUpdateEvents,
disconnectUpdateEvents,
} = useSystemUpdate()
const canUpdate = computed(
() =>
Boolean(updateInfo.value?.hasUpdate) &&
Boolean(updateInfo.value?.updateSupported) &&
!updating.value &&
!checking.value,
)
const updateStatus = computed(() => {
if (updateInfo.value?.warning) return '检查失败'
if (!updateInfo.value) return '未检查'
if (updateInfo.value.hasUpdate) return '发现新版本'
return '已是最新'
const statusView = computed(() => {
if (updating.value) {
return {
label: '更新中',
icon: RefreshCw,
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
iconClass: 'text-(--cp-info)',
}
}
if (updateError.value || updateInfo.value?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
iconClass: 'text-(--cp-danger)',
}
}
if (updateSuccess.value) {
return {
label: '已更新',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
if (hasUpdate.value) {
return {
label: '有新版本',
icon: ArrowUpCircle,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
return {
label: updateInfo.value ? '已是最新' : '未检查',
icon: CheckCircle2,
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
iconClass: 'text-(--cp-text-muted)',
}
})
const updateStatusClass = computed(() => {
if (updateInfo.value?.warning) return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
if (updateInfo.value?.hasUpdate) return 'bg-(--cp-info-bg) text-(--cp-info-text)'
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
const summaryItems = computed(() => [
{
label: '当前版本',
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
title: version.value?.version,
},
{
label: '最新版本',
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
title: updateInfo.value?.latestVersion,
},
{
label: '构建',
value: displayValue(updateInfo.value?.buildTypeLabel),
title: updateInfo.value?.buildType,
},
{
label: '部署',
value: displayValue(version.value?.deploymentModeLabel),
title: version.value?.deploymentMode,
},
])
const statusMessage = computed(() => {
if (updateError.value) return updateError.value
if (updateSuccess.value && needRestart.value) return '更新已完成,等待重启生效'
if (updating.value) return '正在替换应用包'
if (updateInfo.value?.unsupportedReason) return updateInfo.value.unsupportedReason
if (updateInfo.value?.warning) return updateInfo.value.warning
if (hasUpdate.value) return `可更新到 v${updateInfo.value?.latestVersion}`
if (updateInfo.value) return '当前版本已是最新'
return '尚未检查更新'
})
const deploymentStatusClass = computed(() =>
version.value?.deploymentMode === 'docker'
? 'bg-(--cp-info-bg) text-(--cp-info-text)'
: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
const statusToneClass = computed(() => {
if (updateError.value || updateInfo.value?.warning) {
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
}
if (updateInfo.value?.unsupportedReason || (hasUpdate.value && !isReleaseBuild.value)) {
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
}
if (updateSuccess.value || hasUpdate.value) {
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
}
return 'bg-(--cp-bg-muted) text-(--cp-text-secondary)'
})
const hasStatusNotice = computed(() =>
Boolean(
updateError.value ||
updateSuccess.value ||
updateInfo.value?.unsupportedReason ||
updateInfo.value?.warning ||
(hasUpdate.value && !isReleaseBuild.value),
),
)
const updateLogRows = computed(() =>
updateLogs.value.map((item) => ({
...item,
time: formatLogTime(item.at),
})),
)
const streamStatusLabel = computed(() => {
if (updateStreaming.value) return '实时'
if (updateStreamError.value) return '断开'
return '待连接'
})
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
function formatLogTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--:--:--'
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
async function loadSystem() {
try {
loading.value = true
const [versionData, updateData] = await Promise.all([
getSystemVersion(),
checkSystemUpdates(false),
])
version.value = versionData
updateInfo.value = updateData
loadedOnce = true
} catch (error: any) {
toast.error(error.message || '加载系统更新信息失败')
} finally {
loading.value = false
}
function logMarkerClass(level: SystemUpdateLogLevel) {
if (level === 'success') return 'text-(--cp-success)'
if (level === 'warning') return 'text-(--cp-warning)'
if (level === 'error') return 'text-(--cp-danger)'
return 'text-(--cp-info)'
}
function logTextClass(level: SystemUpdateLogLevel) {
if (level === 'success') return 'text-(--cp-success)'
if (level === 'warning') return 'text-(--cp-warning)'
if (level === 'error') return 'text-(--cp-danger)'
return 'text-(--cp-text-primary)'
}
async function handleCheckUpdates(force = true) {
if (checking.value) return
try {
checking.value = true
updateInfo.value = await checkSystemUpdates(force)
toast.success(updateInfo.value.hasUpdate ? '发现可用更新' : '当前已是最新版本')
const data = await checkUpdates(force)
toast.success(data?.hasUpdate ? '发现可用更新' : '当前已是最新版本')
} catch (error: any) {
toast.error(error.message || '检查更新失败')
} finally {
checking.value = false
}
}
async function waitForReconnect(targetVersion: string) {
reconnecting.value = true
reconnectMessage.value = '等待服务重启...'
for (let attempt = 0; attempt < 40 && !disposed; attempt += 1) {
await sleep(3000)
try {
const data = await getSystemVersion()
version.value = data
reconnectMessage.value = `服务已响应,当前版本 ${data.version}`
if (!targetVersion || data.version === targetVersion) {
toast.success('系统已恢复')
await handleCheckUpdates(false)
break
}
} catch {
reconnectMessage.value = '服务暂时不可用,继续等待...'
}
}
reconnecting.value = false
}
async function handleUpdate() {
if (!canUpdate.value || !updateInfo.value) return
try {
updating.value = true
const result = await performSystemUpdate({
confirmBackup: updateInfo.value.requiresBackup,
})
toast.success('更新任务已启动')
if (result.needReconnect) {
await waitForReconnect(result.targetVersion)
const result = await updateNow()
if (result?.needRestart) {
toast.success('更新完成,请重启服务')
}
} catch (error: any) {
toast.error(error.message || '启动更新失败')
} finally {
updating.value = false
}
}
async function handleRollback() {
if (rollingBack.value) return
try {
rollingBack.value = true
await rollbackSystemUpdate()
toast.success('回滚任务已启动')
} catch (error: any) {
toast.error(error.message || '启动回滚失败')
} finally {
rollingBack.value = false
toast.error(error.message || '更新失败')
}
}
async function handleRestart() {
if (restarting.value) return
try {
restarting.value = true
await restartSystem()
toast.success('重启已触发')
await waitForReconnect(version.value?.version || '')
} catch (error: any) {
toast.error(error.message || '重启失败')
} finally {
restarting.value = false
}
toast.success('正在重启服务')
await restartNow()
}
watch(open, (visible) => {
if (visible && !loadedOnce) {
void loadSystem()
if (visible) {
connectUpdateEvents()
} else if (!updating.value && !restarting.value) {
disconnectUpdateEvents()
}
if (visible && !loadedOnce.value) {
void loadSystem(false).catch((error: any) => {
toast.error(error.message || '加载系统更新信息失败')
})
}
})
onUnmounted(() => {
disposed = true
clearRestartTimer()
disconnectUpdateEvents()
})
</script>
@@ -180,124 +231,151 @@ onUnmounted(() => {
<BaseModal
v-model="open"
title="系统更新"
description="检查版本并触发 Docker 在线更新。"
variant="info"
width="780px"
description="版本、更新状态与实时进度。"
variant="success"
width="820px"
scrollable
body-max-height="68vh"
body-view-class="pr-4"
:close-disabled="updating || rollingBack || restarting"
body-max-height="72vh"
body-view-class="pr-3"
:close-disabled="updating || restarting"
>
<div class="grid gap-4">
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">运行版本</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-[650] text-(--cp-text-secondary)">
{{ loading ? '加载中...' : displayValue(version?.version) }}
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-[780] text-(--cp-text-primary)">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 items-center rounded-full px-2.5 text-[12px] font-[760]"
:class="deploymentStatusClass"
class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-[760]"
:class="statusView.badge"
>
{{ loading ? '加载中...' : displayValue(version?.deploymentMode) }}
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
{{ statusView.label }}
</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">镜像</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
:title="version?.image || '-'"
>
{{ displayValue(version?.image) }}
<div class="grid gap-2.5 sm:grid-cols-4">
<div
v-for="item in summaryItems"
:key="item.label"
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
>
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
{{ item.label }}
</p>
</div>
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">Git SHA</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-[720] text-(--cp-text-primary)"
:title="item.title || item.value"
>
{{ displayValue(version?.gitSha) }}
{{ item.value }}
</p>
</div>
</div>
</section>
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="flex flex-wrap items-center gap-3">
<span
class="inline-flex h-8 items-center rounded-full px-3 text-[13px] font-[760]"
:class="updateStatusClass"
>
{{ updateStatus }}
</span>
<span class="font-mono text-[13px] font-[650] text-(--cp-text-secondary)">
{{ displayValue(updateInfo?.currentVersion) }} ->
{{ displayValue(updateInfo?.latestVersion) }}
</span>
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
<div
v-if="!hasStatusNotice || updateInfo?.releaseUrl"
class="flex flex-wrap items-center justify-between gap-3"
>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">状态</p>
<p
v-if="!hasStatusNotice"
class="mt-1.5 mb-0 wrap-break-word text-[13px] leading-normal font-[720] text-(--cp-text-primary)"
>
{{ statusMessage }}
</p>
</div>
<a
v-if="updateInfo?.releaseUrl"
:href="updateInfo.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1.5 text-[13px] font-[720] text-(--cp-info-text) no-underline"
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-(--cp-input-radius-base) bg-(--cp-info-bg) px-2.5 text-[12px] font-[720] text-(--cp-info-text) no-underline hover:bg-(--cp-info-bg-hover)"
>
Release
发布页
<ExternalLink class="size-3.5" />
</a>
</div>
<div
v-if="updateInfo?.unsupportedReason"
class="rounded-(--cp-input-radius-base) bg-(--cp-warning-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-warning-text)"
v-if="hasStatusNotice"
class="flex items-center gap-2.5 rounded-(--cp-input-radius-base) px-3 py-2.5"
:class="statusToneClass"
>
{{ updateInfo.unsupportedReason }}
<GitBranch
v-if="hasUpdate && !isReleaseBuild && !updateError && !updateInfo?.warning"
class="size-3.5 shrink-0"
/>
<component v-else :is="statusView.icon" class="size-3.5 shrink-0" />
<p class="m-0 wrap-break-word text-[12px] font-[650] leading-none">
{{ statusMessage }}
</p>
</div>
</section>
<div
v-if="updateInfo?.warning"
class="rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-danger-text)"
>
{{ updateInfo.warning }}
</div>
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
<p class="m-0 text-[13px] leading-none font-[760] text-(--cp-text-primary)">更新进度</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-(--cp-bg-subtle) px-2 text-[11px] leading-none font-[720] text-(--cp-text-secondary)"
:title="updateStreamError || streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-(--cp-success)' : 'bg-(--cp-text-muted)'"
/>
{{ streamStatusLabel }}
</span>
</header>
<div
v-if="reconnecting"
class="rounded-(--cp-input-radius-base) bg-(--cp-info-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-info-text)"
>
{{ reconnectMessage }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">目标镜像</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
:title="updateInfo?.targetImage || '-'"
<BaseScrollbar max-height="260px" view-class="px-4 pb-4">
<div v-if="updateLogRows.length" class="grid gap-2">
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2 font-mono text-[11px] leading-[1.55]"
>
{{ displayValue(updateInfo?.targetImage) }}
</p>
<span class="tabular-nums text-(--cp-text-muted)">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="logMarkerClass(log.level)"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
<span v-if="log.step" class="mr-1 text-(--cp-text-muted)">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
</div>
<div>
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">备份确认</p>
<p class="mt-1.5 mb-0 text-[12px] font-[720] text-(--cp-text-primary)">
{{ updateInfo?.requiresBackup ? '需要' : '不需要' }}
</p>
</div>
</div>
<p v-else class="m-0 py-8 text-center text-[12px] font-[650] text-(--cp-text-muted)">
暂无进度
</p>
</BaseScrollbar>
</section>
<section
v-if="updateInfo?.notes"
class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
>
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<BaseScrollbar max-height="260px" view-class="pr-2">
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
{{ displayValue(updateInfo?.latestVersion) }}
</span>
</div>
<BaseScrollbar max-height="160px" view-class="pr-2">
<pre
class="m-0 whitespace-pre-wrap break-words font-mono text-[11px] leading-[1.65] font-[620] text-(--cp-text-primary)"
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-[620] text-(--cp-text-primary)"
>{{ updateInfo.notes }}</pre
>
</BaseScrollbar>
@@ -308,36 +386,37 @@ onUnmounted(() => {
<BaseButton
variant="default"
:loading="checking"
:disabled="loading"
:disabled="loading || updating || restarting"
@click="handleCheckUpdates(true)"
>
<template #icon>
<RefreshCw class="size-4" />
<RefreshCw class="size-3.5" />
</template>
检查
检查更新
</BaseButton>
<BaseButton variant="default" :loading="rollingBack" @click="handleRollback">
<template #icon>
<RotateCcw class="size-4" />
</template>
回滚
</BaseButton>
<BaseButton variant="default" :loading="restarting" @click="handleRestart">
<BaseButton
v-if="updateSuccess && needRestart"
variant="success"
:loading="restarting"
:disabled="updating"
@click="handleRestart"
>
<template #icon>
<Power class="size-4" />
</template>
重启
{{ restarting ? `重启中 ${restartCountdown}s` : '立即重启' }}
</BaseButton>
<BaseButton
variant="primary"
v-else
variant="success"
:loading="updating"
:disabled="!canUpdate"
@click="handleUpdate"
>
<template #icon>
<PackageCheck class="size-4" />
<ArrowUpCircle class="size-4" />
</template>
一键更新
立即更新
</BaseButton>
</template>
</BaseModal>