mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
feat: support admin online update and deploy flow
This commit is contained in:
+22
-4
@@ -57,10 +57,28 @@ ADMIN_USERNAME=admin123456
|
|||||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||||
|
|
||||||
# 管理后台一键更新(默认开启)
|
# 管理后台一键更新(正式发布版默认可用)
|
||||||
# install.sh 会自动安装 docker-compose.update.yml,并写入:
|
# 正式发布版会下载 GitHub Release 包,校验 SHA256 后切换 /opt/aether/current 并重启。
|
||||||
AETHER_SYSTEM_UPDATE_COMMAND=/opt/aether/compose/update.sh
|
# 源码/本地构建不支持后台在线更新。
|
||||||
AETHER_SYSTEM_UPDATE_WORKDIR=/opt/aether/compose
|
# AETHER_BASE_DIR=/opt/aether
|
||||||
|
# Docker Compose 默认把应用日志输出到 stdout/stderr。
|
||||||
|
# 如需文件日志,可改成 file 或 both,并把可写目录挂载到 /opt/aether/logs。
|
||||||
|
# AETHER_LOG_DESTINATION=stdout
|
||||||
|
# AETHER_LOG_FORMAT=pretty
|
||||||
|
# AETHER_LOG_DIR=/opt/aether/logs
|
||||||
|
# 服务器访问 GitHub 需要代理时可配置;也兼容 UPDATE_PROXY_URL / HTTPS_PROXY / ALL_PROXY / HTTP_PROXY。
|
||||||
|
# 如果 Aether 跑在 Docker 容器里,想走宿主机代理时请写 host.docker.internal,不要写 127.0.0.1。
|
||||||
|
# AETHER_UPDATE_PROXY_URL=http://host.docker.internal:7890
|
||||||
|
# 共享出口触发 GitHub API 限流时可配置只读 token;也兼容 GITHUB_TOKEN / GH_TOKEN。
|
||||||
|
# AETHER_UPDATE_GITHUB_TOKEN=
|
||||||
|
# 下载超时控制:总超时默认 600 秒;连续无响应/无数据默认 30 秒。
|
||||||
|
# AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS=600
|
||||||
|
# AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS=30
|
||||||
|
# 本地联调后台在线更新(配合 docker-compose.release-local.yml):
|
||||||
|
# 会用当前源码构建 release-layout 测试镜像,并伪装成较旧版本以触发升级入口。
|
||||||
|
# AETHER_RELEASE_LOCAL_VERSION=v0.7.0
|
||||||
|
# AETHER_RELEASE_LOCAL_PORT=18085
|
||||||
|
# LOCAL_RELEASE_APP_IMAGE=aether-app:release-local
|
||||||
|
|
||||||
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
||||||
# 推荐计算方式(单实例):
|
# 推荐计算方式(单实例):
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
||||||
|
AETHER_BUILD_TYPE: release
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -272,7 +273,6 @@ jobs:
|
|||||||
chmod 0755 "${root}/install.sh"
|
chmod 0755 "${root}/install.sh"
|
||||||
install -m 0755 update.sh "${root}/update.sh"
|
install -m 0755 update.sh "${root}/update.sh"
|
||||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||||
install -m 0644 docker-compose.update.yml "${root}/docker-compose.update.yml"
|
|
||||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||||
install -m 0755 scripts/migrate-pg-compose-to-single-node.sh "${root}/scripts/migrate-pg-compose-to-single-node.sh"
|
install -m 0755 scripts/migrate-pg-compose-to-single-node.sh "${root}/scripts/migrate-pg-compose-to-single-node.sh"
|
||||||
install -m 0755 scripts/migrate-pg-to-single-node.sh "${root}/scripts/migrate-pg-to-single-node.sh"
|
install -m 0755 scripts/migrate-pg-to-single-node.sh "${root}/scripts/migrate-pg-to-single-node.sh"
|
||||||
|
|||||||
Generated
+1
@@ -266,6 +266,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tar",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tikv-jemallocator",
|
"tikv-jemallocator",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
serde_path_to_error = "0.1"
|
serde_path_to_error = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
tar = "0.4"
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
|
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
|
|||||||
+26
-17
@@ -1,33 +1,42 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Aether Gateway 运行时镜像(交叉编译方案)
|
# Aether Gateway runtime image (cross-compilation)
|
||||||
# 二进制和前端产物均由 CI 预先构建,此 Dockerfile 仅做打包
|
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
|
||||||
# 用法: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
||||||
#
|
#
|
||||||
# 构建上下文中须包含:
|
# Build context must contain:
|
||||||
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl 交叉编译产物)
|
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
|
||||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl 交叉编译产物)
|
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
|
||||||
# dist/frontend/ (npm run build 产物)
|
# dist/frontend/ (npm run build output)
|
||||||
|
|
||||||
FROM docker:27-cli
|
# --- layout stage: create /opt/aether directory structure with symlink ---
|
||||||
|
# distroless has no shell, so we use busybox to set up the symlink.
|
||||||
|
FROM busybox:1.37-musl AS layout
|
||||||
|
|
||||||
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN apk add --no-cache bash
|
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
||||||
|
|
||||||
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
|
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
||||||
COPY dist/frontend/ /srv/frontend
|
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||||
|
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
||||||
|
|
||||||
WORKDIR /app
|
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
||||||
|
|
||||||
|
# --- final stage: distroless runtime ---
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
|
COPY --from=layout --chown=65532:65532 /opt/aether /opt/aether
|
||||||
|
|
||||||
|
WORKDIR /opt/aether
|
||||||
|
|
||||||
ENV RUST_LOG=aether_gateway=info \
|
ENV RUST_LOG=aether_gateway=info \
|
||||||
APP_PORT=8084 \
|
APP_PORT=8084 \
|
||||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||||
|
|
||||||
EXPOSE 8084
|
EXPOSE 8084
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||||
|
|
||||||
USER root
|
USER 65532:65532
|
||||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||||
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
||||||
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
||||||
|
|
||||||
ARG RUST_VERSION=1.95.0
|
ARG RUST_VERSION=1.95.0
|
||||||
|
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||||
|
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||||
|
|
||||||
# ==================== 前端构建 ====================
|
# ==================== 前端构建 ====================
|
||||||
FROM node:22-slim AS frontend-builder
|
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||||
ARG AETHER_BUILD_VERSION
|
ARG AETHER_BUILD_VERSION
|
||||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||||
@@ -18,7 +20,7 @@ COPY frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ==================== Rust gateway 构建 ====================
|
# ==================== Rust gateway 构建 ====================
|
||||||
FROM rust:${RUST_VERSION}-slim AS gateway-base
|
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。
|
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||||
|
# Aether 本地发布版联调镜像
|
||||||
|
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
|
||||||
|
|
||||||
|
ARG RUST_VERSION=1.95.0
|
||||||
|
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||||
|
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||||
|
|
||||||
|
# ==================== 前端构建 ====================
|
||||||
|
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||||
|
ARG AETHER_BUILD_VERSION
|
||||||
|
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||||
|
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||||
|
npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ==================== Rust gateway 构建 ====================
|
||||||
|
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||||
|
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
cmake \
|
||||||
|
git \
|
||||||
|
libclang-dev \
|
||||||
|
libssl-dev \
|
||||||
|
pkg-config \
|
||||||
|
perl
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
|
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||||
|
cargo install cargo-chef --locked
|
||||||
|
|
||||||
|
FROM gateway-base AS gateway-planner
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY apps/ ./apps/
|
||||||
|
COPY crates/ ./crates/
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM gateway-base AS gateway-builder
|
||||||
|
ARG AETHER_BUILD_VERSION
|
||||||
|
ARG AETHER_BUILD_TYPE=release
|
||||||
|
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||||
|
AETHER_VERSION=${AETHER_BUILD_VERSION} \
|
||||||
|
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
|
||||||
|
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||||
|
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
|
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||||
|
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||||
|
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY apps/ ./apps/
|
||||||
|
COPY crates/ ./crates/
|
||||||
|
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
|
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||||
|
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||||
|
cargo build --release --locked -p aether-gateway && \
|
||||||
|
cp target/release/aether-gateway /tmp/aether-gateway
|
||||||
|
|
||||||
|
# ==================== 最小运行时打包 ====================
|
||||||
|
FROM gateway-builder AS runtime-prep
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p \
|
||||||
|
/runtime-root/app/data \
|
||||||
|
/runtime-root/etc \
|
||||||
|
/runtime-root/etc/ssl \
|
||||||
|
/runtime-root/lib \
|
||||||
|
/runtime-root/lib64 \
|
||||||
|
/runtime-root/usr/lib \
|
||||||
|
/runtime-root/opt/aether/logs \
|
||||||
|
/runtime-root/opt/aether/releases/image/bin \
|
||||||
|
/runtime-root/opt/aether/releases/image/frontend; \
|
||||||
|
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
|
||||||
|
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
|
||||||
|
: > /tmp/runtime-libs.txt; \
|
||||||
|
: > /tmp/runtime-scan-queue.txt; \
|
||||||
|
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||||
|
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||||
|
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||||
|
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||||
|
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||||
|
[ -n "$lib" ]; \
|
||||||
|
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||||
|
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||||
|
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
done; \
|
||||||
|
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||||
|
while read -r lib; do \
|
||||||
|
[ -n "$lib" ]; \
|
||||||
|
dest="/runtime-root$(dirname "$lib")"; \
|
||||||
|
mkdir -p "$dest"; \
|
||||||
|
cp -L "$lib" "$dest/"; \
|
||||||
|
done < /tmp/runtime-libs.txt; \
|
||||||
|
for lib in \
|
||||||
|
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||||
|
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||||
|
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||||
|
if [ -f "$lib" ]; then \
|
||||||
|
dest="/runtime-root$(dirname "$lib")"; \
|
||||||
|
mkdir -p "$dest"; \
|
||||||
|
cp -L "$lib" "$dest/"; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||||
|
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||||
|
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||||
|
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||||
|
fi; \
|
||||||
|
if [ -f /etc/nsswitch.conf ]; then \
|
||||||
|
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||||
|
fi
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
|
||||||
|
|
||||||
|
# ==================== 运行时镜像 ====================
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=runtime-prep /runtime-root/ /
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
RUST_LOG=aether_gateway=info \
|
||||||
|
APP_PORT=8084 \
|
||||||
|
AETHER_BASE_DIR=/opt/aether \
|
||||||
|
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||||
|
|
||||||
|
EXPOSE 8084
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||||
@@ -69,14 +69,11 @@ Docker Compose 部署后,可在部署目录直接执行:
|
|||||||
./update.sh --mode single-node
|
./update.sh --mode single-node
|
||||||
```
|
```
|
||||||
|
|
||||||
管理后台右上角“版本信息”会在检测到新版本时显示“立即更新”。通过 `install.sh` 初始化的 Docker Compose 部署会自动安装 `docker-compose.update.yml`,把部署目录和 Docker socket 挂给 `app` 容器,并默认写入:
|
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,再显式设置 `AETHER_LOG_DESTINATION=file|both`,并把一个可写目录挂载到 `/opt/aether/logs`(或同步覆盖 `AETHER_LOG_DIR`)。
|
||||||
|
|
||||||
```bash
|
管理后台右上角“版本信息”会在检测到新版本时显示“立即更新”。正式发布版支持后台内置更新:点击后会下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 Docker restart policy / systemd / launchd 拉起新版本。整个过程不需要挂载 Docker socket,也不需要额外 helper 容器。
|
||||||
AETHER_SYSTEM_UPDATE_COMMAND=/opt/aether/compose/update.sh
|
|
||||||
AETHER_SYSTEM_UPDATE_WORKDIR=/opt/aether/compose
|
|
||||||
```
|
|
||||||
|
|
||||||
如果你是手动部署或用了自定义路径,可以把这两个变量改成你自己的 `update.sh` 所在位置,并确保运行时容器能访问该路径和 `/var/run/docker.sock`。启用后点击“立即更新”会先拉取最新 `app` 镜像,下载完成后按钮会切换为“立即重启”;点击“立即重启”会重建 `app` 容器并应用新版本。这不是运行时热补丁,更新过程中服务会短暂重启。
|
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||||
|
|
||||||
如果是本地源码构建镜像的部署,继续使用:
|
如果是本地源码构建镜像的部署,继续使用:
|
||||||
|
|
||||||
@@ -84,9 +81,32 @@ AETHER_SYSTEM_UPDATE_WORKDIR=/opt/aether/compose
|
|||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.release-local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
||||||
|
|
||||||
|
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
||||||
|
|
||||||
|
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.release-local.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
可选变量:
|
||||||
|
|
||||||
|
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
||||||
|
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||||
|
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||||
|
|
||||||
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/fawney19/Aether.git
|
||||||
cd Aether
|
cd Aether
|
||||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = { workspace = true, features = ["oid"] }
|
sha2 = { workspace = true, features = ["oid"] }
|
||||||
|
tar.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::process::Command;
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-env-changed=AETHER_BUILD_VERSION");
|
println!("cargo:rerun-if-env-changed=AETHER_BUILD_VERSION");
|
||||||
|
println!("cargo:rerun-if-env-changed=AETHER_BUILD_TYPE");
|
||||||
println!("cargo:rerun-if-env-changed=AETHER_VERSION");
|
println!("cargo:rerun-if-env-changed=AETHER_VERSION");
|
||||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
||||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||||
@@ -27,6 +28,12 @@ fn main() {
|
|||||||
.unwrap_or(package_version);
|
.unwrap_or(package_version);
|
||||||
|
|
||||||
println!("cargo:rustc-env=AETHER_BUILD_VERSION={version}");
|
println!("cargo:rustc-env=AETHER_BUILD_VERSION={version}");
|
||||||
|
|
||||||
|
let build_type = env::var("AETHER_BUILD_TYPE")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "source".to_string());
|
||||||
|
println!("cargo:rustc-env=AETHER_BUILD_TYPE={build_type}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git_describe_version() -> Option<String> {
|
fn git_describe_version() -> Option<String> {
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/releases" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"releases",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::GET
|
} else if method == http::Method::GET
|
||||||
&& normalized_path == "/api/admin/system/update-capability"
|
&& normalized_path == "/api/admin/system/update-capability"
|
||||||
{
|
{
|
||||||
@@ -50,6 +58,30 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/rollback" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"rollback",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/update-status" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"update_status",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/update-history" {
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"update_history",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
|
|||||||
@@ -253,6 +253,18 @@ fn classifies_admin_system_update_routes_as_admin_proxy_routes() {
|
|||||||
"/api/admin/system/apply-update",
|
"/api/admin/system/apply-update",
|
||||||
"apply_update",
|
"apply_update",
|
||||||
),
|
),
|
||||||
|
(http::Method::POST, "/api/admin/system/rollback", "rollback"),
|
||||||
|
(http::Method::GET, "/api/admin/system/releases", "releases"),
|
||||||
|
(
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/system/update-history",
|
||||||
|
"update_history",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/system/update-status",
|
||||||
|
"update_status",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (method, path, expected_kind) in cases {
|
for (method, path, expected_kind) in cases {
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use crate::handlers::admin::system::shared::paths::{
|
|||||||
};
|
};
|
||||||
use crate::handlers::admin::system::shared::settings::{
|
use crate::handlers::admin::system::shared::settings::{
|
||||||
apply_admin_system_settings_update, build_admin_api_formats_payload,
|
apply_admin_system_settings_update, build_admin_api_formats_payload,
|
||||||
build_admin_system_check_update_payload_from_release, build_admin_system_settings_payload,
|
build_admin_system_check_update_payload_from_release, build_admin_system_releases_list_payload,
|
||||||
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
|
build_admin_system_settings_payload, build_admin_system_stats_payload, current_aether_version,
|
||||||
|
fetch_admin_system_releases, fetch_latest_admin_system_release, resolve_update_target,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
|
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
|
||||||
use crate::handlers::admin::system::shared::update::{
|
use crate::handlers::admin::system::shared::update::{
|
||||||
build_admin_system_update_capability_payload, prepare_admin_system_update_task,
|
build_admin_system_update_capability_payload, prepare_admin_system_update_task,
|
||||||
|
read_update_history, read_update_task_status, start_admin_system_rollback_task,
|
||||||
start_admin_system_update_task,
|
start_admin_system_update_task,
|
||||||
};
|
};
|
||||||
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||||
@@ -61,7 +63,8 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
|||||||
&& request_method == http::Method::GET
|
&& request_method == http::Method::GET
|
||||||
&& request_path == "/api/admin/system/check-update"
|
&& request_path == "/api/admin/system/check-update"
|
||||||
{
|
{
|
||||||
let (latest_release, error) = fetch_latest_admin_system_release().await;
|
let force = query_flag(request_context.query_string(), "force");
|
||||||
|
let (latest_release, error) = fetch_latest_admin_system_release(force).await;
|
||||||
return Ok(Some(
|
return Ok(Some(
|
||||||
Json(build_admin_system_check_update_payload_from_release(
|
Json(build_admin_system_check_update_payload_from_release(
|
||||||
latest_release,
|
latest_release,
|
||||||
@@ -71,6 +74,17 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("releases")
|
||||||
|
&& request_method == http::Method::GET
|
||||||
|
&& request_path == "/api/admin/system/releases"
|
||||||
|
{
|
||||||
|
let force = query_flag(request_context.query_string(), "force");
|
||||||
|
let (releases, error) = fetch_admin_system_releases(force).await;
|
||||||
|
return Ok(Some(
|
||||||
|
Json(build_admin_system_releases_list_payload(releases, error)).into_response(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if decision.route_kind.as_deref() == Some("update_capability")
|
if decision.route_kind.as_deref() == Some("update_capability")
|
||||||
&& request_method == http::Method::GET
|
&& request_method == http::Method::GET
|
||||||
&& request_path == "/api/admin/system/update-capability"
|
&& request_path == "/api/admin/system/update-capability"
|
||||||
@@ -84,34 +98,99 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
|||||||
&& request_method == http::Method::POST
|
&& request_method == http::Method::POST
|
||||||
&& request_path == "/api/admin/system/prepare-update"
|
&& request_path == "/api/admin/system/prepare-update"
|
||||||
{
|
{
|
||||||
return Ok(Some(match prepare_admin_system_update_task().await? {
|
let target_version = request_body
|
||||||
Ok(payload) => attach_admin_audit_response(
|
.filter(|b| !b.is_empty())
|
||||||
Json(payload).into_response(),
|
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||||
"admin_system_update_prepared",
|
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
|
||||||
"prepare_system_update",
|
|
||||||
"system_update",
|
let (version, tarball_url, sha256sums_url) =
|
||||||
"global",
|
match resolve_update_target(target_version).await {
|
||||||
),
|
Ok(result) => result,
|
||||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
Err((status, payload)) => {
|
||||||
}));
|
return Ok(Some((status, Json(payload)).into_response()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(Some(
|
||||||
|
match prepare_admin_system_update_task(version, tarball_url, sha256sums_url).await? {
|
||||||
|
Ok(payload) => attach_admin_audit_response(
|
||||||
|
Json(payload).into_response(),
|
||||||
|
"admin_system_update_prepared",
|
||||||
|
"prepare_system_update",
|
||||||
|
"system_update",
|
||||||
|
"global",
|
||||||
|
),
|
||||||
|
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if decision.route_kind.as_deref() == Some("apply_update")
|
if decision.route_kind.as_deref() == Some("apply_update")
|
||||||
&& request_method == http::Method::POST
|
&& request_method == http::Method::POST
|
||||||
&& request_path == "/api/admin/system/apply-update"
|
&& request_path == "/api/admin/system/apply-update"
|
||||||
{
|
{
|
||||||
return Ok(Some(match start_admin_system_update_task().await? {
|
let version = request_body
|
||||||
|
.filter(|b| !b.is_empty())
|
||||||
|
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||||
|
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
|
||||||
|
|
||||||
|
return Ok(Some(
|
||||||
|
match start_admin_system_update_task(version).await? {
|
||||||
|
Ok(payload) => attach_admin_audit_response(
|
||||||
|
Json(payload).into_response(),
|
||||||
|
"admin_system_update_started",
|
||||||
|
"apply_system_update",
|
||||||
|
"system_update",
|
||||||
|
"global",
|
||||||
|
),
|
||||||
|
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("rollback")
|
||||||
|
&& request_method == http::Method::POST
|
||||||
|
&& request_path == "/api/admin/system/rollback"
|
||||||
|
{
|
||||||
|
return Ok(Some(match start_admin_system_rollback_task().await? {
|
||||||
Ok(payload) => attach_admin_audit_response(
|
Ok(payload) => attach_admin_audit_response(
|
||||||
Json(payload).into_response(),
|
Json(payload).into_response(),
|
||||||
"admin_system_update_started",
|
"admin_system_rollback_started",
|
||||||
"apply_system_update",
|
"rollback_system_update",
|
||||||
"system_update",
|
"system_rollback",
|
||||||
"global",
|
"global",
|
||||||
),
|
),
|
||||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("update_status")
|
||||||
|
&& request_method == http::Method::GET
|
||||||
|
&& request_path == "/api/admin/system/update-status"
|
||||||
|
{
|
||||||
|
let status = read_update_task_status();
|
||||||
|
return Ok(Some(
|
||||||
|
Json(json!({
|
||||||
|
"phase": status.phase,
|
||||||
|
"error": status.error,
|
||||||
|
"output": status.output,
|
||||||
|
"progress_label": status.progress_label,
|
||||||
|
"downloaded_bytes": status.downloaded_bytes,
|
||||||
|
"total_bytes": status.total_bytes,
|
||||||
|
"progress_percent": status.progress_percent,
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("update_history")
|
||||||
|
&& request_method == http::Method::GET
|
||||||
|
&& request_path == "/api/admin/system/update-history"
|
||||||
|
{
|
||||||
|
let entries = read_update_history();
|
||||||
|
return Ok(Some(Json(json!({ "entries": entries })).into_response()));
|
||||||
|
}
|
||||||
|
|
||||||
if decision.route_kind.as_deref() == Some("aws_regions")
|
if decision.route_kind.as_deref() == Some("aws_regions")
|
||||||
&& request_method == http::Method::GET
|
&& request_method == http::Method::GET
|
||||||
&& request_path == "/api/admin/system/aws-regions"
|
&& request_path == "/api/admin/system/aws-regions"
|
||||||
@@ -976,6 +1055,15 @@ fn query_param(query_string: Option<&str>, name: &str) -> Option<String> {
|
|||||||
.find_map(|(key, value)| (key == name && !value.is_empty()).then(|| value.into_owned()))
|
.find_map(|(key, value)| (key == name && !value.is_empty()).then(|| value.into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn query_flag(query_string: Option<&str>, name: &str) -> bool {
|
||||||
|
query_param(query_string, name).is_some_and(|value| {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>, Response<Body>> {
|
fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>, Response<Body>> {
|
||||||
let Some(value) = query_param(query_string, "older_than_days") else {
|
let Some(value) = query_param(query_string, "older_than_days") else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pub(crate) mod paths;
|
|||||||
pub(crate) mod settings;
|
pub(crate) mod settings;
|
||||||
pub(crate) mod smtp;
|
pub(crate) mod smtp;
|
||||||
pub(crate) mod update;
|
pub(crate) mod update;
|
||||||
|
pub(crate) mod update_client;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::handlers::admin::shared::build_admin_usage_counter_health_payload;
|
use crate::handlers::admin::shared::build_admin_usage_counter_health_payload;
|
||||||
|
use crate::handlers::admin::system::shared::update_client::{
|
||||||
|
build_direct_update_http_client, build_update_http_client,
|
||||||
|
has_explicit_update_proxy_env, update_github_token_from_env,
|
||||||
|
};
|
||||||
use crate::handlers::shared::{system_config_bool, system_config_string};
|
use crate::handlers::shared::{system_config_bool, system_config_string};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::system::{
|
use aether_admin::system::{
|
||||||
build_admin_api_formats_payload as build_admin_api_formats_payload_pure,
|
build_admin_api_formats_payload as build_admin_api_formats_payload_pure,
|
||||||
build_admin_system_check_update_payload as build_admin_system_check_update_payload_pure,
|
build_admin_system_check_update_payload as build_admin_system_check_update_payload_pure,
|
||||||
build_admin_system_check_update_payload_with_release,
|
build_admin_system_check_update_payload_with_release, build_admin_system_releases_payload,
|
||||||
build_admin_system_settings_payload as build_admin_system_settings_payload_pure,
|
build_admin_system_settings_payload as build_admin_system_settings_payload_pure,
|
||||||
build_admin_system_settings_updated_payload,
|
build_admin_system_settings_updated_payload,
|
||||||
build_admin_system_stats_payload as build_admin_system_stats_payload_pure,
|
build_admin_system_stats_payload as build_admin_system_stats_payload_pure,
|
||||||
@@ -15,17 +19,18 @@ use axum::body::Bytes;
|
|||||||
use axum::http;
|
use axum::http;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
#[cfg(not(test))]
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const AETHER_RELEASES_API_URL: &str =
|
const AETHER_RELEASES_API_URL: &str =
|
||||||
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
||||||
|
const AETHER_RELEASE_TAG_URL_BASE: &str = "https://github.com/fawney19/Aether/releases/tag";
|
||||||
|
const SOURCE_BUILD_UPDATE_BLOCKER: &str = "当前为源码构建,请使用 git pull 后重新编译。";
|
||||||
|
const SOURCE_BUILD_RELEASE_BLOCKER: &str = "当前为源码构建,请手动切换到对应标签后重新编译。";
|
||||||
|
|
||||||
/// Minimum interval between actual GitHub API requests. Within this window
|
/// Minimum interval between actual GitHub API requests. Within this window
|
||||||
/// the cached result is reused.
|
/// the cached result is reused.
|
||||||
#[cfg(not(test))]
|
const RELEASE_CACHE_TTL: Duration = Duration::from_secs(1200);
|
||||||
const RELEASE_CACHE_TTL: Duration = Duration::from_secs(300);
|
|
||||||
|
|
||||||
pub(crate) fn current_aether_version() -> String {
|
pub(crate) fn current_aether_version() -> String {
|
||||||
option_env!("AETHER_BUILD_VERSION")
|
option_env!("AETHER_BUILD_VERSION")
|
||||||
@@ -42,86 +47,382 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
|
|||||||
latest_release: Option<AdminSystemUpdateRelease>,
|
latest_release: Option<AdminSystemUpdateRelease>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
build_admin_system_check_update_payload_with_release(
|
let mut payload = build_admin_system_check_update_payload_with_release(
|
||||||
current_aether_version(),
|
current_aether_version(),
|
||||||
latest_release,
|
latest_release,
|
||||||
error,
|
error,
|
||||||
)
|
);
|
||||||
|
apply_source_build_check_update_override(&mut payload, current_build_is_release());
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_admin_system_releases_list_payload(
|
||||||
|
releases: Vec<AdminSystemUpdateRelease>,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut payload = build_admin_system_releases_payload(current_aether_version(), releases, error);
|
||||||
|
apply_source_build_releases_override(&mut payload, current_build_is_release());
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_build_is_release() -> bool {
|
||||||
|
option_env!("AETHER_BUILD_TYPE").unwrap_or("source") == "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_source_build_check_update_override(payload: &mut Value, release_build: bool) {
|
||||||
|
if release_build {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if payload.get("has_update").and_then(Value::as_bool) != Some(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["updatable"] = json!(false);
|
||||||
|
payload["update_blocker"] = json!(SOURCE_BUILD_UPDATE_BLOCKER);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_source_build_releases_override(payload: &mut Value, release_build: bool) {
|
||||||
|
if release_build {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(releases) = payload.get_mut("releases").and_then(Value::as_array_mut) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for release in releases {
|
||||||
|
if release.get("is_current").and_then(Value::as_bool) == Some(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
release["updatable"] = json!(false);
|
||||||
|
if release.get("update_blocker").is_none() || release["update_blocker"].is_null() {
|
||||||
|
release["update_blocker"] = json!(SOURCE_BUILD_RELEASE_BLOCKER);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
pub(crate) async fn fetch_latest_admin_system_release(
|
struct CachedReleases {
|
||||||
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
all: Vec<AdminSystemUpdateRelease>,
|
||||||
use std::sync::Mutex;
|
latest: Option<AdminSystemUpdateRelease>,
|
||||||
use std::time::Instant;
|
error: Option<String>,
|
||||||
|
fetched_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
struct CachedRelease {
|
#[cfg(not(test))]
|
||||||
result: (Option<AdminSystemUpdateRelease>, Option<String>),
|
fn releases_cache() -> &'static std::sync::Mutex<Option<CachedReleases>> {
|
||||||
fetched_at: Instant,
|
static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<CachedReleases>>> =
|
||||||
}
|
std::sync::OnceLock::new();
|
||||||
|
CACHE.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
static CACHE: std::sync::OnceLock<Mutex<Option<CachedRelease>>> = std::sync::OnceLock::new();
|
}
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(None));
|
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
async fn ensure_releases_cached(force: bool) {
|
||||||
{
|
{
|
||||||
if let Ok(guard) = cache.lock() {
|
if let Ok(guard) = releases_cache().lock() {
|
||||||
if let Some(cached) = guard.as_ref() {
|
if let Some(cached) = guard.as_ref() {
|
||||||
if cached.fetched_at.elapsed() < RELEASE_CACHE_TTL {
|
if should_reuse_releases_cache(
|
||||||
return cached.result.clone();
|
force,
|
||||||
|
cached.error.is_some(),
|
||||||
|
cached.fetched_at.elapsed(),
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match fetch_latest_admin_system_release_inner().await {
|
let (all, latest, error) = match fetch_admin_system_releases_inner().await {
|
||||||
Ok(release) => (release, None),
|
Ok(releases) => {
|
||||||
Err(err) => (None, Some(err)),
|
let latest = releases.first().cloned();
|
||||||
|
(releases, latest, None)
|
||||||
|
}
|
||||||
|
Err(err) => (Vec::new(), None, Some(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(mut guard) = cache.lock() {
|
if let Ok(mut guard) = releases_cache().lock() {
|
||||||
*guard = Some(CachedRelease {
|
*guard = Some(CachedReleases {
|
||||||
result: result.clone(),
|
all,
|
||||||
fetched_at: Instant::now(),
|
latest,
|
||||||
|
error,
|
||||||
|
fetched_at: std::time::Instant::now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
fn should_reuse_releases_cache(force: bool, has_error: bool, age: Duration) -> bool {
|
||||||
|
!force && !has_error && age < RELEASE_CACHE_TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) async fn fetch_latest_admin_system_release(
|
||||||
|
force: bool,
|
||||||
|
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
||||||
|
ensure_releases_cached(force).await;
|
||||||
|
if let Ok(guard) = releases_cache().lock() {
|
||||||
|
if let Some(cached) = guard.as_ref() {
|
||||||
|
return (cached.latest.clone(), cached.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) async fn fetch_admin_system_releases(
|
||||||
|
force: bool,
|
||||||
|
) -> (Vec<AdminSystemUpdateRelease>, Option<String>) {
|
||||||
|
ensure_releases_cached(force).await;
|
||||||
|
if let Ok(guard) = releases_cache().lock() {
|
||||||
|
if let Some(cached) = guard.as_ref() {
|
||||||
|
return (cached.all.clone(), cached.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Vec::new(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn fetch_admin_system_releases(
|
||||||
|
_force: bool,
|
||||||
|
) -> (Vec<AdminSystemUpdateRelease>, Option<String>) {
|
||||||
|
(
|
||||||
|
Vec::new(),
|
||||||
|
Some("测试环境未请求 GitHub Releases".to_string()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn fetch_latest_admin_system_release(
|
pub(crate) async fn fetch_latest_admin_system_release(
|
||||||
|
_force: bool,
|
||||||
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
||||||
(None, Some("测试环境未请求 GitHub Releases".to_string()))
|
(None, Some("测试环境未请求 GitHub Releases".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
async fn fetch_latest_admin_system_release_inner(
|
pub(crate) async fn resolve_update_target(
|
||||||
) -> Result<Option<AdminSystemUpdateRelease>, String> {
|
version: Option<String>,
|
||||||
let releases: Vec<GitHubRelease> = reqwest::Client::builder()
|
) -> Result<(String, String, Option<String>), (http::StatusCode, serde_json::Value)> {
|
||||||
.timeout(Duration::from_secs(8))
|
let (releases, error) = fetch_admin_system_releases(false).await;
|
||||||
.build()
|
if releases.is_empty() {
|
||||||
.map_err(|err| format!("创建更新检查客户端失败: {err}"))?
|
return Err((
|
||||||
.get(AETHER_RELEASES_API_URL)
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
.header(reqwest::header::USER_AGENT, "Aether-Gateway update-check")
|
json!({ "detail": error.unwrap_or_else(|| "无法获取版本信息".to_string()) }),
|
||||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
));
|
||||||
.send()
|
}
|
||||||
.await
|
|
||||||
.map_err(|err| format!("请求 GitHub Releases 失败: {err}"))?
|
let release = match version {
|
||||||
.error_for_status()
|
Some(ref v) => releases.into_iter().find(|r| r.version == *v),
|
||||||
.map_err(|err| format!("GitHub Releases 返回错误: {err}"))?
|
None => releases.into_iter().next(),
|
||||||
.json()
|
};
|
||||||
.await
|
|
||||||
.map_err(|err| format!("解析 GitHub Releases 失败: {err}"))?;
|
let release = release.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
http::StatusCode::NOT_FOUND,
|
||||||
|
json!({ "detail": "未找到指定版本" }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tarball_url = release.tarball_url.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
http::StatusCode::PRECONDITION_REQUIRED,
|
||||||
|
json!({ "detail": format!("版本 {} 没有适用于当前平台的安装包", release.version) }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let sha256sums_url = release.sha256sums_url.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
http::StatusCode::PRECONDITION_REQUIRED,
|
||||||
|
json!({ "detail": format!("版本 {} 缺少 SHA256SUMS 校验文件,已拒绝在线更新", release.version) }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((release.version, tarball_url, Some(sha256sums_url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn resolve_update_target(
|
||||||
|
_version: Option<String>,
|
||||||
|
) -> Result<(String, String, Option<String>), (http::StatusCode, serde_json::Value)> {
|
||||||
|
Err((
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
json!({ "detail": "测试环境不支持更新" }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
async fn fetch_admin_system_releases_inner() -> Result<Vec<AdminSystemUpdateRelease>, String> {
|
||||||
|
let current_channel = update_channel_for_version(¤t_aether_version());
|
||||||
|
let timeout = Duration::from_secs(8);
|
||||||
|
let github_token = update_github_token_from_env();
|
||||||
|
let client = build_update_http_client(timeout, "更新检查")?;
|
||||||
|
let releases = match fetch_github_releases_with_client(&client, github_token.as_deref()).await
|
||||||
|
{
|
||||||
|
Ok(releases) => releases,
|
||||||
|
Err(err) if err.rate_limited && !has_explicit_update_proxy_env() => {
|
||||||
|
let direct_client = build_direct_update_http_client(timeout, "更新检查直连重试")?;
|
||||||
|
fetch_github_releases_with_client(&direct_client, github_token.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|retry_err| retry_err.message)?
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.message),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(releases
|
Ok(releases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|release| !release.draft && !release.prerelease && release.tag_name.starts_with('v'))
|
.filter(|release| should_include_release_for_channel(release, current_channel))
|
||||||
.map(|release| AdminSystemUpdateRelease {
|
.map(|release| {
|
||||||
version: release.tag_name,
|
let (tarball_url, sha256sums_url) = select_release_tarball_urls(&release);
|
||||||
release_url: Some(release.html_url),
|
AdminSystemUpdateRelease {
|
||||||
release_notes: release.body.filter(|body| !body.trim().is_empty()),
|
version: release.tag_name.clone(),
|
||||||
published_at: release.published_at,
|
release_url: Some(github_release_tag_url(&release.tag_name)),
|
||||||
}))
|
release_notes: release.body.filter(|body| !body.trim().is_empty()),
|
||||||
|
published_at: release.published_at,
|
||||||
|
tarball_url,
|
||||||
|
sha256sums_url,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct GitHubReleaseFetchError {
|
||||||
|
message: String,
|
||||||
|
rate_limited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
async fn fetch_github_releases_with_client(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
github_token: Option<&str>,
|
||||||
|
) -> Result<Vec<GitHubRelease>, GitHubReleaseFetchError> {
|
||||||
|
let mut request = client
|
||||||
|
.get(AETHER_RELEASES_API_URL)
|
||||||
|
.header(reqwest::header::USER_AGENT, "Aether-Gateway update-check")
|
||||||
|
.header(reqwest::header::ACCEPT, "application/vnd.github+json");
|
||||||
|
if let Some(token) = github_token {
|
||||||
|
request = request.bearer_auth(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|err| GitHubReleaseFetchError {
|
||||||
|
message: format!("请求 GitHub Releases 失败: {err}"),
|
||||||
|
rate_limited: false,
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(github_release_response_error(status, &body));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json().await.map_err(|err| GitHubReleaseFetchError {
|
||||||
|
message: format!("解析 GitHub Releases 失败: {err}"),
|
||||||
|
rate_limited: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn github_release_response_error(
|
||||||
|
status: reqwest::StatusCode,
|
||||||
|
response_body: &str,
|
||||||
|
) -> GitHubReleaseFetchError {
|
||||||
|
if is_github_rate_limit_error(status, response_body) {
|
||||||
|
return GitHubReleaseFetchError {
|
||||||
|
message: "GitHub Releases API 已触发限流;当前共享代理出口的匿名额度已用尽,更新检查将自动尝试直连。若仍失败,请配置 AETHER_UPDATE_GITHUB_TOKEN / GITHUB_TOKEN / GH_TOKEN,或为 GitHub 更新检查单独设置可用代理。".to_string(),
|
||||||
|
rate_limited: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = parse_github_error_message(response_body);
|
||||||
|
let message = if detail.is_empty() {
|
||||||
|
format!("GitHub Releases 返回错误: HTTP {status}")
|
||||||
|
} else {
|
||||||
|
format!("GitHub Releases 返回错误: HTTP {status}; {detail}")
|
||||||
|
};
|
||||||
|
GitHubReleaseFetchError {
|
||||||
|
message,
|
||||||
|
rate_limited: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_github_rate_limit_error(status: reqwest::StatusCode, response_body: &str) -> bool {
|
||||||
|
status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
&& response_body
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_github_error_message(response_body: &str) -> String {
|
||||||
|
serde_json::from_str::<serde_json::Value>(response_body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|message| message.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|message| !message.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn should_include_release_for_channel(
|
||||||
|
release: &GitHubRelease,
|
||||||
|
current_channel: UpdateChannel,
|
||||||
|
) -> bool {
|
||||||
|
if release.draft || !release.tag_name.starts_with('v') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !release.prerelease {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current_channel.allows_prerelease(&release.tag_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum UpdateChannel {
|
||||||
|
Stable,
|
||||||
|
Rc,
|
||||||
|
Beta,
|
||||||
|
OtherPrerelease,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateChannel {
|
||||||
|
fn allows_prerelease(self, release_version: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Stable => false,
|
||||||
|
Self::Rc => update_channel_for_version(release_version) == Self::Rc,
|
||||||
|
Self::Beta => update_channel_for_version(release_version) == Self::Beta,
|
||||||
|
Self::OtherPrerelease => {
|
||||||
|
matches!(
|
||||||
|
update_channel_for_version(release_version),
|
||||||
|
Self::Rc | Self::Beta | Self::OtherPrerelease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_channel_for_version(version: &str) -> UpdateChannel {
|
||||||
|
let normalized = version
|
||||||
|
.trim()
|
||||||
|
.strip_prefix('v')
|
||||||
|
.or_else(|| version.trim().strip_prefix('V'))
|
||||||
|
.unwrap_or(version.trim());
|
||||||
|
let Some((_, prerelease)) = normalized.split_once('-') else {
|
||||||
|
return UpdateChannel::Stable;
|
||||||
|
};
|
||||||
|
let prerelease = prerelease.to_ascii_lowercase();
|
||||||
|
if prerelease.starts_with("rc") {
|
||||||
|
UpdateChannel::Rc
|
||||||
|
} else if prerelease.starts_with("beta") {
|
||||||
|
UpdateChannel::Beta
|
||||||
|
} else {
|
||||||
|
UpdateChannel::OtherPrerelease
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GitHubReleaseAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
@@ -137,6 +438,175 @@ struct GitHubRelease {
|
|||||||
draft: bool,
|
draft: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
prerelease: bool,
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GitHubReleaseAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn github_release_tag_url(tag_name: &str) -> String {
|
||||||
|
format!("{AETHER_RELEASE_TAG_URL_BASE}/{tag_name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn select_release_tarball_urls(release: &GitHubRelease) -> (Option<String>, Option<String>) {
|
||||||
|
let platform = if cfg!(target_os = "macos") {
|
||||||
|
"macos"
|
||||||
|
} else {
|
||||||
|
"linux"
|
||||||
|
};
|
||||||
|
let arch = if cfg!(target_arch = "aarch64") {
|
||||||
|
"arm64"
|
||||||
|
} else {
|
||||||
|
"amd64"
|
||||||
|
};
|
||||||
|
let expected_name = format!("aether-{}-{}-{}.tar.gz", release.tag_name, platform, arch);
|
||||||
|
|
||||||
|
let tarball_url = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == expected_name)
|
||||||
|
.map(|a| a.browser_download_url.clone());
|
||||||
|
|
||||||
|
let sha256sums_url = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == "SHA256SUMS")
|
||||||
|
.map(|a| a.browser_download_url.clone());
|
||||||
|
|
||||||
|
(tarball_url, sha256sums_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_channel_detects_stable_rc_beta_and_other_prerelease() {
|
||||||
|
assert_eq!(update_channel_for_version("v1.2.3"), UpdateChannel::Stable);
|
||||||
|
assert_eq!(update_channel_for_version("1.2.3-rc1"), UpdateChannel::Rc);
|
||||||
|
assert_eq!(
|
||||||
|
update_channel_for_version("1.2.3-beta.2"),
|
||||||
|
UpdateChannel::Beta
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_channel_for_version("1.2.3-alpha.1"),
|
||||||
|
UpdateChannel::OtherPrerelease
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_channel_does_not_allow_prereleases() {
|
||||||
|
assert!(!UpdateChannel::Stable.allows_prerelease("v1.2.3-rc1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prerelease_channels_only_follow_matching_channel() {
|
||||||
|
assert!(UpdateChannel::Rc.allows_prerelease("v1.2.3-rc2"));
|
||||||
|
assert!(!UpdateChannel::Rc.allows_prerelease("v1.2.3-beta.1"));
|
||||||
|
assert!(UpdateChannel::Beta.allows_prerelease("v1.2.3-beta.2"));
|
||||||
|
assert!(!UpdateChannel::Beta.allows_prerelease("v1.2.3-rc1"));
|
||||||
|
assert!(UpdateChannel::OtherPrerelease.allows_prerelease("v1.2.3-alpha.1"));
|
||||||
|
assert!(UpdateChannel::OtherPrerelease.allows_prerelease("v1.2.3-rc1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_release_tag_url_points_to_explicit_tag_page() {
|
||||||
|
assert_eq!(
|
||||||
|
github_release_tag_url("v0.7.3"),
|
||||||
|
"https://github.com/fawney19/Aether/releases/tag/v0.7.3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_release_cache_is_reused_within_ttl() {
|
||||||
|
assert!(should_reuse_releases_cache(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Duration::from_secs(60)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_release_cache_is_not_reused() {
|
||||||
|
assert!(!should_reuse_releases_cache(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Duration::from_secs(60)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_refresh_bypasses_release_cache() {
|
||||||
|
assert!(!should_reuse_releases_cache(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Duration::from_secs(60)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_rate_limit_response_is_marked_retryable() {
|
||||||
|
let err = github_release_response_error(
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
r#"{"message":"API rate limit exceeded for 1.2.3.4."}"#,
|
||||||
|
);
|
||||||
|
assert!(err.rate_limited);
|
||||||
|
assert!(err.message.contains("GitHub Releases API 已触发限流"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_rate_limit_github_response_keeps_http_status_message() {
|
||||||
|
let err = github_release_response_error(
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
r#"{"message":"Resource not accessible"}"#,
|
||||||
|
);
|
||||||
|
assert!(!err.rate_limited);
|
||||||
|
assert!(err.message.contains("HTTP 403 Forbidden"));
|
||||||
|
assert!(err.message.contains("Resource not accessible"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_build_check_update_override_marks_latest_release_non_updatable() {
|
||||||
|
let mut payload = json!({
|
||||||
|
"has_update": true,
|
||||||
|
"updatable": true,
|
||||||
|
"update_blocker": serde_json::Value::Null
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_source_build_check_update_override(&mut payload, false);
|
||||||
|
|
||||||
|
assert_eq!(payload["updatable"], false);
|
||||||
|
assert_eq!(payload["update_blocker"], SOURCE_BUILD_UPDATE_BLOCKER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_build_releases_override_marks_non_current_entries_non_updatable() {
|
||||||
|
let mut payload = json!({
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "v0.7.3",
|
||||||
|
"is_current": false,
|
||||||
|
"updatable": true,
|
||||||
|
"update_blocker": serde_json::Value::Null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v0.7.2",
|
||||||
|
"is_current": true,
|
||||||
|
"updatable": true,
|
||||||
|
"update_blocker": "当前版本"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_source_build_releases_override(&mut payload, false);
|
||||||
|
|
||||||
|
assert_eq!(payload["releases"][0]["updatable"], false);
|
||||||
|
assert_eq!(
|
||||||
|
payload["releases"][0]["update_blocker"],
|
||||||
|
SOURCE_BUILD_RELEASE_BLOCKER
|
||||||
|
);
|
||||||
|
assert_eq!(payload["releases"][1]["updatable"], true);
|
||||||
|
assert_eq!(payload["releases"][1]["update_blocker"], "当前版本");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn build_admin_system_stats_payload(
|
pub(crate) async fn build_admin_system_stats_payload(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const EXPLICIT_UPDATE_PROXY_ENV_KEYS: &[&str] = &["AETHER_UPDATE_PROXY_URL", "UPDATE_PROXY_URL"];
|
||||||
|
|
||||||
|
const UPDATE_PROXY_ENV_KEYS: &[&str] = &[
|
||||||
|
"AETHER_UPDATE_PROXY_URL",
|
||||||
|
"UPDATE_PROXY_URL",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
const UPDATE_GITHUB_TOKEN_ENV_KEYS: &[&str] =
|
||||||
|
&["AETHER_UPDATE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"];
|
||||||
|
|
||||||
|
pub(crate) fn build_update_http_client(
|
||||||
|
timeout: Duration,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<reqwest::Client, String> {
|
||||||
|
let mut builder = base_update_http_client_builder(timeout);
|
||||||
|
if let Some(proxy_url) = update_proxy_url_from_env() {
|
||||||
|
let proxy = reqwest::Proxy::all(proxy_url)
|
||||||
|
.map_err(|_| format!("创建{label}代理失败,请检查更新代理环境变量"))?
|
||||||
|
.no_proxy(reqwest::NoProxy::from_env());
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("创建{label}客户端失败: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_direct_update_http_client(
|
||||||
|
timeout: Duration,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<reqwest::Client, String> {
|
||||||
|
base_update_http_client_builder(timeout)
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("创建{label}客户端失败: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_explicit_update_proxy_env() -> bool {
|
||||||
|
read_nonempty_env_value(EXPLICIT_UPDATE_PROXY_ENV_KEYS).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_update_http_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
|
||||||
|
reqwest::Client::builder().timeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_proxy_url_from_env() -> Option<String> {
|
||||||
|
read_nonempty_env_value(UPDATE_PROXY_ENV_KEYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_github_token_from_env() -> Option<String> {
|
||||||
|
read_nonempty_env_value(UPDATE_GITHUB_TOKEN_ENV_KEYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_nonempty_env_value(keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter().find_map(|key| {
|
||||||
|
std::env::var(key)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::Method;
|
use axum::http::header::{CACHE_CONTROL, EXPIRES, PRAGMA};
|
||||||
|
use axum::http::{HeaderValue, Method};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::any;
|
use axum::routing::any;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -112,7 +113,16 @@ async fn serve_static_asset(static_dir: &PathBuf, request: Request) -> Response
|
|||||||
|
|
||||||
async fn serve_frontend_index(index_html: &PathBuf, request: Request) -> Response {
|
async fn serve_frontend_index(index_html: &PathBuf, request: Request) -> Response {
|
||||||
match ServeFile::new(index_html).oneshot(request).await {
|
match ServeFile::new(index_html).oneshot(request).await {
|
||||||
Ok(response) => response.into_response(),
|
Ok(mut response) => {
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-store, no-cache, must-revalidate"),
|
||||||
|
);
|
||||||
|
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
|
||||||
|
headers.insert(EXPIRES, HeaderValue::from_static("0"));
|
||||||
|
response.into_response()
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, "failed to serve frontend index");
|
warn!(error = %err, "failed to serve frontend index");
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
|||||||
@@ -36,69 +36,7 @@ use crate::constants::{
|
|||||||
};
|
};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
|
||||||
struct TestEnvVarGuard {
|
static SYSTEM_UPDATE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
key: &'static str,
|
|
||||||
previous: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestUpdateCommand {
|
|
||||||
path: std::path::PathBuf,
|
|
||||||
log_path: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestUpdateCommand {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_file(&self.path);
|
|
||||||
let _ = std::fs::remove_file(&self.log_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestEnvVarGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(previous) = self.previous.as_deref() {
|
|
||||||
std::env::set_var(self.key, previous);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var(self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_test_env_var(key: &'static str, value: &str) -> TestEnvVarGuard {
|
|
||||||
let previous = std::env::var(key).ok();
|
|
||||||
std::env::set_var(key, value);
|
|
||||||
TestEnvVarGuard { key, previous }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_test_update_command() -> TestUpdateCommand {
|
|
||||||
let suffix = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("system time should be valid")
|
|
||||||
.as_nanos();
|
|
||||||
let temp_dir = std::env::temp_dir();
|
|
||||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
|
||||||
let path = temp_dir.join(format!("aether-update-test-{suffix}.{extension}"));
|
|
||||||
let log_path = temp_dir.join(format!("aether-update-test-{suffix}.log"));
|
|
||||||
let log_path_text = log_path.to_string_lossy();
|
|
||||||
let content = if cfg!(windows) {
|
|
||||||
format!("@echo off\r\necho %*>>\"{log_path_text}\"\r\nexit /b 0\r\n")
|
|
||||||
} else {
|
|
||||||
let escaped_log_path = log_path_text.replace('"', "\\\"");
|
|
||||||
format!("#!/usr/bin/env sh\nprintf '%s\\n' \"$*\" >> \"{escaped_log_path}\"\n")
|
|
||||||
};
|
|
||||||
std::fs::write(&path, content).expect("test update command should be written");
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let mut permissions = std::fs::metadata(&path)
|
|
||||||
.expect("test update command metadata should be readable")
|
|
||||||
.permissions();
|
|
||||||
permissions.set_mode(0o755);
|
|
||||||
std::fs::set_permissions(&path, permissions)
|
|
||||||
.expect("test update command should be executable");
|
|
||||||
}
|
|
||||||
|
|
||||||
TestUpdateCommand { path, log_path }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||||
@@ -224,6 +162,9 @@ async fn gateway_handles_admin_system_check_update_locally_with_bearer_admin_ses
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_system_update_capability_locally() {
|
async fn gateway_handles_admin_system_update_capability_locally() {
|
||||||
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -253,8 +194,9 @@ async fn gateway_handles_admin_system_update_capability_locally() {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["command_env"], "AETHER_SYSTEM_UPDATE_COMMAND");
|
assert!(payload["supported"].is_boolean());
|
||||||
assert!(payload["enabled"].is_boolean());
|
assert!(payload["build_type"].is_string());
|
||||||
|
assert!(payload["task_status"].is_string());
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -263,15 +205,9 @@ async fn gateway_handles_admin_system_update_capability_locally() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_prepares_admin_system_update_locally() {
|
async fn gateway_prepares_admin_system_update_locally() {
|
||||||
let command = create_test_update_command();
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
let _command_guard = set_test_env_var(
|
.lock()
|
||||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
command
|
|
||||||
.path
|
|
||||||
.to_str()
|
|
||||||
.expect("test command path should be utf-8"),
|
|
||||||
);
|
|
||||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -299,14 +235,9 @@ async fn gateway_prepares_admin_system_update_locally() {
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["need_restart"], json!(true));
|
assert!(payload["detail"].is_string());
|
||||||
assert!(payload["message"]
|
|
||||||
.as_str()
|
|
||||||
.is_some_and(|value| value.contains("立即重启")));
|
|
||||||
let command_log = std::fs::read_to_string(&command.log_path).expect("test command should run");
|
|
||||||
assert!(command_log.contains("--prepare"));
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -314,7 +245,10 @@ async fn gateway_prepares_admin_system_update_locally() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_rejects_admin_system_apply_update_without_config_locally() {
|
async fn gateway_rejects_admin_system_apply_update_without_prepared_version() {
|
||||||
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().route(
|
let upstream = Router::new().route(
|
||||||
@@ -342,11 +276,9 @@ async fn gateway_rejects_admin_system_apply_update_without_config_locally() {
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert!(payload["detail"]
|
assert!(payload["detail"].is_string());
|
||||||
.as_str()
|
|
||||||
.is_some_and(|value| value.contains("AETHER_SYSTEM_UPDATE_COMMAND")));
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -354,17 +286,29 @@ async fn gateway_rejects_admin_system_apply_update_without_config_locally() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_rejects_admin_system_apply_update_when_command_path_is_inaccessible() {
|
async fn gateway_rejects_admin_system_rollback_without_previous_release() {
|
||||||
let _command_guard = set_test_env_var(
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
.lock()
|
||||||
"/definitely/missing/aether-update.sh",
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/system/rollback",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
.post(format!("{gateway_url}/api/admin/system/rollback"))
|
||||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
@@ -375,9 +319,118 @@ async fn gateway_rejects_admin_system_apply_update_when_command_path_is_inaccess
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert!(payload["detail"]
|
assert!(payload["detail"].is_string());
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_system_releases_locally() {
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/system/releases",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}/api/admin/system/releases"))
|
||||||
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload["current_version"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.is_some_and(|value| value.contains("路径不可访问")));
|
.is_some_and(|value| !value.is_empty()));
|
||||||
|
assert!(payload["releases"].is_array());
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_rejects_admin_system_apply_update_with_nonexistent_version() {
|
||||||
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/system/apply-update",
|
||||||
|
any(move |_request: Request| {
|
||||||
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||||
|
async move {
|
||||||
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||||
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"version":"v99.99.99"}"#)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload["detail"].is_string());
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_system_update_status_locally() {
|
||||||
|
let _lock = SYSTEM_UPDATE_TEST_MUTEX
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}/api/admin/system/update-status"))
|
||||||
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload["phase"].is_string());
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api(
|
|||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("cache-control")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("no-store, no-cache, must-revalidate")
|
||||||
|
);
|
||||||
let content_type = response
|
let content_type = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
@@ -161,6 +168,13 @@ async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api(
|
|||||||
.await
|
.await
|
||||||
.expect("spa request should succeed");
|
.expect("spa request should succeed");
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("cache-control")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("no-store, no-cache, must-revalidate")
|
||||||
|
);
|
||||||
let body = response.text().await.expect("spa body should be readable");
|
let body = response.text().await.expect("spa body should be readable");
|
||||||
assert!(body.contains("Aether Frontend"));
|
assert!(body.contains("Aether Frontend"));
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ pub struct AdminSystemUpdateRelease {
|
|||||||
pub release_url: Option<String>,
|
pub release_url: Option<String>,
|
||||||
pub release_notes: Option<String>,
|
pub release_notes: Option<String>,
|
||||||
pub published_at: Option<String>,
|
pub published_at: Option<String>,
|
||||||
|
pub tarball_url: Option<String>,
|
||||||
|
pub sha256sums_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -781,11 +783,19 @@ pub fn build_admin_system_check_update_payload_with_release(
|
|||||||
let has_update = latest_release
|
let has_update = latest_release
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|release| admin_system_update_available(¤t_version, &release.version));
|
.is_some_and(|release| admin_system_update_available(¤t_version, &release.version));
|
||||||
|
let update_blocker = latest_release
|
||||||
|
.as_ref()
|
||||||
|
.and_then(admin_system_update_blocker);
|
||||||
|
let updatable = latest_release
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|release| admin_system_update_blocker(release).is_none());
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"current_version": current_version,
|
"current_version": current_version,
|
||||||
"latest_version": latest_release.as_ref().map(|release| release.version.clone()),
|
"latest_version": latest_release.as_ref().map(|release| release.version.clone()),
|
||||||
"has_update": has_update,
|
"has_update": has_update,
|
||||||
|
"updatable": updatable,
|
||||||
|
"update_blocker": update_blocker,
|
||||||
"release_url": latest_release.as_ref().and_then(|release| release.release_url.clone()),
|
"release_url": latest_release.as_ref().and_then(|release| release.release_url.clone()),
|
||||||
"release_notes": latest_release.as_ref().and_then(|release| release.release_notes.clone()),
|
"release_notes": latest_release.as_ref().and_then(|release| release.release_notes.clone()),
|
||||||
"published_at": latest_release.as_ref().and_then(|release| release.published_at.clone()),
|
"published_at": latest_release.as_ref().and_then(|release| release.published_at.clone()),
|
||||||
@@ -793,6 +803,53 @@ pub fn build_admin_system_check_update_payload_with_release(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_admin_system_releases_payload(
|
||||||
|
current_version: String,
|
||||||
|
releases: Vec<AdminSystemUpdateRelease>,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let entries: Vec<serde_json::Value> = releases
|
||||||
|
.iter()
|
||||||
|
.map(|release| {
|
||||||
|
let is_current = {
|
||||||
|
let norm_current = normalized_admin_system_version(¤t_version);
|
||||||
|
let norm_release = normalized_admin_system_version(&release.version);
|
||||||
|
norm_current == norm_release
|
||||||
|
};
|
||||||
|
let is_newer = admin_system_update_available(¤t_version, &release.version);
|
||||||
|
let update_blocker = admin_system_update_blocker(release);
|
||||||
|
json!({
|
||||||
|
"version": release.version,
|
||||||
|
"release_url": release.release_url,
|
||||||
|
"release_notes": release.release_notes,
|
||||||
|
"published_at": release.published_at,
|
||||||
|
"tarball_url": release.tarball_url,
|
||||||
|
"sha256sums_url": release.sha256sums_url,
|
||||||
|
"is_current": is_current,
|
||||||
|
"is_newer": is_newer,
|
||||||
|
"updatable": update_blocker.is_none(),
|
||||||
|
"update_blocker": update_blocker,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"current_version": current_version,
|
||||||
|
"releases": entries,
|
||||||
|
"error": error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_system_update_blocker(release: &AdminSystemUpdateRelease) -> Option<&'static str> {
|
||||||
|
if release.tarball_url.is_none() {
|
||||||
|
Some("当前平台暂无安装包")
|
||||||
|
} else if release.sha256sums_url.is_none() {
|
||||||
|
Some("缺少 SHA256SUMS 校验文件")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalized_admin_system_version(version: &str) -> String {
|
fn normalized_admin_system_version(version: &str) -> String {
|
||||||
let trimmed = version.trim();
|
let trimmed = version.trim();
|
||||||
trimmed
|
trimmed
|
||||||
@@ -2576,6 +2633,8 @@ mod tests {
|
|||||||
),
|
),
|
||||||
release_notes: Some("release notes".to_string()),
|
release_notes: Some("release notes".to_string()),
|
||||||
published_at: Some("2026-05-13T00:00:00Z".to_string()),
|
published_at: Some("2026-05-13T00:00:00Z".to_string()),
|
||||||
|
tarball_url: None,
|
||||||
|
sha256sums_url: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -2589,9 +2648,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(payload["release_notes"], "release notes");
|
assert_eq!(payload["release_notes"], "release notes");
|
||||||
assert_eq!(payload["published_at"], "2026-05-13T00:00:00Z");
|
assert_eq!(payload["published_at"], "2026-05-13T00:00:00Z");
|
||||||
|
assert_eq!(payload["updatable"], false);
|
||||||
|
assert_eq!(payload["update_blocker"], "当前平台暂无安装包");
|
||||||
assert_eq!(payload["error"], serde_json::Value::Null);
|
assert_eq!(payload["error"], serde_json::Value::Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_admin_system_check_update_payload_reports_updatable_release() {
|
||||||
|
let payload = build_admin_system_check_update_payload_with_release(
|
||||||
|
"0.7.0-rc27".to_string(),
|
||||||
|
Some(AdminSystemUpdateRelease {
|
||||||
|
version: "v0.7.0-rc28".to_string(),
|
||||||
|
release_url: None,
|
||||||
|
release_notes: None,
|
||||||
|
published_at: None,
|
||||||
|
tarball_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/aether.tar.gz".to_string()),
|
||||||
|
sha256sums_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/SHA256SUMS".to_string()),
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(payload["has_update"], true);
|
||||||
|
assert_eq!(payload["updatable"], true);
|
||||||
|
assert_eq!(payload["update_blocker"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_admin_system_check_update_payload_normalizes_v_prefix() {
|
fn build_admin_system_check_update_payload_normalizes_v_prefix() {
|
||||||
let payload = build_admin_system_check_update_payload_with_release(
|
let payload = build_admin_system_check_update_payload_with_release(
|
||||||
@@ -2601,6 +2682,8 @@ mod tests {
|
|||||||
release_url: None,
|
release_url: None,
|
||||||
release_notes: None,
|
release_notes: None,
|
||||||
published_at: None,
|
published_at: None,
|
||||||
|
tarball_url: None,
|
||||||
|
sha256sums_url: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -2618,6 +2701,8 @@ mod tests {
|
|||||||
release_url: None,
|
release_url: None,
|
||||||
release_notes: None,
|
release_notes: None,
|
||||||
published_at: None,
|
published_at: None,
|
||||||
|
tarball_url: None,
|
||||||
|
sha256sums_url: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -2635,6 +2720,8 @@ mod tests {
|
|||||||
release_url: None,
|
release_url: None,
|
||||||
release_notes: None,
|
release_notes: None,
|
||||||
published_at: None,
|
published_at: None,
|
||||||
|
tarball_url: None,
|
||||||
|
sha256sums_url: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -2652,6 +2739,8 @@ mod tests {
|
|||||||
release_url: None,
|
release_url: None,
|
||||||
release_notes: None,
|
release_notes: None,
|
||||||
published_at: None,
|
published_at: None,
|
||||||
|
tarball_url: None,
|
||||||
|
sha256sums_url: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Aether 本地在线更新联调环境
|
||||||
|
# 使用当前源码构建一个 release-layout 测试镜像,专门验证管理后台一键更新。
|
||||||
|
#
|
||||||
|
# 启动:
|
||||||
|
# docker compose -f docker-compose.release-local.yml up -d --build
|
||||||
|
#
|
||||||
|
# 重置(包含 /opt/aether/current 与历史版本目录):
|
||||||
|
# docker compose -f docker-compose.release-local.yml down -v
|
||||||
|
|
||||||
|
name: aether-release-local
|
||||||
|
|
||||||
|
services:
|
||||||
|
release-local-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.app.release-local
|
||||||
|
args:
|
||||||
|
AETHER_BUILD_VERSION: ${AETHER_RELEASE_LOCAL_VERSION:-v0.7.0}
|
||||||
|
AETHER_BUILD_TYPE: release
|
||||||
|
image: ${LOCAL_RELEASE_APP_IMAGE:-aether-app:release-local}
|
||||||
|
container_name: aether-release-local-app
|
||||||
|
env_file:
|
||||||
|
- ${AETHER_ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
AETHER_DATABASE_DRIVER: sqlite
|
||||||
|
AETHER_DATABASE_URL: sqlite://./data/aether.db
|
||||||
|
AETHER_RUNTIME_BACKEND: memory
|
||||||
|
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY: single-node
|
||||||
|
AETHER_GATEWAY_NODE_ROLE: all
|
||||||
|
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
|
||||||
|
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
|
||||||
|
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
|
||||||
|
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
|
||||||
|
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
|
||||||
|
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
|
||||||
|
APP_PORT: ${AETHER_RELEASE_LOCAL_PORT:-18085}
|
||||||
|
AETHER_UPDATE_PROXY_URL: ${AETHER_UPDATE_PROXY_URL:-}
|
||||||
|
UPDATE_PROXY_URL: ${UPDATE_PROXY_URL:-}
|
||||||
|
AETHER_BASE_DIR: /opt/aether
|
||||||
|
AETHER_GATEWAY_AUTO_PREPARE_DATABASE: ${AETHER_GATEWAY_AUTO_PREPARE_DATABASE:-true}
|
||||||
|
ports:
|
||||||
|
- "${AETHER_RELEASE_LOCAL_PORT:-18085}:${AETHER_RELEASE_LOCAL_PORT:-18085}"
|
||||||
|
volumes:
|
||||||
|
- ./data-release-local:/app/data
|
||||||
|
- aether_release_local_root:/opt/aether
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aether_release_local_root:
|
||||||
@@ -6,14 +6,15 @@ services:
|
|||||||
- ${AETHER_ENV_FILE:-.env}
|
- ${AETHER_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
AETHER_BASE_DIR: /opt/aether
|
||||||
AETHER_DATABASE_DRIVER: sqlite
|
AETHER_DATABASE_DRIVER: sqlite
|
||||||
AETHER_DATABASE_URL: sqlite://./data/aether.db
|
AETHER_DATABASE_URL: sqlite:///opt/aether/data/aether.db
|
||||||
AETHER_RUNTIME_BACKEND: memory
|
AETHER_RUNTIME_BACKEND: memory
|
||||||
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY: single-node
|
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY: single-node
|
||||||
AETHER_GATEWAY_NODE_ROLE: all
|
AETHER_GATEWAY_NODE_ROLE: all
|
||||||
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-both}
|
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
|
||||||
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
|
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
|
||||||
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/app/logs}
|
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
|
||||||
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
|
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
|
||||||
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
|
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
|
||||||
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
|
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
|
||||||
@@ -22,12 +23,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
|
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/opt/aether/data
|
||||||
- ./logs:/app/logs
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "aether-gateway", "--healthcheck"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 60s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Optional one-click update wiring for Docker Compose deployments.
|
|
||||||
#
|
|
||||||
# This file is installed by install.sh and included by update.sh when present.
|
|
||||||
# It gives the app container access to the deployment directory and Docker
|
|
||||||
# socket so the admin "立即更新" button can execute update.sh.
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
environment:
|
|
||||||
AETHER_SYSTEM_UPDATE_COMMAND: ${AETHER_SYSTEM_UPDATE_COMMAND:-/opt/aether/compose/update.sh}
|
|
||||||
AETHER_SYSTEM_UPDATE_WORKDIR: ${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}
|
|
||||||
volumes:
|
|
||||||
- ${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}:${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
+3
-10
@@ -81,9 +81,10 @@ services:
|
|||||||
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-both}
|
AETHER_BASE_DIR: /opt/aether
|
||||||
|
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
|
||||||
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
|
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
|
||||||
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/app/logs}
|
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
|
||||||
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
|
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
|
||||||
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
|
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
|
||||||
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
|
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
|
||||||
@@ -96,14 +97,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
|
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "aether-gateway", "--healthcheck"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 60s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest",
|
"test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs",
|
||||||
"test:ui": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest --ui",
|
"test:ui": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs --ui",
|
||||||
"test:run": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest run",
|
"test:run": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"version": "git describe --tags --always"
|
"version": "git describe --tags --always"
|
||||||
|
|||||||
+91
-13
@@ -367,6 +367,8 @@ export interface CheckUpdateResponse {
|
|||||||
current_version: string
|
current_version: string
|
||||||
latest_version: string | null
|
latest_version: string | null
|
||||||
has_update: boolean
|
has_update: boolean
|
||||||
|
updatable: boolean
|
||||||
|
update_blocker: string | null
|
||||||
release_url: string | null
|
release_url: string | null
|
||||||
release_notes: string | null
|
release_notes: string | null
|
||||||
published_at: string | null
|
published_at: string | null
|
||||||
@@ -374,21 +376,63 @@ export interface CheckUpdateResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemUpdateCapabilityResponse {
|
export interface SystemUpdateCapabilityResponse {
|
||||||
|
supported: boolean
|
||||||
|
build_type: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
command_env: string
|
rollback_available: boolean
|
||||||
workdir_env: string
|
task_status: string
|
||||||
command?: string | null
|
task_error: string | null
|
||||||
workdir?: string | null
|
install_root?: string
|
||||||
detail?: string | null
|
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskStatusResponse {
|
||||||
|
phase: string
|
||||||
|
error: string | null
|
||||||
|
output: string | null
|
||||||
|
progress_label?: string | null
|
||||||
|
downloaded_bytes?: number | null
|
||||||
|
total_bytes?: number | null
|
||||||
|
progress_percent?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateHistoryEntry {
|
||||||
|
timestamp: string
|
||||||
|
operation: string
|
||||||
|
success: boolean
|
||||||
|
error: string | null
|
||||||
|
output_tail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateHistoryResponse {
|
||||||
|
entries: UpdateHistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApplySystemUpdateResponse {
|
export interface ApplySystemUpdateResponse {
|
||||||
message: string
|
message: string
|
||||||
started: boolean
|
started: boolean
|
||||||
need_restart: boolean
|
need_restart: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReleaseEntry {
|
||||||
|
version: string
|
||||||
|
release_url: string | null
|
||||||
|
release_notes: string | null
|
||||||
|
published_at: string | null
|
||||||
|
tarball_url?: string | null
|
||||||
|
sha256sums_url?: string | null
|
||||||
|
is_current: boolean
|
||||||
|
is_newer: boolean
|
||||||
|
updatable: boolean
|
||||||
|
update_blocker: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleasesListResponse {
|
||||||
|
current_version: string
|
||||||
|
releases: ReleaseEntry[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// LDAP 配置响应
|
// LDAP 配置响应
|
||||||
export interface LdapConfigResponse {
|
export interface LdapConfigResponse {
|
||||||
server_url: string | null
|
server_url: string | null
|
||||||
@@ -990,9 +1034,18 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 检查系统更新
|
// 检查系统更新
|
||||||
async checkUpdate(): Promise<CheckUpdateResponse> {
|
async checkUpdate(force = false): Promise<CheckUpdateResponse> {
|
||||||
const response = await apiClient.get<CheckUpdateResponse>(
|
const response = await apiClient.get<CheckUpdateResponse>(
|
||||||
'/api/admin/system/check-update'
|
'/api/admin/system/check-update',
|
||||||
|
force ? { params: { force: 'true' } } : undefined
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSystemReleases(force = false): Promise<ReleasesListResponse> {
|
||||||
|
const response = await apiClient.get<ReleasesListResponse>(
|
||||||
|
'/api/admin/system/releases',
|
||||||
|
force ? { params: { force: 'true' } } : undefined
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -1005,18 +1058,43 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 准备系统一键更新(拉取最新镜像)
|
// 准备系统一键更新(下载并校验 release 包)
|
||||||
async prepareSystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
async prepareSystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
|
||||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||||
'/api/admin/system/prepare-update'
|
'/api/admin/system/prepare-update',
|
||||||
|
version ? { version } : undefined
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 触发系统一键重启(重建 app 容器)
|
// 触发系统一键重启(切换 release 并退出等待进程管理器拉起)
|
||||||
async applySystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
async applySystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
|
||||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||||
'/api/admin/system/apply-update'
|
'/api/admin/system/apply-update',
|
||||||
|
version ? { version } : undefined
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 回滚到上一个版本
|
||||||
|
async rollbackSystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
||||||
|
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||||
|
'/api/admin/system/rollback'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 查询更新任务状态
|
||||||
|
async getUpdateStatus(): Promise<UpdateTaskStatusResponse> {
|
||||||
|
const response = await apiClient.get<UpdateTaskStatusResponse>(
|
||||||
|
'/api/admin/system/update-status'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUpdateHistory(): Promise<UpdateHistoryResponse> {
|
||||||
|
const response = await apiClient.get<UpdateHistoryResponse>(
|
||||||
|
'/api/admin/system/update-history'
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model="isOpen"
|
v-model="isOpen"
|
||||||
size="md"
|
size="lg"
|
||||||
title=""
|
title=""
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center text-center py-2">
|
<div class="flex flex-col items-center text-center py-2">
|
||||||
@@ -11,80 +11,160 @@
|
|||||||
class-name="text-primary"
|
class-name="text-primary"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Reconnecting State -->
|
||||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
<template v-if="updatePhase === 'reconnecting'">
|
||||||
发现新版本
|
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||||
</h2>
|
正在重启服务
|
||||||
|
</h2>
|
||||||
<!-- Version Info -->
|
<p class="text-sm text-muted-foreground max-w-xs mt-2 mb-2">
|
||||||
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
|
服务正在切换版本并重启,请稍候...
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
最新版本
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
|
<div class="flex items-center gap-2 text-primary mt-2 mb-4">
|
||||||
{{ formatDisplayVersion(latestVersion) }}
|
<svg
|
||||||
</p>
|
class="animate-spin h-5 w-5"
|
||||||
</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ reconnectMessage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Release Notes -->
|
<!-- Normal Update State -->
|
||||||
<div
|
<template v-else>
|
||||||
v-if="releaseNotes"
|
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||||
class="w-full mt-3 mb-4"
|
{{ dialogTitleText }}
|
||||||
>
|
</h2>
|
||||||
|
|
||||||
|
<!-- Version Info -->
|
||||||
|
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ versionLabelText }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
|
||||||
|
{{ formatDisplayVersion(latestVersion) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Release Notes -->
|
||||||
<div
|
<div
|
||||||
v-if="publishedAt"
|
v-if="displayReleaseNotes"
|
||||||
class="text-left text-xs text-muted-foreground mb-2"
|
class="w-full mt-3 mb-4"
|
||||||
>
|
>
|
||||||
发布于 {{ formattedPublishedAt }}
|
<div
|
||||||
|
v-if="publishedAt"
|
||||||
|
class="mb-2 text-left text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
发布于 {{ formattedPublishedAt }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-left text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/80">
|
||||||
|
更新内容
|
||||||
|
</div>
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
class="max-h-64 w-full overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-left text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
||||||
|
v-html="renderedReleaseNotes"
|
||||||
|
/>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left text-xs font-medium text-muted-foreground mb-2">
|
|
||||||
更新内容
|
<!-- Description (fallback when no release notes) -->
|
||||||
</div>
|
<p
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
v-else
|
||||||
|
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
|
||||||
|
>
|
||||||
|
{{ fallbackDescriptionText }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="updatePhase === 'restart'"
|
||||||
|
class="mt-1 text-xs text-primary"
|
||||||
|
>
|
||||||
|
更新包已下载,点击"立即重启"完成安装
|
||||||
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full max-h-48 overflow-y-auto rounded-lg bg-muted/50 p-3 text-left text-sm text-foreground/80 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0"
|
v-if="updating && updatePhase === 'download'"
|
||||||
v-html="renderedReleaseNotes"
|
class="mt-3 w-full max-w-sm"
|
||||||
/>
|
>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<div class="mb-1.5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
</div>
|
<span class="truncate">{{ downloadProgressText }}</span>
|
||||||
|
<span
|
||||||
|
v-if="downloadProgressPercent !== null"
|
||||||
|
class="shrink-0 font-mono text-primary"
|
||||||
|
>
|
||||||
|
{{ downloadProgressPercent }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||||
|
:style="{ width: progressBarWidth }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description (fallback when no release notes) -->
|
<!-- Source Build Hint -->
|
||||||
<p
|
<p
|
||||||
v-else
|
v-if="!canApplyUpdate"
|
||||||
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
|
class="mt-1 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
新版本已发布,建议更新以获得最新功能和安全修复
|
{{ updateBlockerText }}
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
<p
|
|
||||||
v-if="updatePhase === 'restart'"
|
|
||||||
class="mt-1 text-xs text-primary"
|
|
||||||
>
|
|
||||||
更新包已下载,点击“立即重启”完成安装
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full gap-3">
|
<div
|
||||||
|
v-if="updatePhase !== 'reconnecting'"
|
||||||
|
class="flex w-full gap-3"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
:disabled="updating"
|
:disabled="updating || rollingBack"
|
||||||
@click="handleLater"
|
@click="handleLater"
|
||||||
>
|
>
|
||||||
稍后提醒
|
稍后提醒
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="rollbackAvailable"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
:disabled="updating"
|
:disabled="updating || rollingBack"
|
||||||
@click="handleViewRelease"
|
@click="handleRollback"
|
||||||
>
|
>
|
||||||
查看更新
|
{{ rollingBack ? '回滚中...' : '回滚上一版本' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
:disabled="updating"
|
:disabled="updating || rollingBack"
|
||||||
|
@click="handleViewRelease"
|
||||||
|
>
|
||||||
|
{{ releaseLinkLabelText }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="updateSupported"
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="updating || rollingBack || !canApplyUpdate"
|
||||||
@click="handleApplyUpdate"
|
@click="handleApplyUpdate"
|
||||||
>
|
>
|
||||||
{{ actionButtonLabel }}
|
{{ actionButtonLabel }}
|
||||||
@@ -100,8 +180,11 @@ import { Dialog } from '@/components/ui'
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||||
import { formatDisplayVersion } from '@/utils/version'
|
import { formatDisplayVersion } from '@/utils/version'
|
||||||
|
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||||
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
|
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -110,18 +193,57 @@ const props = defineProps<{
|
|||||||
releaseUrl: string | null
|
releaseUrl: string | null
|
||||||
releaseNotes: string | null
|
releaseNotes: string | null
|
||||||
publishedAt: string | null
|
publishedAt: string | null
|
||||||
updatePhase?: 'download' | 'restart'
|
dialogTitle?: string
|
||||||
|
versionLabel?: string
|
||||||
|
releaseLinkLabel?: string
|
||||||
|
updatePhase?: 'download' | 'restart' | 'reconnecting'
|
||||||
updating?: boolean
|
updating?: boolean
|
||||||
|
updateSupported?: boolean
|
||||||
|
updatable?: boolean
|
||||||
|
updateBlocker?: string | null
|
||||||
|
reconnectMessage?: string
|
||||||
|
rollbackAvailable?: boolean
|
||||||
|
rollingBack?: boolean
|
||||||
|
downloadProgressText?: string | null
|
||||||
|
downloadProgressPercent?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
applyUpdate: []
|
applyUpdate: []
|
||||||
|
rollback: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = ref(props.modelValue)
|
const isOpen = ref(props.modelValue)
|
||||||
const updating = computed(() => props.updating ?? false)
|
const updating = computed(() => props.updating ?? false)
|
||||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||||
|
const updateSupported = computed(() => props.updateSupported ?? true)
|
||||||
|
const updatable = computed(() => props.updatable ?? true)
|
||||||
|
const canApplyUpdate = computed(() => updateSupported.value && updatable.value)
|
||||||
|
const updateBlockerText = computed(() => {
|
||||||
|
if (!updateSupported.value) return props.updateBlocker || SOURCE_BUILD_UPDATE_HINT
|
||||||
|
return props.updateBlocker || '当前版本暂不支持在线更新'
|
||||||
|
})
|
||||||
|
const reconnectMessage = computed(() => props.reconnectMessage ?? '等待服务恢复...')
|
||||||
|
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
||||||
|
const rollingBack = computed(() => props.rollingBack ?? false)
|
||||||
|
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||||
|
const dialogTitleText = computed(() => props.dialogTitle ?? '发现新版本')
|
||||||
|
const versionLabelText = computed(() => props.versionLabel ?? '最新版本')
|
||||||
|
const releaseLinkLabelText = computed(() => props.releaseLinkLabel ?? '查看发布')
|
||||||
|
const fallbackDescriptionText = computed(() => {
|
||||||
|
if (!canApplyUpdate.value) return updateBlockerText.value
|
||||||
|
return '新版本已发布,建议更新以获得最新功能和安全修复'
|
||||||
|
})
|
||||||
|
const downloadProgressPercent = computed(() => {
|
||||||
|
const value = props.downloadProgressPercent
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Math.max(0, Math.min(100, Math.round(value)))
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
const progressBarWidth = computed(() => {
|
||||||
|
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
|
||||||
|
})
|
||||||
const actionButtonLabel = computed(() => {
|
const actionButtonLabel = computed(() => {
|
||||||
if (updating.value) {
|
if (updating.value) {
|
||||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||||
@@ -137,7 +259,6 @@ watch(isOpen, (val) => {
|
|||||||
emit('update:modelValue', val)
|
emit('update:modelValue', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化发布时间
|
|
||||||
const formattedPublishedAt = computed(() => {
|
const formattedPublishedAt = computed(() => {
|
||||||
if (!props.publishedAt) return ''
|
if (!props.publishedAt) return ''
|
||||||
try {
|
try {
|
||||||
@@ -152,15 +273,20 @@ const formattedPublishedAt = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 渲染 Markdown 格式的 Release Notes(使用 DOMPurify 防止 XSS)
|
const displayReleaseNotes = computed(() => {
|
||||||
|
return normalizeReleaseNotesForDisplay(props.releaseNotes)
|
||||||
|
})
|
||||||
|
|
||||||
const renderedReleaseNotes = computed(() => {
|
const renderedReleaseNotes = computed(() => {
|
||||||
if (!props.releaseNotes) return ''
|
if (!displayReleaseNotes.value) return ''
|
||||||
try {
|
try {
|
||||||
const html = marked.parse(props.releaseNotes, { async: false }) as string
|
const html = marked.parse(displayReleaseNotes.value, {
|
||||||
return DOMPurify.sanitize(html)
|
async: false,
|
||||||
|
breaks: true
|
||||||
|
}) as string
|
||||||
|
return sanitizeMarkdown(html)
|
||||||
} catch {
|
} catch {
|
||||||
// 如果 markdown 解析失败,返回原始文本(转义 HTML)
|
return displayReleaseNotes.value
|
||||||
return props.releaseNotes
|
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
@@ -169,11 +295,10 @@ const renderedReleaseNotes = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleLater() {
|
function handleLater() {
|
||||||
// 记录忽略的版本,24小时内不再提醒
|
|
||||||
const ignoreKey = 'aether_update_ignore'
|
const ignoreKey = 'aether_update_ignore'
|
||||||
const ignoreData = {
|
const ignoreData = {
|
||||||
version: props.latestVersion,
|
version: props.latestVersion,
|
||||||
until: Date.now() + 24 * 60 * 60 * 1000 // 24小时
|
until: Date.now() + 24 * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
|
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
@@ -187,6 +312,11 @@ function handleViewRelease() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleApplyUpdate() {
|
function handleApplyUpdate() {
|
||||||
|
if (!canApplyUpdate.value) return
|
||||||
emit('applyUpdate')
|
emit('applyUpdate')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRollback() {
|
||||||
|
emit('rollback')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,9 +9,14 @@
|
|||||||
aria-label="版本信息"
|
aria-label="版本信息"
|
||||||
>
|
>
|
||||||
<Info
|
<Info
|
||||||
|
v-if="!isReconnecting"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
:class="loading ? 'animate-pulse' : ''"
|
:class="loading ? 'animate-pulse' : ''"
|
||||||
/>
|
/>
|
||||||
|
<RefreshCw
|
||||||
|
v-else
|
||||||
|
class="h-4 w-4 animate-spin"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
@@ -69,12 +74,52 @@
|
|||||||
检查更新失败:{{ status.error }}
|
检查更新失败:{{ status.error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<p
|
||||||
|
v-if="status?.has_update && !canApplyUpdate"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ updateBlockerText }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Reconnecting / busy banner -->
|
||||||
|
<div
|
||||||
|
v-if="isReconnecting"
|
||||||
|
class="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-primary"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span class="text-xs font-medium">服务重启中,请稍候...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="isDownloadingUpdate"
|
||||||
|
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs text-primary">
|
||||||
|
<span class="truncate">{{ downloadProgressText }}</span>
|
||||||
|
<span
|
||||||
|
v-if="downloadProgressPercent !== null"
|
||||||
|
class="shrink-0 font-mono"
|
||||||
|
>
|
||||||
|
{{ downloadProgressPercent }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/15">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||||
|
:style="{ width: progressBarWidth }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
:disabled="loading"
|
:disabled="isBusy || loading"
|
||||||
@click="handleRefresh"
|
@click="handleRefresh"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -84,19 +129,29 @@
|
|||||||
重新检查
|
重新检查
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="status?.has_update && status.release_url"
|
v-if="rollbackAvailable"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="handleRollback"
|
||||||
|
>
|
||||||
|
{{ rollingBack ? '回滚中...' : '回滚' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="status?.has_update && status.release_url && !rollbackAvailable"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
@click="handleOpenRelease"
|
@click="handleOpenRelease"
|
||||||
>
|
>
|
||||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||||
查看更新
|
{{ releaseButtonLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="status?.has_update"
|
v-if="status?.has_update && canApplyUpdate"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
:disabled="updating"
|
:disabled="isBusy"
|
||||||
@click="handleApplyUpdate"
|
@click="handleApplyUpdate"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -106,41 +161,257 @@
|
|||||||
{{ actionButtonLabel }}
|
{{ actionButtonLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Releases List -->
|
||||||
|
<div v-if="!isReconnecting">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
|
||||||
|
@click="toggleReleases"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
class="h-3 w-3 transition-transform"
|
||||||
|
:class="showReleases ? 'rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
历史版本
|
||||||
|
<span
|
||||||
|
v-if="releases.length > 0"
|
||||||
|
class="text-[10px] text-muted-foreground/60"
|
||||||
|
>({{ releases.length }})</span>
|
||||||
|
<RefreshCw
|
||||||
|
v-if="loadingReleases"
|
||||||
|
class="ml-auto h-3 w-3 animate-spin text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showReleases"
|
||||||
|
class="mt-2 max-h-48 space-y-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="releasesError"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ releasesError }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-for="release in releases"
|
||||||
|
:key="release.version"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-md border border-border/40 px-2.5 py-1.5 text-left text-xs transition hover:border-primary/30 hover:bg-primary/5"
|
||||||
|
:class="release.is_current ? 'bg-primary/5 border-primary/20' : 'bg-muted/10'"
|
||||||
|
@click="openReleaseDetails(release)"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="break-all font-mono font-medium text-foreground">
|
||||||
|
{{ formatDisplayVersion(release.version) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="release.is_current"
|
||||||
|
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||||
|
>当前</span>
|
||||||
|
<span
|
||||||
|
v-else-if="release.is_newer"
|
||||||
|
class="shrink-0 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||||
|
>新</span>
|
||||||
|
<span
|
||||||
|
v-if="release.is_newer && release.updatable === false"
|
||||||
|
class="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||||
|
>不可在线更新</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="release.published_at"
|
||||||
|
class="mt-0.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatDate(release.published_at) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="release.update_blocker"
|
||||||
|
class="mt-0.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ release.update_blocker }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||||
|
详情
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
v-if="!loadingReleases && releases.length === 0 && !releasesError"
|
||||||
|
class="py-2 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无版本信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-model="showReleaseDetails"
|
||||||
|
size="xl"
|
||||||
|
:title="selectedReleaseTitle"
|
||||||
|
:description="selectedReleaseDescription"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedRelease"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full border border-border/60 bg-muted/20 px-2 py-0.5 font-mono text-[11px] text-foreground">
|
||||||
|
{{ formatDisplayVersion(selectedRelease.version) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedRelease.is_current"
|
||||||
|
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||||
|
>
|
||||||
|
当前运行版本
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="selectedRelease.is_newer"
|
||||||
|
class="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
可升级版本
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
历史版本
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="selectedReleaseHelpText"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ selectedReleaseHelpText }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedReleaseDisplayNotes"
|
||||||
|
class="max-h-[26rem] overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
||||||
|
v-html="selectedReleaseNotesHtml"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
这个版本没有附带更新说明。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="showReleaseDetails = false"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="selectedRelease?.release_url"
|
||||||
|
variant="outline"
|
||||||
|
@click="handleOpenSelectedReleasePage"
|
||||||
|
>
|
||||||
|
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||||
|
查看标签页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canUseSelectedRelease"
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="handleUseSelectedRelease"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="mr-2 h-3.5 w-3.5"
|
||||||
|
:class="isBusy ? 'animate-spin' : ''"
|
||||||
|
/>
|
||||||
|
{{ selectedReleaseActionLabel }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { CheckUpdateResponse } from '@/api/admin'
|
import type { CheckUpdateResponse, ReleaseEntry } from '@/api/admin'
|
||||||
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
|
import { adminApi } from '@/api/admin'
|
||||||
|
import { Button, Dialog, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
|
||||||
|
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||||
import { formatDisplayVersion } from '@/utils/version'
|
import { formatDisplayVersion } from '@/utils/version'
|
||||||
import { describeUpdateStatus } from '@/utils/updateStatus'
|
import { describeUpdateStatus } from '@/utils/updateStatus'
|
||||||
import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||||
|
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: CheckUpdateResponse | null
|
status: CheckUpdateResponse | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
updating?: boolean
|
updating?: boolean
|
||||||
updatePhase?: 'download' | 'restart'
|
updatePhase?: 'download' | 'restart' | 'reconnecting'
|
||||||
|
updateSupported?: boolean
|
||||||
|
rollbackAvailable?: boolean
|
||||||
|
rollingBack?: boolean
|
||||||
|
downloadProgressText?: string | null
|
||||||
|
downloadProgressPercent?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
refresh: []
|
||||||
openRelease: []
|
openRelease: []
|
||||||
applyUpdate: []
|
applyUpdate: []
|
||||||
|
previewRelease: [release: ReleaseEntry]
|
||||||
|
rollback: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const showReleases = ref(false)
|
||||||
|
const showReleaseDetails = ref(false)
|
||||||
|
const loadingReleases = ref(false)
|
||||||
|
const releases = ref<ReleaseEntry[]>([])
|
||||||
|
const releasesError = ref<string | null>(null)
|
||||||
|
const selectedRelease = ref<ReleaseEntry | null>(null)
|
||||||
|
let releasesFetched = false
|
||||||
|
|
||||||
const loading = computed(() => props.loading ?? false)
|
const loading = computed(() => props.loading ?? false)
|
||||||
const updating = computed(() => props.updating ?? false)
|
const updating = computed(() => props.updating ?? false)
|
||||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||||
|
const updateSupported = computed(() => props.updateSupported ?? true)
|
||||||
|
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
||||||
|
const rollingBack = computed(() => props.rollingBack ?? false)
|
||||||
|
const isReconnecting = computed(() => updatePhase.value === 'reconnecting')
|
||||||
|
const isDownloadingUpdate = computed(() => updating.value && updatePhase.value === 'download')
|
||||||
|
const isBusy = computed(() => updating.value || rollingBack.value || isReconnecting.value)
|
||||||
|
const canApplyUpdate = computed(() => updateSupported.value && props.status?.updatable !== false)
|
||||||
|
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||||
|
const downloadProgressPercent = computed(() => {
|
||||||
|
const value = props.downloadProgressPercent
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Math.max(0, Math.min(100, Math.round(value)))
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
const progressBarWidth = computed(() => {
|
||||||
|
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
|
||||||
|
})
|
||||||
|
const updateBlockerText = computed(() => {
|
||||||
|
if (!updateSupported.value) {
|
||||||
|
return props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT
|
||||||
|
}
|
||||||
|
return props.status?.update_blocker || '当前版本暂不支持在线更新'
|
||||||
|
})
|
||||||
|
const releaseButtonLabel = computed(() => updateSupported.value ? '查看更新' : '查看发布')
|
||||||
const buttonClass = computed(() => {
|
const buttonClass = computed(() => {
|
||||||
const classes = []
|
const classes = []
|
||||||
|
|
||||||
|
if (isReconnecting.value) {
|
||||||
|
classes.push('bg-primary/10 text-primary animate-pulse')
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
classes.push('bg-muted/50')
|
classes.push('bg-muted/50')
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +428,12 @@ const buttonClass = computed(() => {
|
|||||||
|
|
||||||
return classes
|
return classes
|
||||||
})
|
})
|
||||||
const statusLabel = computed(() => describeUpdateStatus(props.status))
|
const statusLabel = computed(() => {
|
||||||
|
if (isReconnecting.value) return '重启中'
|
||||||
|
if (rollingBack.value) return '回滚中'
|
||||||
|
if (updating.value) return '更新中'
|
||||||
|
return describeUpdateStatus(props.status)
|
||||||
|
})
|
||||||
const currentVersionLabel = computed(() => {
|
const currentVersionLabel = computed(() => {
|
||||||
return props.status?.current_version
|
return props.status?.current_version
|
||||||
? formatDisplayVersion(props.status.current_version)
|
? formatDisplayVersion(props.status.current_version)
|
||||||
@@ -169,12 +445,16 @@ const latestVersionLabel = computed(() => {
|
|||||||
: ''
|
: ''
|
||||||
})
|
})
|
||||||
const statusPillClass = computed(() => {
|
const statusPillClass = computed(() => {
|
||||||
|
if (isReconnecting.value || updating.value || rollingBack.value) {
|
||||||
|
return 'border-primary/20 bg-primary/10 text-primary'
|
||||||
|
}
|
||||||
if (!props.status) return 'border-border/60 bg-background/70 text-muted-foreground'
|
if (!props.status) return 'border-border/60 bg-background/70 text-muted-foreground'
|
||||||
if (props.status.has_update) return 'border-primary/20 bg-primary/10 text-primary'
|
if (props.status.has_update) return 'border-primary/20 bg-primary/10 text-primary'
|
||||||
if (props.status.error) return 'border-destructive/20 bg-destructive/10 text-destructive'
|
if (props.status.error) return 'border-destructive/20 bg-destructive/10 text-destructive'
|
||||||
return 'border-border/60 bg-background/70 text-muted-foreground'
|
return 'border-border/60 bg-background/70 text-muted-foreground'
|
||||||
})
|
})
|
||||||
const buttonTitle = computed(() => {
|
const buttonTitle = computed(() => {
|
||||||
|
if (isReconnecting.value) return '服务重启中...'
|
||||||
if (!props.status) return '版本信息'
|
if (!props.status) return '版本信息'
|
||||||
return `版本信息:${statusLabel.value}`
|
return `版本信息:${statusLabel.value}`
|
||||||
})
|
})
|
||||||
@@ -184,8 +464,104 @@ const actionButtonLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||||
})
|
})
|
||||||
|
const selectedReleaseTitle = computed(() => {
|
||||||
|
return selectedRelease.value
|
||||||
|
? `版本详情 · ${formatDisplayVersion(selectedRelease.value.version)}`
|
||||||
|
: '版本详情'
|
||||||
|
})
|
||||||
|
const selectedReleaseDescription = computed(() => {
|
||||||
|
return selectedRelease.value?.published_at
|
||||||
|
? `发布于 ${formatDate(selectedRelease.value.published_at)}`
|
||||||
|
: '查看该版本的发布说明'
|
||||||
|
})
|
||||||
|
const canUseSelectedRelease = computed(() => {
|
||||||
|
return !!selectedRelease.value &&
|
||||||
|
!selectedRelease.value.is_current &&
|
||||||
|
selectedRelease.value.updatable !== false &&
|
||||||
|
updateSupported.value
|
||||||
|
})
|
||||||
|
const selectedReleaseActionLabel = computed(() => {
|
||||||
|
if (!selectedRelease.value) return '切换到此版本'
|
||||||
|
return selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本'
|
||||||
|
})
|
||||||
|
const selectedReleaseHelpText = computed(() => {
|
||||||
|
if (!selectedRelease.value) return ''
|
||||||
|
if (selectedRelease.value.is_current) return '当前正在运行这个版本。'
|
||||||
|
if (!updateSupported.value) {
|
||||||
|
return selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT
|
||||||
|
}
|
||||||
|
if (selectedRelease.value.update_blocker) return selectedRelease.value.update_blocker
|
||||||
|
return selectedRelease.value.is_newer
|
||||||
|
? '将这个版本作为在线更新目标。'
|
||||||
|
: '将切换到这个历史版本。'
|
||||||
|
})
|
||||||
|
const selectedReleaseDisplayNotes = computed(() => {
|
||||||
|
return normalizeReleaseNotesForDisplay(selectedRelease.value?.release_notes)
|
||||||
|
})
|
||||||
|
const selectedReleaseNotesHtml = computed(() => {
|
||||||
|
if (!selectedReleaseDisplayNotes.value) return ''
|
||||||
|
try {
|
||||||
|
const html = marked.parse(selectedReleaseDisplayNotes.value, {
|
||||||
|
async: false,
|
||||||
|
breaks: true
|
||||||
|
}) as string
|
||||||
|
return sanitizeMarkdown(html)
|
||||||
|
} catch {
|
||||||
|
return selectedReleaseDisplayNotes.value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReleases(force = false) {
|
||||||
|
if (releasesFetched && !force) return
|
||||||
|
loadingReleases.value = true
|
||||||
|
releasesError.value = null
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getSystemReleases(force)
|
||||||
|
releases.value = data.releases
|
||||||
|
releasesError.value = data.error
|
||||||
|
releasesFetched = true
|
||||||
|
} catch (err) {
|
||||||
|
releasesError.value = err instanceof Error ? err.message : '获取版本列表失败'
|
||||||
|
} finally {
|
||||||
|
loadingReleases.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReleases() {
|
||||||
|
showReleases.value = !showReleases.value
|
||||||
|
if (showReleases.value) {
|
||||||
|
fetchReleases()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReleaseDetails(release: ReleaseEntry) {
|
||||||
|
selectedRelease.value = release
|
||||||
|
isOpen.value = false
|
||||||
|
showReleaseDetails.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
|
releasesFetched = false
|
||||||
|
if (showReleases.value) {
|
||||||
|
fetchReleases(true)
|
||||||
|
}
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +570,24 @@ function handleOpenRelease() {
|
|||||||
emit('openRelease')
|
emit('openRelease')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenSelectedReleasePage() {
|
||||||
|
if (selectedRelease.value?.release_url) {
|
||||||
|
window.open(selectedRelease.value.release_url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUseSelectedRelease() {
|
||||||
|
if (!selectedRelease.value || !canUseSelectedRelease.value) return
|
||||||
|
showReleaseDetails.value = false
|
||||||
|
isOpen.value = false
|
||||||
|
emit('previewRelease', selectedRelease.value)
|
||||||
|
}
|
||||||
|
|
||||||
function handleApplyUpdate() {
|
function handleApplyUpdate() {
|
||||||
emit('applyUpdate')
|
emit('applyUpdate')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRollback() {
|
||||||
|
emit('rollback')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -120,9 +120,16 @@
|
|||||||
:loading="loadingVersionStatus"
|
:loading="loadingVersionStatus"
|
||||||
:updating="applyingSystemUpdate"
|
:updating="applyingSystemUpdate"
|
||||||
:update-phase="systemUpdatePhase"
|
:update-phase="systemUpdatePhase"
|
||||||
|
:update-supported="updateSupported"
|
||||||
|
:rollback-available="rollbackAvailable"
|
||||||
|
:rolling-back="rollingBack"
|
||||||
|
:download-progress-text="updateProgressText"
|
||||||
|
:download-progress-percent="updateProgressPercent"
|
||||||
@refresh="handleVersionRefresh"
|
@refresh="handleVersionRefresh"
|
||||||
@open-release="openVersionReleasePage"
|
@open-release="openVersionReleasePage"
|
||||||
|
@preview-release="openReleaseUpdateDialog"
|
||||||
@apply-update="handleApplySystemUpdate"
|
@apply-update="handleApplySystemUpdate"
|
||||||
|
@rollback="handleRollback"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
@@ -306,9 +313,16 @@
|
|||||||
:loading="loadingVersionStatus"
|
:loading="loadingVersionStatus"
|
||||||
:updating="applyingSystemUpdate"
|
:updating="applyingSystemUpdate"
|
||||||
:update-phase="systemUpdatePhase"
|
:update-phase="systemUpdatePhase"
|
||||||
|
:update-supported="updateSupported"
|
||||||
|
:rollback-available="rollbackAvailable"
|
||||||
|
:rolling-back="rollingBack"
|
||||||
|
:download-progress-text="updateProgressText"
|
||||||
|
:download-progress-percent="updateProgressPercent"
|
||||||
@refresh="handleVersionRefresh"
|
@refresh="handleVersionRefresh"
|
||||||
@open-release="openVersionReleasePage"
|
@open-release="openVersionReleasePage"
|
||||||
|
@preview-release="openReleaseUpdateDialog"
|
||||||
@apply-update="handleApplySystemUpdate"
|
@apply-update="handleApplySystemUpdate"
|
||||||
|
@rollback="handleRollback"
|
||||||
/>
|
/>
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<button
|
<button
|
||||||
@@ -391,9 +405,21 @@
|
|||||||
:release-url="updateInfo.release_url"
|
:release-url="updateInfo.release_url"
|
||||||
:release-notes="updateInfo.release_notes"
|
:release-notes="updateInfo.release_notes"
|
||||||
:published-at="updateInfo.published_at"
|
:published-at="updateInfo.published_at"
|
||||||
|
:dialog-title="updateDialogTitle"
|
||||||
|
:version-label="updateDialogVersionLabel"
|
||||||
|
:release-link-label="updateDialogReleaseLinkLabel"
|
||||||
:updating="applyingSystemUpdate"
|
:updating="applyingSystemUpdate"
|
||||||
:update-phase="systemUpdatePhase"
|
:update-phase="systemUpdatePhase"
|
||||||
|
:update-supported="updateSupported"
|
||||||
|
:updatable="updateInfo.updatable"
|
||||||
|
:update-blocker="updateInfo.update_blocker"
|
||||||
|
:reconnect-message="reconnectMessage"
|
||||||
|
:rollback-available="rollbackAvailable"
|
||||||
|
:rolling-back="rollingBack"
|
||||||
|
:download-progress-text="updateProgressText"
|
||||||
|
:download-progress-percent="updateProgressPercent"
|
||||||
@apply-update="handleApplySystemUpdate"
|
@apply-update="handleApplySystemUpdate"
|
||||||
|
@rollback="handleRollback"
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
@@ -408,7 +434,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
|
|||||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { isDemoMode } from '@/config/demo'
|
import { isDemoMode } from '@/config/demo'
|
||||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type UpdateTaskStatusResponse } from '@/api/admin'
|
||||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -460,7 +486,7 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
|||||||
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||||
|
|
||||||
type SystemUpdatePhase = 'download' | 'restart'
|
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -490,11 +516,38 @@ const updateInfo = ref<CheckUpdateResponse | null>(null)
|
|||||||
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
||||||
const loadingVersionStatus = ref(false)
|
const loadingVersionStatus = ref(false)
|
||||||
const applyingSystemUpdate = ref(false)
|
const applyingSystemUpdate = ref(false)
|
||||||
|
const updateSupported = ref(true)
|
||||||
|
const reconnectMessage = ref('等待服务恢复...')
|
||||||
|
const rollbackAvailable = ref(false)
|
||||||
|
const rollingBack = ref(false)
|
||||||
|
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
|
||||||
|
const updateDialogMode = ref<'latest' | 'selected'>('latest')
|
||||||
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
||||||
const preparedUpdateVersion = ref<string | null>(
|
const preparedUpdateVersion = ref<string | null>(
|
||||||
readSessionStorageItem('aether_prepared_update_version')
|
readSessionStorageItem('aether_prepared_update_version')
|
||||||
)
|
)
|
||||||
|
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||||
|
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||||
|
let updateStatusPollTimer: number | null = null
|
||||||
|
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
|
||||||
|
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
|
||||||
|
const updateDialogTitle = computed(() => {
|
||||||
|
if (updateDialogMode.value === 'selected') {
|
||||||
|
return updateSupported.value ? '切换版本' : '版本详情'
|
||||||
|
}
|
||||||
|
return '发现新版本'
|
||||||
|
})
|
||||||
|
const updateDialogVersionLabel = computed(() => {
|
||||||
|
if (updateDialogMode.value === 'selected') {
|
||||||
|
return updateSupported.value ? '目标版本' : '版本标签'
|
||||||
|
}
|
||||||
|
return '最新版本'
|
||||||
|
})
|
||||||
|
const updateDialogReleaseLinkLabel = computed(() => {
|
||||||
|
if (updateDialogMode.value === 'selected') return '查看标签页'
|
||||||
|
return updateSupported.value ? '查看更新' : '查看发布'
|
||||||
|
})
|
||||||
|
|
||||||
watch(systemUpdatePhase, (val) => {
|
watch(systemUpdatePhase, (val) => {
|
||||||
setSessionStorageItem('aether_update_phase', val)
|
setSessionStorageItem('aether_update_phase', val)
|
||||||
@@ -508,7 +561,9 @@ watch(preparedUpdateVersion, (val) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
|
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
|
||||||
return readSessionStorageItem('aether_update_phase') === 'restart' ? 'restart' : 'download'
|
const stored = readSessionStorageItem('aether_update_phase')
|
||||||
|
if (stored === 'restart' || stored === 'reconnecting') return stored
|
||||||
|
return 'download'
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSessionStorageItem(key: string): string | null {
|
function readSessionStorageItem(key: string): string | null {
|
||||||
@@ -535,6 +590,80 @@ function removeSessionStorageItem(key: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
|
||||||
|
if (!status) return '正在下载更新包...'
|
||||||
|
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
|
||||||
|
const downloaded = status.downloaded_bytes
|
||||||
|
const total = status.total_bytes
|
||||||
|
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
|
||||||
|
return `${label} ${formatFileSize(downloaded)} / ${formatFileSize(total)}`
|
||||||
|
}
|
||||||
|
if (typeof downloaded === 'number' && downloaded > 0) {
|
||||||
|
return `${label} ${formatFileSize(downloaded)}`
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdateTaskPhase(phase: string): string {
|
||||||
|
switch (phase) {
|
||||||
|
case 'downloading':
|
||||||
|
return '正在下载更新包'
|
||||||
|
case 'downloading_checksum':
|
||||||
|
return '正在下载校验文件'
|
||||||
|
case 'verifying':
|
||||||
|
return '正在校验更新包'
|
||||||
|
case 'extracting':
|
||||||
|
return '正在解压更新包'
|
||||||
|
case 'prepared':
|
||||||
|
return '更新包已准备完成'
|
||||||
|
default:
|
||||||
|
return '正在准备更新'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUpdateTaskStatus() {
|
||||||
|
try {
|
||||||
|
updateTaskStatus.value = await adminApi.getUpdateStatus()
|
||||||
|
} catch {
|
||||||
|
// Keep the last progress snapshot while the request is in flight or the service restarts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
|
||||||
|
const deadline = Date.now() + 10 * 60 * 1000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
await refreshUpdateTaskStatus()
|
||||||
|
const status = updateTaskStatus.value
|
||||||
|
if (status?.phase === 'prepared') return status
|
||||||
|
if (status?.phase === 'failed') {
|
||||||
|
throw new Error(status.error || '下载更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('下载更新超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdateStatusPolling() {
|
||||||
|
stopUpdateStatusPolling()
|
||||||
|
void refreshUpdateTaskStatus()
|
||||||
|
updateStatusPollTimer = window.setInterval(() => {
|
||||||
|
void refreshUpdateTaskStatus()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopUpdateStatusPolling() {
|
||||||
|
if (updateStatusPollTimer !== null) {
|
||||||
|
window.clearInterval(updateStatusPollTimer)
|
||||||
|
updateStatusPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 路由变化时自动关闭移动端菜单
|
// 路由变化时自动关闭移动端菜单
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
@@ -558,14 +687,28 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadVersionStatus() {
|
async function loadVersionStatus(force = false) {
|
||||||
if (!isAdmin.value) return null
|
if (!isAdmin.value) return null
|
||||||
if (versionStatusLoadPromise) return versionStatusLoadPromise
|
if (versionStatusLoadPromise) return versionStatusLoadPromise
|
||||||
|
|
||||||
loadingVersionStatus.value = true
|
loadingVersionStatus.value = true
|
||||||
versionStatusLoadPromise = (async () => {
|
versionStatusLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
versionStatus.value = await adminApi.checkUpdate()
|
const [status, capability] = await Promise.all([
|
||||||
|
adminApi.checkUpdate(force),
|
||||||
|
adminApi.getSystemUpdateCapability().catch(() => null),
|
||||||
|
])
|
||||||
|
if (capability) {
|
||||||
|
rollbackAvailable.value = capability.rollback_available
|
||||||
|
updateSupported.value = capability.supported
|
||||||
|
}
|
||||||
|
versionStatus.value = capability?.supported === false && status.has_update
|
||||||
|
? {
|
||||||
|
...status,
|
||||||
|
updatable: false,
|
||||||
|
update_blocker: SOURCE_BUILD_UPDATE_HINT,
|
||||||
|
}
|
||||||
|
: status
|
||||||
syncSystemUpdatePhase(versionStatus.value)
|
syncSystemUpdatePhase(versionStatus.value)
|
||||||
return versionStatus.value
|
return versionStatus.value
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -581,23 +724,21 @@ async function loadVersionStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
||||||
if (!status?.has_update) {
|
if (systemUpdatePhase.value === 'reconnecting') return
|
||||||
systemUpdatePhase.value = 'download'
|
if (systemUpdatePhase.value === 'restart') {
|
||||||
preparedUpdateVersion.value = null
|
if (!preparedUpdateVersion.value) {
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!status?.has_update) {
|
||||||
if (
|
|
||||||
systemUpdatePhase.value === 'restart' &&
|
|
||||||
(!preparedUpdateVersion.value || preparedUpdateVersion.value !== status.latest_version)
|
|
||||||
) {
|
|
||||||
systemUpdatePhase.value = 'download'
|
systemUpdatePhase.value = 'download'
|
||||||
preparedUpdateVersion.value = null
|
preparedUpdateVersion.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVersionRefresh() {
|
function handleVersionRefresh() {
|
||||||
void loadVersionStatus()
|
void loadVersionStatus(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openVersionReleasePage() {
|
function openVersionReleasePage() {
|
||||||
@@ -606,30 +747,88 @@ function openVersionReleasePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse {
|
||||||
|
const currentVersion =
|
||||||
|
versionStatus.value?.current_version ||
|
||||||
|
updateInfo.value?.current_version ||
|
||||||
|
__APP_VERSION__ ||
|
||||||
|
''
|
||||||
|
const sourceBuild = !updateSupported.value
|
||||||
|
return {
|
||||||
|
current_version: currentVersion,
|
||||||
|
latest_version: release.version,
|
||||||
|
has_update: !release.is_current,
|
||||||
|
updatable: !sourceBuild && !release.is_current && release.updatable,
|
||||||
|
update_blocker: release.is_current
|
||||||
|
? '当前已是这个版本'
|
||||||
|
: sourceBuild
|
||||||
|
? SOURCE_BUILD_RELEASE_HINT
|
||||||
|
: release.update_blocker,
|
||||||
|
release_url: release.release_url,
|
||||||
|
release_notes: release.release_notes,
|
||||||
|
published_at: release.published_at,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReleaseUpdateDialog(release: ReleaseEntry) {
|
||||||
|
updateDialogMode.value = 'selected'
|
||||||
|
updateInfo.value = buildUpdateInfoFromRelease(release)
|
||||||
|
if (systemUpdatePhase.value !== 'reconnecting') {
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
preparedUpdateVersion.value = null
|
||||||
|
}
|
||||||
|
showUpdateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApplySystemUpdate() {
|
async function handleApplySystemUpdate() {
|
||||||
if (applyingSystemUpdate.value) return
|
if (applyingSystemUpdate.value) return
|
||||||
applyingSystemUpdate.value = true
|
applyingSystemUpdate.value = true
|
||||||
try {
|
try {
|
||||||
const capability = await adminApi.getSystemUpdateCapability()
|
const capability = await adminApi.getSystemUpdateCapability()
|
||||||
if (!capability.enabled) {
|
rollbackAvailable.value = capability.rollback_available
|
||||||
|
if (!capability.supported) {
|
||||||
|
updateSupported.value = false
|
||||||
showError(
|
showError(
|
||||||
capability.detail || `一键更新未启用,请先在部署环境配置 ${capability.command_env}`,
|
SOURCE_BUILD_UPDATE_HINT,
|
||||||
'无法启动更新'
|
'不支持在线更新'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
updateSupported.value = true
|
||||||
|
|
||||||
if (systemUpdatePhase.value === 'download') {
|
if (systemUpdatePhase.value === 'download') {
|
||||||
const result = await adminApi.prepareSystemUpdate()
|
const targetStatus = updateInfo.value || versionStatus.value
|
||||||
preparedUpdateVersion.value = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
|
if (targetStatus?.has_update && targetStatus.updatable === false) {
|
||||||
systemUpdatePhase.value = 'restart'
|
showError(
|
||||||
success(result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
targetStatus.update_blocker || '当前版本暂不支持在线更新',
|
||||||
|
'无法在线更新'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetVersion = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
|
||||||
|
updateTaskStatus.value = null
|
||||||
|
startUpdateStatusPolling()
|
||||||
|
try {
|
||||||
|
const result = await adminApi.prepareSystemUpdate(targetVersion)
|
||||||
|
const finalStatus = await waitForPreparedUpdate()
|
||||||
|
preparedUpdateVersion.value = targetVersion
|
||||||
|
systemUpdatePhase.value = 'restart'
|
||||||
|
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
||||||
|
} finally {
|
||||||
|
stopUpdateStatusPolling()
|
||||||
|
void refreshUpdateTaskStatus()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await adminApi.applySystemUpdate()
|
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
|
||||||
success(result.message || '一键重启已启动')
|
success(result.message || '一键重启已启动')
|
||||||
showUpdateDialog.value = false
|
systemUpdatePhase.value = 'reconnecting'
|
||||||
|
reconnectMessage.value = '服务正在重启...'
|
||||||
|
showUpdateDialog.value = true
|
||||||
|
applyingSystemUpdate.value = false
|
||||||
|
await pollHealthUntilReady()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
||||||
showError(parseApiError(err, fallback))
|
showError(parseApiError(err, fallback))
|
||||||
@@ -638,8 +837,82 @@ async function handleApplySystemUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRollback() {
|
||||||
|
if (rollingBack.value) return
|
||||||
|
rollingBack.value = true
|
||||||
|
try {
|
||||||
|
const result = await adminApi.rollbackSystemUpdate()
|
||||||
|
success(result.message || '回滚已启动')
|
||||||
|
systemUpdatePhase.value = 'reconnecting'
|
||||||
|
reconnectMessage.value = '正在回滚到上一版本...'
|
||||||
|
showUpdateDialog.value = true
|
||||||
|
rollingBack.value = false
|
||||||
|
await pollHealthUntilReady()
|
||||||
|
} catch (err) {
|
||||||
|
showError(parseApiError(err, '回滚失败'))
|
||||||
|
} finally {
|
||||||
|
rollingBack.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHealthUntilReady() {
|
||||||
|
const maxAttempts = 60
|
||||||
|
const intervalMs = 2000
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
if (i < 3) {
|
||||||
|
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
|
||||||
|
await new Promise(r => setTimeout(r, intervalMs))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = i * 2
|
||||||
|
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/_gateway/health', {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
reconnectMessage.value = '服务已恢复,正在刷新...'
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
window.location.replace(buildFreshReloadUrl())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// expected while service is down
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 30 seconds, start checking if the task actually failed
|
||||||
|
if (i > 15) {
|
||||||
|
try {
|
||||||
|
const status = await adminApi.getUpdateStatus()
|
||||||
|
if (status.phase === 'failed' && status.error) {
|
||||||
|
reconnectMessage.value = `更新失败: ${status.error}`
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// service still down, continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, intervalMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectMessage.value = '等待超时,请手动刷新页面'
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFreshReloadUrl(): string {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('__aether_reload', Date.now().toString())
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
function showDebugUpdateDialog() {
|
function showDebugUpdateDialog() {
|
||||||
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
||||||
|
updateDialogMode.value = 'latest'
|
||||||
updateInfo.value = {
|
updateInfo.value = {
|
||||||
current_version: currentVersion,
|
current_version: currentVersion,
|
||||||
latest_version: 'v0.7.0-rc99',
|
latest_version: 'v0.7.0-rc99',
|
||||||
@@ -652,6 +925,8 @@ function showDebugUpdateDialog() {
|
|||||||
'- 统一版本号显示格式',
|
'- 统一版本号显示格式',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
published_at: new Date().toISOString(),
|
published_at: new Date().toISOString(),
|
||||||
|
updatable: true,
|
||||||
|
update_blocker: null,
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
systemUpdatePhase.value = 'download'
|
systemUpdatePhase.value = 'download'
|
||||||
@@ -675,6 +950,8 @@ function showDebugVersionStatus(hasUpdate = true) {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
: null,
|
: null,
|
||||||
published_at: hasUpdate ? new Date().toISOString() : null,
|
published_at: hasUpdate ? new Date().toISOString() : null,
|
||||||
|
updatable: hasUpdate,
|
||||||
|
update_blocker: null,
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
systemUpdatePhase.value = 'download'
|
systemUpdatePhase.value = 'download'
|
||||||
@@ -694,6 +971,7 @@ async function checkForUpdate() {
|
|||||||
const result = versionStatus.value ?? await loadVersionStatus()
|
const result = versionStatus.value ?? await loadVersionStatus()
|
||||||
if (result?.has_update && result.latest_version) {
|
if (result?.has_update && result.latest_version) {
|
||||||
if (shouldShowUpdatePrompt(result.latest_version)) {
|
if (shouldShowUpdatePrompt(result.latest_version)) {
|
||||||
|
updateDialogMode.value = 'latest'
|
||||||
updateInfo.value = result
|
updateInfo.value = result
|
||||||
showUpdateDialog.value = true
|
showUpdateDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -786,6 +1064,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('storage', handleStorageChange)
|
window.removeEventListener('storage', handleStorageChange)
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
stopUpdateStatusPolling()
|
||||||
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
|
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
|
||||||
delete window.__aetherShowUpdateDialog
|
delete window.__aetherShowUpdateDialog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeReleaseNotesForDisplay, trimReleaseNotesForDisplay } from '../releaseNotes'
|
||||||
|
|
||||||
|
describe('trimReleaseNotesForDisplay', () => {
|
||||||
|
it('keeps manual notes and removes GitHub auto-generated sections', () => {
|
||||||
|
const input = [
|
||||||
|
'### Features',
|
||||||
|
'- 新增在线更新',
|
||||||
|
'',
|
||||||
|
'### Bug Fixes',
|
||||||
|
'- 修复版本检查',
|
||||||
|
'',
|
||||||
|
'## What\'s Changed',
|
||||||
|
'* fix: something by @someone in https://github.com/example/repo/pull/1',
|
||||||
|
'',
|
||||||
|
'## New Contributors',
|
||||||
|
'* @someone made their first contribution',
|
||||||
|
'',
|
||||||
|
'**Full Changelog**: https://github.com/example/repo/compare/v1...v2',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(trimReleaseNotesForDisplay(input)).toBe([
|
||||||
|
'### Features',
|
||||||
|
'- 新增在线更新',
|
||||||
|
'',
|
||||||
|
'### Bug Fixes',
|
||||||
|
'- 修复版本检查',
|
||||||
|
].join('\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes standalone full changelog lines', () => {
|
||||||
|
const input = '**Full Changelog**: https://github.com/example/repo/compare/v1...v2'
|
||||||
|
expect(trimReleaseNotesForDisplay(input)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns original notes when there is no auto-generated footer', () => {
|
||||||
|
const input = [
|
||||||
|
'### Features',
|
||||||
|
'- 支持历史版本切换',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(trimReleaseNotesForDisplay(input)).toBe(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeReleaseNotesForDisplay', () => {
|
||||||
|
it('keeps existing markdown structure intact', () => {
|
||||||
|
const input = [
|
||||||
|
'### Features',
|
||||||
|
'- 新增在线更新',
|
||||||
|
'',
|
||||||
|
'### Fixes',
|
||||||
|
'- 修复版本检查',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts plain multi-section notes into markdown sections', () => {
|
||||||
|
const input = [
|
||||||
|
'Features',
|
||||||
|
'新增在线更新弹窗',
|
||||||
|
'支持选择历史版本',
|
||||||
|
'',
|
||||||
|
'问题修复',
|
||||||
|
'修复下载进度显示不准',
|
||||||
|
'修复版本详情跳转错误',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(normalizeReleaseNotesForDisplay(input)).toBe([
|
||||||
|
'### Features',
|
||||||
|
'- 新增在线更新弹窗',
|
||||||
|
'- 支持选择历史版本',
|
||||||
|
'',
|
||||||
|
'### 问题修复',
|
||||||
|
'- 修复下载进度显示不准',
|
||||||
|
'- 修复版本详情跳转错误',
|
||||||
|
].join('\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not over-normalize plain paragraph text', () => {
|
||||||
|
const input = '这次更新主要修复了在线更新流程中的代理问题,并优化了历史版本切换体验。'
|
||||||
|
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,8 @@ function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdate
|
|||||||
current_version: '0.7.0-rc27',
|
current_version: '0.7.0-rc27',
|
||||||
latest_version: null,
|
latest_version: null,
|
||||||
has_update: false,
|
has_update: false,
|
||||||
|
updatable: false,
|
||||||
|
update_blocker: null,
|
||||||
release_url: null,
|
release_url: null,
|
||||||
release_notes: null,
|
release_notes: null,
|
||||||
published_at: null,
|
published_at: null,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const AUTO_GENERATED_RELEASE_SECTION_PATTERNS = [
|
||||||
|
/^#{1,6}\s+what'?s changed\s*$/i,
|
||||||
|
/^#{1,6}\s+new contributors\s*$/i,
|
||||||
|
/^\*\*full changelog\*\*:/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
const MARKDOWN_STRUCTURE_PATTERNS = [
|
||||||
|
/^#{1,6}\s+\S/,
|
||||||
|
/^\s*[-*+]\s+\S/,
|
||||||
|
/^\s*\d+\.\s+\S/,
|
||||||
|
/^\s*>\s+\S/,
|
||||||
|
/^\s*```/,
|
||||||
|
/^\s*---+\s*$/,
|
||||||
|
/^\s*\|(?:[^|]+\|)+\s*$/,
|
||||||
|
/`[^`]+`/,
|
||||||
|
/\[[^\]]+\]\([^)]+\)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const SENTENCE_END_PUNCTUATION = /[,。,.!?!?;;]$/
|
||||||
|
const SECTION_HEADING_SUFFIX = /[::]\s*$/
|
||||||
|
const URL_PATTERN = /https?:\/\/|www\./i
|
||||||
|
const BRACKET_PREFIX_PATTERN = /^[\[((【<]/
|
||||||
|
const SECTION_HEADING_TEXT_PATTERN = /[\u3400-\u9FFFA-Za-z]/
|
||||||
|
|
||||||
|
function isStructuredMarkdownLine(line: string): boolean {
|
||||||
|
return MARKDOWN_STRUCTURE_PATTERNS.some((pattern) => pattern.test(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSectionHeading(line: string): string {
|
||||||
|
return line.replace(SECTION_HEADING_SUFFIX, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSectionHeading(line: string): boolean {
|
||||||
|
const trimmed = normalizeSectionHeading(line)
|
||||||
|
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.length > 24) return false
|
||||||
|
if (URL_PATTERN.test(trimmed)) return false
|
||||||
|
if (BRACKET_PREFIX_PATTERN.test(trimmed)) return false
|
||||||
|
if (isStructuredMarkdownLine(trimmed)) return false
|
||||||
|
if (SENTENCE_END_PUNCTUATION.test(trimmed)) return false
|
||||||
|
if (!SECTION_HEADING_TEXT_PATTERN.test(trimmed)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectConfirmedHeadingIndexes(lines: string[]): Set<number> {
|
||||||
|
const candidates = lines
|
||||||
|
.map((line, index) => {
|
||||||
|
if (!looksLikeSectionHeading(line.trim())) return -1
|
||||||
|
if (index === 0) return index
|
||||||
|
return lines[index - 1].trim() === '' ? index : -1
|
||||||
|
})
|
||||||
|
.filter((index) => index !== -1)
|
||||||
|
|
||||||
|
const confirmed = new Set<number>()
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i += 1) {
|
||||||
|
const current = candidates[i]
|
||||||
|
const next = i + 1 < candidates.length ? candidates[i + 1] : lines.length
|
||||||
|
|
||||||
|
for (let cursor = current + 1; cursor < next; cursor += 1) {
|
||||||
|
const content = lines[cursor].trim()
|
||||||
|
if (!content) continue
|
||||||
|
confirmed.add(current)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseBlankLines(lines: string[]): string {
|
||||||
|
return lines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||||
|
if (!notes) return ''
|
||||||
|
|
||||||
|
const normalized = notes.replace(/\r\n?/g, '\n').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
|
||||||
|
const lines = normalized.split('\n')
|
||||||
|
const cutoff = lines.findIndex((line) => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
return AUTO_GENERATED_RELEASE_SECTION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cutoff === -1) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.slice(0, cutoff).join('\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||||
|
const trimmed = trimReleaseNotesForDisplay(notes)
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
const lines = trimmed.split('\n')
|
||||||
|
if (lines.some((line) => isStructuredMarkdownLine(line.trim()))) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingIndexes = collectConfirmedHeadingIndexes(lines)
|
||||||
|
if (headingIndexes.size < 2) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: string[] = []
|
||||||
|
let insideSection = false
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const trimmedLine = lines[index].trim()
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||||
|
normalized.push('')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headingIndexes.has(index)) {
|
||||||
|
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||||
|
normalized.push('')
|
||||||
|
}
|
||||||
|
normalized.push(`### ${normalizeSectionHeading(trimmedLine)}`)
|
||||||
|
insideSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insideSection) {
|
||||||
|
normalized.push(`- ${trimmedLine.replace(/^[-*+•]\s*/, '')}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(trimmedLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collapseBlankLines(normalized)
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export function buildUpdateErrorStatus(
|
|||||||
current_version: previousStatus?.current_version || '',
|
current_version: previousStatus?.current_version || '',
|
||||||
latest_version: null,
|
latest_version: null,
|
||||||
has_update: false,
|
has_update: false,
|
||||||
|
updatable: false,
|
||||||
|
update_blocker: null,
|
||||||
release_url: null,
|
release_url: null,
|
||||||
release_notes: null,
|
release_notes: null,
|
||||||
published_at: null,
|
published_at: null,
|
||||||
|
|||||||
+79
-52
@@ -34,6 +34,18 @@ fi
|
|||||||
SERVICE_USER="${SERVICE_USER:-aether}"
|
SERVICE_USER="${SERVICE_USER:-aether}"
|
||||||
SERVICE_GROUP="${SERVICE_GROUP:-aether}"
|
SERVICE_GROUP="${SERVICE_GROUP:-aether}"
|
||||||
SERVICE_NAME="aether-gateway"
|
SERVICE_NAME="aether-gateway"
|
||||||
|
COMPOSE_RELEASE_BASE_DIR="/opt/aether"
|
||||||
|
COMPOSE_RELEASE_CURRENT_DIR="${COMPOSE_RELEASE_BASE_DIR}/current"
|
||||||
|
COMPOSE_RELEASE_FRONTEND_DIR="${COMPOSE_RELEASE_CURRENT_DIR}/frontend"
|
||||||
|
COMPOSE_RELEASE_LOG_DIR="${COMPOSE_RELEASE_BASE_DIR}/logs"
|
||||||
|
COMPOSE_RELEASE_SQLITE_DATABASE_URL="sqlite://${COMPOSE_RELEASE_BASE_DIR}/data/aether.db"
|
||||||
|
COMPOSE_LOG_DESTINATION_DEFAULT="stdout"
|
||||||
|
COMPOSE_LOG_FORMAT_DEFAULT="pretty"
|
||||||
|
COMPOSE_LOG_ROTATION_DEFAULT="daily"
|
||||||
|
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
|
||||||
|
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
|
||||||
|
COMPOSE_APP_PORT_DEFAULT="8084"
|
||||||
|
COMPOSE_CLI=()
|
||||||
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
|
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
|
||||||
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
|
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
|
||||||
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
|
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
|
||||||
@@ -598,6 +610,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ -z "${MIGRATE_FROM_COMPOSE}" && "${MODE}" != "compose" ]]; then
|
if [[ -z "${MIGRATE_FROM_COMPOSE}" && "${MODE}" != "compose" ]]; then
|
||||||
if ui_is_zh; then
|
if ui_is_zh; then
|
||||||
cat >/dev/tty <<'EOF'
|
cat >/dev/tty <<'EOF'
|
||||||
@@ -904,11 +917,21 @@ ensure_directory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_compose_runtime() {
|
require_compose_runtime() {
|
||||||
|
resolve_compose_cli
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_compose_cli() {
|
||||||
|
if [[ "${#COMPOSE_CLI[@]}" -gt 0 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CLI=(docker compose)
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v docker-compose >/dev/null 2>&1; then
|
if command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CLI=(docker-compose)
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -920,11 +943,13 @@ require_compose_runtime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compose_command() {
|
compose_command() {
|
||||||
if docker compose version >/dev/null 2>&1; then
|
resolve_compose_cli
|
||||||
printf '%s\n' "docker compose"
|
printf '%s\n' "${COMPOSE_CLI[*]}"
|
||||||
else
|
}
|
||||||
printf '%s\n' "docker-compose"
|
|
||||||
fi
|
run_compose() {
|
||||||
|
resolve_compose_cli
|
||||||
|
"${COMPOSE_CLI[@]}" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
compose_next_steps() {
|
compose_next_steps() {
|
||||||
@@ -941,8 +966,8 @@ Install complete.
|
|||||||
Docker Compose service:
|
Docker Compose service:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
./update.sh
|
./update.sh
|
||||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml ps
|
${compose_cmd} -f docker-compose.yml ps
|
||||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml logs -f app
|
${compose_cmd} -f docker-compose.yml logs -f app
|
||||||
|
|
||||||
Health checks:
|
Health checks:
|
||||||
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
|
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
|
||||||
@@ -962,9 +987,9 @@ compose_manual_start_steps() {
|
|||||||
|
|
||||||
Next steps:
|
Next steps:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml pull
|
${compose_cmd} -f docker-compose.yml pull
|
||||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml up -d
|
${compose_cmd} -f docker-compose.yml up -d
|
||||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml logs -f app
|
${compose_cmd} -f docker-compose.yml logs -f app
|
||||||
|
|
||||||
Later updates:
|
Later updates:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
@@ -977,19 +1002,12 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_compose_deployment() {
|
start_compose_deployment() {
|
||||||
local compose_cmd
|
local -a compose_args=(--project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml")
|
||||||
compose_cmd="$(compose_command)"
|
|
||||||
|
|
||||||
info "pulling Docker Compose images"
|
info "pulling Docker Compose images"
|
||||||
if [[ "${compose_cmd}" == "docker compose" ]]; then
|
run_compose "${compose_args[@]}" pull
|
||||||
docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" pull
|
info "starting Docker Compose services"
|
||||||
info "starting Docker Compose services"
|
run_compose "${compose_args[@]}" up -d
|
||||||
docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" up -d
|
|
||||||
else
|
|
||||||
docker-compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" pull
|
|
||||||
info "starting Docker Compose services"
|
|
||||||
docker-compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" up -d
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
migration_options_requested() {
|
migration_options_requested() {
|
||||||
@@ -1764,6 +1782,31 @@ compose_image() {
|
|||||||
printf '%s:%s\n' "${IMAGE_REPO}" "${tag}"
|
printf '%s:%s\n' "${IMAGE_REPO}" "${tag}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compose_app_port() {
|
||||||
|
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
append_compose_log_env_defaults() {
|
||||||
|
local output="$1"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "${COMPOSE_LOG_FORMAT_DEFAULT}"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_DIR" "${COMPOSE_RELEASE_LOG_DIR}"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_ROTATION" "${COMPOSE_LOG_ROTATION_DEFAULT}"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_RETENTION_DAYS" "${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}"
|
||||||
|
replace_or_append_env "${output}" "AETHER_LOG_MAX_FILES" "${COMPOSE_LOG_MAX_FILES_DEFAULT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_log_env_block() {
|
||||||
|
cat <<EOF
|
||||||
|
AETHER_LOG_DESTINATION=${COMPOSE_LOG_DESTINATION_DEFAULT}
|
||||||
|
AETHER_LOG_FORMAT=${COMPOSE_LOG_FORMAT_DEFAULT}
|
||||||
|
AETHER_LOG_DIR=${COMPOSE_RELEASE_LOG_DIR}
|
||||||
|
AETHER_LOG_ROTATION=${COMPOSE_LOG_ROTATION_DEFAULT}
|
||||||
|
AETHER_LOG_RETENTION_DAYS=${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}
|
||||||
|
AETHER_LOG_MAX_FILES=${COMPOSE_LOG_MAX_FILES_DEFAULT}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
generate_compose_env() {
|
generate_compose_env() {
|
||||||
local output="$1"
|
local output="$1"
|
||||||
local jwt_key encryption_key
|
local jwt_key encryption_key
|
||||||
@@ -1773,7 +1816,7 @@ generate_compose_env() {
|
|||||||
|
|
||||||
cp "${COMPOSE_DIR}/.env.example" "${output}"
|
cp "${COMPOSE_DIR}/.env.example" "${output}"
|
||||||
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
|
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
|
||||||
replace_or_append_env "${output}" "APP_PORT" "${APP_PORT:-8084}"
|
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
|
||||||
replace_or_append_env "${output}" "DB_PASSWORD" "aether"
|
replace_or_append_env "${output}" "DB_PASSWORD" "aether"
|
||||||
replace_or_append_env "${output}" "REDIS_PASSWORD" "aether"
|
replace_or_append_env "${output}" "REDIS_PASSWORD" "aether"
|
||||||
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
|
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
|
||||||
@@ -1781,12 +1824,8 @@ generate_compose_env() {
|
|||||||
replace_or_append_env "${output}" "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
|
replace_or_append_env "${output}" "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
|
||||||
replace_or_append_env "${output}" "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
|
replace_or_append_env "${output}" "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
|
||||||
replace_or_append_env "${output}" "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
|
replace_or_append_env "${output}" "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
|
||||||
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "both"
|
append_compose_log_env_defaults "${output}"
|
||||||
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "pretty"
|
|
||||||
replace_or_append_env "${output}" "AETHER_LOG_DIR" "/app/logs"
|
|
||||||
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
|
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
|
||||||
replace_or_append_env "${output}" "AETHER_SYSTEM_UPDATE_COMMAND" "${COMPOSE_DIR}/update.sh"
|
|
||||||
replace_or_append_env "${output}" "AETHER_SYSTEM_UPDATE_WORKDIR" "${COMPOSE_DIR}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generate_compose_single_node_env() {
|
generate_compose_single_node_env() {
|
||||||
@@ -1800,26 +1839,19 @@ generate_compose_single_node_env() {
|
|||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
RUST_LOG=aether_gateway=info
|
RUST_LOG=aether_gateway=info
|
||||||
AETHER_LOG_DESTINATION=both
|
$(compose_log_env_block)
|
||||||
AETHER_LOG_FORMAT=pretty
|
|
||||||
AETHER_LOG_DIR=/app/logs
|
|
||||||
AETHER_LOG_ROTATION=daily
|
|
||||||
AETHER_LOG_RETENTION_DAYS=7
|
|
||||||
AETHER_LOG_MAX_FILES=30
|
|
||||||
|
|
||||||
APP_IMAGE=$(compose_image)
|
APP_IMAGE=$(compose_image)
|
||||||
APP_PORT=${APP_PORT:-8084}
|
APP_PORT=$(compose_app_port)
|
||||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR}
|
||||||
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
||||||
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||||
AETHER_SYSTEM_UPDATE_COMMAND=${COMPOSE_DIR}/update.sh
|
|
||||||
AETHER_SYSTEM_UPDATE_WORKDIR=${COMPOSE_DIR}
|
|
||||||
AETHER_RUNTIME_BACKEND=memory
|
AETHER_RUNTIME_BACKEND=memory
|
||||||
API_KEY_PREFIX=sk
|
API_KEY_PREFIX=sk
|
||||||
|
|
||||||
AETHER_DATABASE_DRIVER=sqlite
|
AETHER_DATABASE_DRIVER=sqlite
|
||||||
AETHER_DATABASE_URL=sqlite:///app/data/aether.db
|
AETHER_DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
|
||||||
DATABASE_URL=sqlite:///app/data/aether.db
|
DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
|
||||||
|
|
||||||
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}}
|
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}}
|
||||||
ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}}
|
ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}}
|
||||||
@@ -2191,10 +2223,7 @@ install_compose_mode() {
|
|||||||
resolve_compose_dir
|
resolve_compose_dir
|
||||||
info "preparing Docker Compose deployment in ${COMPOSE_DIR}"
|
info "preparing Docker Compose deployment in ${COMPOSE_DIR}"
|
||||||
ensure_directory "${COMPOSE_DIR}"
|
ensure_directory "${COMPOSE_DIR}"
|
||||||
ensure_directory "${COMPOSE_DIR}/logs"
|
|
||||||
|
|
||||||
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
||||||
install_project_file "docker-compose.update.yml" "${COMPOSE_DIR}/docker-compose.update.yml" "0644"
|
|
||||||
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
||||||
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
||||||
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||||
@@ -2211,12 +2240,10 @@ install_compose_mode() {
|
|||||||
|
|
||||||
Docker Compose files are ready:
|
Docker Compose files are ready:
|
||||||
${COMPOSE_DIR}/docker-compose.yml
|
${COMPOSE_DIR}/docker-compose.yml
|
||||||
${COMPOSE_DIR}/docker-compose.update.yml
|
|
||||||
${COMPOSE_DIR}/.env
|
${COMPOSE_DIR}/.env
|
||||||
${COMPOSE_DIR}/.env.example
|
${COMPOSE_DIR}/.env.example
|
||||||
${COMPOSE_DIR}/update.sh
|
${COMPOSE_DIR}/update.sh
|
||||||
${COMPOSE_DIR}/generate_keys.sh
|
${COMPOSE_DIR}/generate_keys.sh
|
||||||
${COMPOSE_DIR}/logs
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "${SKIP_START}" == "true" ]]; then
|
if [[ "${SKIP_START}" == "true" ]]; then
|
||||||
@@ -2233,11 +2260,9 @@ install_compose_single_node_mode() {
|
|||||||
resolve_compose_dir
|
resolve_compose_dir
|
||||||
info "preparing Docker Compose single-node deployment in ${COMPOSE_DIR}"
|
info "preparing Docker Compose single-node deployment in ${COMPOSE_DIR}"
|
||||||
ensure_directory "${COMPOSE_DIR}"
|
ensure_directory "${COMPOSE_DIR}"
|
||||||
ensure_directory "${COMPOSE_DIR}/logs"
|
|
||||||
ensure_directory "${COMPOSE_DIR}/data"
|
ensure_directory "${COMPOSE_DIR}/data"
|
||||||
|
|
||||||
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
||||||
install_project_file "docker-compose.update.yml" "${COMPOSE_DIR}/docker-compose.update.yml" "0644"
|
|
||||||
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
||||||
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
||||||
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||||
@@ -2254,13 +2279,11 @@ install_compose_single_node_mode() {
|
|||||||
|
|
||||||
Docker Compose single-node files are ready:
|
Docker Compose single-node files are ready:
|
||||||
${COMPOSE_DIR}/docker-compose.yml
|
${COMPOSE_DIR}/docker-compose.yml
|
||||||
${COMPOSE_DIR}/docker-compose.update.yml
|
|
||||||
${COMPOSE_DIR}/.env
|
${COMPOSE_DIR}/.env
|
||||||
${COMPOSE_DIR}/.env.example
|
${COMPOSE_DIR}/.env.example
|
||||||
${COMPOSE_DIR}/update.sh
|
${COMPOSE_DIR}/update.sh
|
||||||
${COMPOSE_DIR}/generate_keys.sh
|
${COMPOSE_DIR}/generate_keys.sh
|
||||||
${COMPOSE_DIR}/data
|
${COMPOSE_DIR}/data
|
||||||
${COMPOSE_DIR}/logs
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "${SKIP_START}" == "true" ]]; then
|
if [[ "${SKIP_START}" == "true" ]]; then
|
||||||
@@ -2294,7 +2317,10 @@ install_release() {
|
|||||||
[[ -d "${bundle}/frontend" ]] || die "frontend directory not found: ${bundle}/frontend"
|
[[ -d "${bundle}/frontend" ]] || die "frontend directory not found: ${bundle}/frontend"
|
||||||
|
|
||||||
info "installing release ${VERSION} into ${release_dir}"
|
info "installing release ${VERSION} into ${release_dir}"
|
||||||
install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases" "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
|
install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
|
||||||
|
chown root:"${SERVICE_GROUP}" "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
|
||||||
|
chmod 2775 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
|
||||||
|
install -d -m 0755 "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
|
||||||
if is_darwin; then
|
if is_darwin; then
|
||||||
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
|
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
|
||||||
"${INSTALL_ROOT}/data" \
|
"${INSTALL_ROOT}/data" \
|
||||||
@@ -2308,12 +2334,13 @@ install_release() {
|
|||||||
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
|
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
|
||||||
install -m 0755 "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
|
install -m 0755 "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
|
||||||
cp -R "${bundle}/frontend/." "${release_dir}/frontend/"
|
cp -R "${bundle}/frontend/." "${release_dir}/frontend/"
|
||||||
chmod -R u=rwX,go=rX "${release_dir}"
|
chmod -R u=rwX,g=rwX,o=rX "${release_dir}"
|
||||||
if is_darwin; then
|
if is_darwin; then
|
||||||
chown -R root:wheel "${release_dir}"
|
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
|
||||||
else
|
else
|
||||||
chown -R root:root "${release_dir}"
|
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
|
||||||
fi
|
fi
|
||||||
|
chmod -R u=rwX,g=rwX,o=rX "${release_dir}"
|
||||||
ln -sfn "${release_dir}" "${current_link}"
|
ln -sfn "${release_dir}" "${current_link}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ write_single_node_env() {
|
|||||||
printf '\n# Single-node Compose runtime overrides\n'
|
printf '\n# Single-node Compose runtime overrides\n'
|
||||||
printf 'APP_IMAGE=%s\n' "$app_image"
|
printf 'APP_IMAGE=%s\n' "$app_image"
|
||||||
printf 'APP_PORT=%s\n' "$app_port"
|
printf 'APP_PORT=%s\n' "$app_port"
|
||||||
printf 'AETHER_GATEWAY_STATIC_DIR=/srv/frontend\n'
|
printf 'AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend\n'
|
||||||
printf 'AETHER_LOG_DESTINATION=both\n'
|
printf 'AETHER_LOG_DESTINATION=both\n'
|
||||||
printf 'AETHER_LOG_FORMAT=pretty\n'
|
printf 'AETHER_LOG_FORMAT=pretty\n'
|
||||||
printf 'AETHER_LOG_DIR=/app/logs\n'
|
printf 'AETHER_LOG_DIR=/app/logs\n'
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
|||||||
MODE="auto"
|
MODE="auto"
|
||||||
COMPOSE_DIR=""
|
COMPOSE_DIR=""
|
||||||
APP_SERVICE="app"
|
APP_SERVICE="app"
|
||||||
|
COMPOSE_WAIT_TIMEOUT_SECS=120
|
||||||
|
COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS=2
|
||||||
NO_PULL=false
|
NO_PULL=false
|
||||||
FORCE_RECREATE=false
|
FORCE_RECREATE=false
|
||||||
SHOW_LOGS=false
|
SHOW_LOGS=false
|
||||||
LOCAL_BUILD=false
|
LOCAL_BUILD=false
|
||||||
PREPARE_ONLY=false
|
PREPARE_ONLY=false
|
||||||
COMPOSE_FILES=()
|
COMPOSE_FILES=()
|
||||||
|
COMPOSE=()
|
||||||
|
COMPOSE_ARGS=()
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -121,13 +125,33 @@ if [[ "${MODE}" == "local-build" || "${LOCAL_BUILD}" == "true" ]]; then
|
|||||||
exec bash "${deploy_script}" "${args[@]}"
|
exec bash "${deploy_script}" "${args[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if docker compose version >/dev/null 2>&1; then
|
resolve_compose_cli() {
|
||||||
COMPOSE=(docker compose)
|
if [[ "${#COMPOSE[@]}" -gt 0 ]]; then
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
return
|
||||||
COMPOSE=(docker-compose)
|
fi
|
||||||
else
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE=(docker compose)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE=(docker-compose)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
die "docker compose or docker-compose is required"
|
die "docker compose or docker-compose is required"
|
||||||
fi
|
}
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_config() {
|
||||||
|
compose config "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_compose_cli
|
||||||
|
|
||||||
docker info >/dev/null 2>&1 || die "Docker is not running"
|
docker info >/dev/null 2>&1 || die "Docker is not running"
|
||||||
|
|
||||||
@@ -145,7 +169,7 @@ resolve_compose_file() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${#COMPOSE_FILES[@]}" -eq 0 ]]; then
|
resolve_default_compose_files() {
|
||||||
case "${MODE}" in
|
case "${MODE}" in
|
||||||
compose)
|
compose)
|
||||||
COMPOSE_FILES=("docker-compose.yml")
|
COMPOSE_FILES=("docker-compose.yml")
|
||||||
@@ -167,13 +191,12 @@ if [[ "${#COMPOSE_FILES[@]}" -eq 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -f "${COMPOSE_DIR}/docker-compose.update.yml" ]]; then
|
if [[ "${#COMPOSE_FILES[@]}" -eq 0 ]]; then
|
||||||
COMPOSE_FILES+=("docker-compose.update.yml")
|
resolve_default_compose_files
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMPOSE_ARGS=()
|
|
||||||
COMPOSE_ARGS+=(--project-directory "${COMPOSE_DIR}")
|
COMPOSE_ARGS+=(--project-directory "${COMPOSE_DIR}")
|
||||||
for file in "${COMPOSE_FILES[@]}"; do
|
for file in "${COMPOSE_FILES[@]}"; do
|
||||||
resolved_file="$(resolve_compose_file "${file}")"
|
resolved_file="$(resolve_compose_file "${file}")"
|
||||||
@@ -181,7 +204,7 @@ for file in "${COMPOSE_FILES[@]}"; do
|
|||||||
COMPOSE_ARGS+=(-f "${resolved_file}")
|
COMPOSE_ARGS+=(-f "${resolved_file}")
|
||||||
done
|
done
|
||||||
|
|
||||||
services="$("${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" config --services)"
|
services="$(compose_config --services)"
|
||||||
if ! grep -qx "${APP_SERVICE}" <<< "${services}"; then
|
if ! grep -qx "${APP_SERVICE}" <<< "${services}"; then
|
||||||
die "service '${APP_SERVICE}' not found in compose config"
|
die "service '${APP_SERVICE}' not found in compose config"
|
||||||
fi
|
fi
|
||||||
@@ -189,35 +212,36 @@ fi
|
|||||||
echo ">>> Compose directory: ${COMPOSE_DIR}"
|
echo ">>> Compose directory: ${COMPOSE_DIR}"
|
||||||
echo ">>> App service: ${APP_SERVICE}"
|
echo ">>> App service: ${APP_SERVICE}"
|
||||||
|
|
||||||
if [[ "${PREPARE_ONLY}" == "true" ]]; then
|
compose_pull_app() {
|
||||||
echo ">>> Preparing update by pulling latest image for ${APP_SERVICE}..."
|
compose pull "${APP_SERVICE}"
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" pull "${APP_SERVICE}"
|
}
|
||||||
echo ">>> Done."
|
|
||||||
echo ">>> Note: image is downloaded. Recreate ${APP_SERVICE} to apply the update."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${NO_PULL}" != "true" ]]; then
|
compose_up_app() {
|
||||||
echo ">>> Pulling latest image for ${APP_SERVICE}..."
|
local wait_for_health="${1:-false}"
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" pull "${APP_SERVICE}"
|
local -a up_args=(up -d)
|
||||||
fi
|
|
||||||
|
|
||||||
has_healthcheck() {
|
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" config 2>/dev/null \
|
up_args+=(--force-recreate)
|
||||||
| grep -q "healthcheck:" 2>/dev/null
|
fi
|
||||||
|
if [[ "${wait_for_health}" == "true" ]]; then
|
||||||
|
up_args+=(--wait --wait-timeout "${COMPOSE_WAIT_TIMEOUT_SECS}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
up_args+=("${APP_SERVICE}")
|
||||||
|
compose "${up_args[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_healthy() {
|
wait_healthy() {
|
||||||
local timeout="${1:-120}"
|
local timeout="${1:-${COMPOSE_WAIT_TIMEOUT_SECS}}"
|
||||||
local elapsed=0
|
local elapsed=0
|
||||||
echo ">>> Waiting for ${APP_SERVICE} to become healthy (timeout ${timeout}s)..."
|
echo ">>> Waiting for ${APP_SERVICE} to become healthy (timeout ${timeout}s)..."
|
||||||
while (( elapsed < timeout )); do
|
while (( elapsed < timeout )); do
|
||||||
local container_id
|
local container_id
|
||||||
local state
|
local state
|
||||||
container_id="$("${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" ps -q "${APP_SERVICE}" 2>/dev/null | head -n 1)"
|
container_id="$(compose ps -q "${APP_SERVICE}" 2>/dev/null | head -n 1)"
|
||||||
if [[ -z "${container_id}" ]]; then
|
if [[ -z "${container_id}" ]]; then
|
||||||
sleep 2
|
sleep "${COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS}"
|
||||||
elapsed=$(( elapsed + 2 ))
|
elapsed=$(( elapsed + COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS ))
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
state="$(docker inspect --format='{{.State.Health.Status}}' \
|
state="$(docker inspect --format='{{.State.Health.Status}}' \
|
||||||
@@ -226,48 +250,38 @@ wait_healthy() {
|
|||||||
echo ">>> Container is healthy."
|
echo ">>> Container is healthy."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep "${COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS}"
|
||||||
elapsed=$(( elapsed + 2 ))
|
elapsed=$(( elapsed + COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS ))
|
||||||
done
|
done
|
||||||
echo ">>> WARNING: health check timed out after ${timeout}s."
|
echo ">>> WARNING: health check timed out after ${timeout}s."
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update execution.
|
if [[ "${PREPARE_ONLY}" == "true" ]]; then
|
||||||
# When a healthcheck is defined we use --wait so compose blocks until
|
echo ">>> Preparing update by pulling latest image for ${APP_SERVICE}..."
|
||||||
# the new container passes health, reducing observable downtime.
|
compose_pull_app
|
||||||
|
echo ">>> Done."
|
||||||
up_args=(up -d)
|
echo ">>> Note: image is downloaded. Recreate ${APP_SERVICE} to apply the update."
|
||||||
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
exit 0
|
||||||
up_args+=(--force-recreate)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Compose v2.20+ supports --wait; older versions may reject it.
|
if [[ "${NO_PULL}" != "true" ]]; then
|
||||||
if has_healthcheck; then
|
echo ">>> Pulling latest image for ${APP_SERVICE}..."
|
||||||
up_args+=(--wait --wait-timeout 120)
|
compose_pull_app
|
||||||
fi
|
fi
|
||||||
up_args+=("${APP_SERVICE}")
|
|
||||||
|
|
||||||
echo ">>> Recreating ${APP_SERVICE}..."
|
echo ">>> Recreating ${APP_SERVICE}..."
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "${up_args[@]}" || {
|
compose_up_app true || {
|
||||||
echo ">>> Compose up with --wait failed; falling back to simple recreate..."
|
echo ">>> Compose up with --wait failed; falling back to simple recreate..."
|
||||||
fallback_up_args=(up -d)
|
compose_up_app false
|
||||||
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
wait_healthy || true
|
||||||
fallback_up_args+=(--force-recreate)
|
|
||||||
fi
|
|
||||||
fallback_up_args+=("${APP_SERVICE}")
|
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "${fallback_up_args[@]}"
|
|
||||||
if has_healthcheck; then
|
|
||||||
wait_healthy 120 || true
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo ">>> Current services:"
|
echo ">>> Current services:"
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" ps
|
compose ps
|
||||||
|
|
||||||
echo ">>> Done."
|
echo ">>> Done."
|
||||||
echo ">>> Note: this is a one-click app container update, not a no-restart hot patch."
|
|
||||||
|
|
||||||
if [[ "${SHOW_LOGS}" == "true" ]]; then
|
if [[ "${SHOW_LOGS}" == "true" ]]; then
|
||||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" logs -f "${APP_SERVICE}"
|
compose logs -f "${APP_SERVICE}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user