diff --git a/.env.example b/.env.example index 7eec8f115..31753fa78 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ APP_PORT=8084 # APP_IMAGE=ghcr.io/fawney19/aether:beta # APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1 +# Docker Compose Web 自动更新 sidecar 镜像。 +# UPDATER_IMAGE=ghcr.io/fawney19/aether-updater:latest + # API Key 前缀(默认 sk) API_KEY_PREFIX=sk @@ -63,7 +66,7 @@ ADMIN_USERNAME=admin123456 # 管理后台更新策略: # - systemd/二进制部署使用 self:下载 GitHub Release 包,校验 SHA256 后切换 current 并重启。 -# - Docker Compose 使用 docker:后台只提示版本,实际更新请在 compose 目录执行 ./update.sh。 +# - Docker Compose 使用 docker:single-node 下可通过 updater sidecar 从 Web 执行 ./update.sh。 # - 源码/本地构建使用 manual:手动拉取源码或下载 release。 # Compose 默认把持久化文件放在 ./datas/{postgres,mysql,sqlite,redis},日志放在 ./logs。 # 分布式/多节点部署不要使用 ./datas 作为共享数据目录;应使用外部共享 Postgres/MySQL 和 Redis。 @@ -71,6 +74,14 @@ ADMIN_USERNAME=admin123456 # AETHER_BASE_DIR=/opt/aether # AETHER_UPDATE_STRATEGY=docker # AETHER_DOCKER_UPDATE_COMMAND=./update.sh +# Docker Web 自动更新内部 token。生产环境请改成随机长字符串,并保持 app/updater 一致。 +# 可用命令生成:openssl rand -hex 32 +AETHER_UPDATER_TOKEN=change-this-updater-token +# updater 通过宿主 docker.sock 调 compose 时,部署目录需要挂载到容器内同一绝对路径。 +# 普通 shell 里 compose 会使用 PWD;CI/systemd 等场景可显式设置。 +# AETHER_DEPLOY_DIR=/opt/aether/compose +# app 调 updater 的内网地址,compose 默认已配置。 +# AETHER_DOCKER_UPDATER_URL=http://updater:8099 # AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node # AETHER_GATEWAY_NODE_ROLE=all # Docker Compose 默认强制把应用日志输出到 stdout/stderr,并由 Docker 轮转日志。 diff --git a/Cargo.lock b/Cargo.lock index 98390c5f1..1be1e6e15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,6 +522,19 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "aether-updater" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "aether-usage-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 13b1787f1..b3da121cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "apps/aether-tunnel", + "apps/aether-updater", "crates/aether-ai-formats", "crates/aether-admin", "crates/aether-ai-serving", diff --git a/Dockerfile.updater b/Dockerfile.updater new file mode 100644 index 000000000..2197d42d9 --- /dev/null +++ b/Dockerfile.updater @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# Internal updater sidecar for Docker Compose deployments. + +ARG RUST_VERSION=1.95.0 + +FROM rust:${RUST_VERSION}-alpine AS builder + +WORKDIR /build +RUN apk add --no-cache musl-dev +COPY . . +RUN --mount=type=cache,id=aether-updater-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=aether-updater-cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=aether-updater-target,target=/build/target,sharing=locked \ + cargo build --release --locked -p aether-updater && \ + cp target/release/aether-updater /tmp/aether-updater + +FROM docker:27-cli + +RUN apk add --no-cache bash + +COPY --from=builder /tmp/aether-updater /usr/local/bin/aether-updater + +ENV AETHER_UPDATER_BIND=0.0.0.0:8099 \ + AETHER_UPDATER_SCRIPT=/aether-deploy/update.sh \ + AETHER_UPDATER_WORKDIR=/aether-deploy + +EXPOSE 8099 +ENTRYPOINT ["/usr/local/bin/aether-updater"] diff --git a/README.md b/README.md index 8fbda8789..a66a7376d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ docker compose -f docker-compose.single-node.yml pull && docker compose -f docke ### 一键更新 -Docker Compose 部署后,可在部署目录直接执行: +Docker Compose 部署后,可在管理后台右上角“版本信息”或 `/admin/system` 的“系统更新”区域执行 Web 自动更新。Compose 会启动一个内网 `aether-updater` sidecar,它只执行部署目录里的固定 `update.sh`,并通过 `AETHER_UPDATER_TOKEN` 与 app 鉴权。生产环境请在 `.env` 中把 `AETHER_UPDATER_TOKEN` 改成随机长字符串;如果不是从部署目录交互式执行 compose,请设置 `AETHER_DEPLOY_DIR` 为部署目录绝对路径。 + +也可以在部署目录直接执行: ```bash ./update.sh @@ -71,9 +73,9 @@ Docker Compose 部署后,可在部署目录直接执行: 仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,并额外挂载一个容器用户可写的目录到 `/opt/aether/logs`。 -管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。 +管理后台右上角“版本信息”会检测新版本。Docker Compose single-node 部署通过 updater sidecar 拉取镜像并重建 `app` 容器;多节点部署不要从单个后台节点执行更新,应使用外部滚动发布。systemd / launchd / 二进制部署继续使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。 -源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。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` 调整。 +源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。服务器访问 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` 调整。 标准 Docker Compose 使用 Docker named volumes 存放 Postgres/Redis/MySQL 数据;Single Node 使用部署目录下的 `./data` 存放 SQLite 数据。 diff --git a/apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs b/apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs index 819bc2410..49881c220 100644 --- a/apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs @@ -20,8 +20,10 @@ use crate::handlers::admin::system::shared::settings::{ use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload; use crate::handlers::admin::system::shared::update::{ build_admin_system_update_capability_payload, current_self_update_blocker, - prepare_admin_system_update_task, read_update_history, read_update_task_status, - self_update_supported, start_admin_system_rollback_task, start_admin_system_update_task, + current_update_strategy, prepare_admin_system_update_task, read_admin_docker_update_status, + read_update_history, read_update_task_status, self_update_supported, + start_admin_docker_update_task, start_admin_system_rollback_task, + start_admin_system_update_task, UpdateStrategy, }; use crate::important_notification::build_important_notification_test_payload; use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions}; @@ -145,8 +147,14 @@ pub(super) async fn maybe_build_local_admin_core_system_response( .and_then(|body| serde_json::from_slice::(body).ok()) .and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from))); + let update_result = if current_update_strategy() == UpdateStrategy::Docker { + start_admin_docker_update_task().await? + } else { + start_admin_system_update_task(version).await? + }; + return Ok(Some( - match start_admin_system_update_task(version).await? { + match update_result { Ok(payload) => attach_admin_audit_response( Json(payload).into_response(), "admin_system_update_started", @@ -179,6 +187,9 @@ pub(super) async fn maybe_build_local_admin_core_system_response( && request_method == http::Method::GET && request_path == "/api/admin/system/update-status" { + if current_update_strategy() == UpdateStrategy::Docker { + return Ok(Some(Json(read_admin_docker_update_status().await).into_response())); + } let status = read_update_task_status(); return Ok(Some( Json(json!({ diff --git a/apps/aether-gateway/src/handlers/admin/system/shared/settings.rs b/apps/aether-gateway/src/handlers/admin/system/shared/settings.rs index dc92975e4..1dc8b083b 100644 --- a/apps/aether-gateway/src/handlers/admin/system/shared/settings.rs +++ b/apps/aether-gateway/src/handlers/admin/system/shared/settings.rs @@ -1,7 +1,7 @@ use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::shared::build_admin_usage_counter_health_payload; use crate::handlers::admin::system::shared::update::{ - current_self_update_blocker, self_update_supported, + current_web_update_blocker, web_update_supported, }; use crate::handlers::admin::system::shared::update_client::{ build_direct_update_http_client, build_update_http_client, has_explicit_update_proxy_env, @@ -55,7 +55,7 @@ pub(crate) fn build_admin_system_check_update_payload_from_release( latest_release, error, ); - apply_self_update_check_update_override(&mut payload, self_update_supported()); + apply_self_update_check_update_override(&mut payload, web_update_supported()); payload } @@ -65,7 +65,7 @@ pub(crate) fn build_admin_system_releases_list_payload( ) -> serde_json::Value { let mut payload = build_admin_system_releases_payload(current_aether_version(), releases, error); - apply_self_update_releases_override(&mut payload, self_update_supported()); + apply_self_update_releases_override(&mut payload, web_update_supported()); payload } @@ -77,7 +77,7 @@ fn apply_self_update_check_update_override(payload: &mut Value, supported: bool) apply_self_update_check_update_override_with_blocker( payload, supported, - current_self_update_blocker(), + current_web_update_blocker(), ); } @@ -129,15 +129,14 @@ fn apply_self_update_releases_override_with_blocker( } fn current_self_update_release_blocker() -> &'static str { + if web_update_supported() { + return ""; + } if !current_build_is_release() { return SOURCE_BUILD_RELEASE_BLOCKER; } - if self_update_supported() { - "" - } else { - current_self_update_blocker() - } + current_web_update_blocker() } #[cfg(not(test))] diff --git a/apps/aether-gateway/src/handlers/admin/system/shared/update.rs b/apps/aether-gateway/src/handlers/admin/system/shared/update.rs index b13f6270b..1b2722f60 100644 --- a/apps/aether-gateway/src/handlers/admin/system/shared/update.rs +++ b/apps/aether-gateway/src/handlers/admin/system/shared/update.rs @@ -1,7 +1,10 @@ -use crate::handlers::admin::system::shared::update_client::build_update_http_client; +use crate::handlers::admin::system::shared::update_client::{ + build_direct_update_http_client, build_update_http_client, +}; use crate::GatewayError; use axum::http; use futures_util::StreamExt; +use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; use std::path::Component; @@ -116,6 +119,8 @@ const DEFAULT_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS: u64 = 30; const SOURCE_BUILD_UPDATE_BLOCKER: &str = "当前为源码构建,请使用 git pull 后重新编译。"; const DOCKER_UPDATE_BLOCKER: &str = "Docker 部署请使用镜像更新:进入 docker-compose.yml 所在目录执行 ./update.sh。"; +const DOCKER_UPDATER_NOT_CONFIGURED_BLOCKER: &str = + "Docker Web 更新未配置,请设置 AETHER_DOCKER_UPDATER_URL 和 AETHER_UPDATER_TOKEN,或在 compose 目录执行 ./update.sh。"; const MANUAL_UPDATE_BLOCKER: &str = "当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。"; const MULTI_NODE_UPDATE_BLOCKER: &str = @@ -350,6 +355,17 @@ pub(crate) fn self_update_supported() -> bool { ) } +pub(crate) fn docker_web_update_supported() -> bool { + current_update_strategy() == UpdateStrategy::Docker + && current_deployment_topology() == DeploymentTopology::SingleNode + && docker_updater_url().is_some() + && docker_updater_token().is_some() +} + +pub(crate) fn web_update_supported() -> bool { + self_update_supported() || docker_web_update_supported() +} + pub(crate) fn current_self_update_blocker() -> &'static str { if !is_release_build() { return SOURCE_BUILD_UPDATE_BLOCKER; @@ -365,6 +381,18 @@ pub(crate) fn current_self_update_blocker() -> &'static str { } } +pub(crate) fn current_web_update_blocker() -> &'static str { + if self_update_supported() || docker_web_update_supported() { + return "一键更新可用"; + } + if current_update_strategy() == UpdateStrategy::Docker + && current_deployment_topology() == DeploymentTopology::SingleNode + { + return DOCKER_UPDATER_NOT_CONFIGURED_BLOCKER; + } + current_self_update_blocker() +} + fn update_logs_dir() -> PathBuf { std::env::var("AETHER_LOG_DIR") .ok() @@ -380,12 +408,43 @@ fn docker_update_command() -> String { .unwrap_or_else(|| "./update.sh".to_string()) } +fn docker_updater_url() -> Option { + std::env::var("AETHER_DOCKER_UPDATER_URL") + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) +} + +fn docker_updater_token() -> Option { + std::env::var("AETHER_UPDATER_TOKEN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn docker_update_status_label( + update_strategy: UpdateStrategy, + deployment_topology: DeploymentTopology, +) -> &'static str { + if update_strategy != UpdateStrategy::Docker { + "not_docker" + } else if deployment_topology != DeploymentTopology::SingleNode { + "unsupported_topology" + } else if docker_web_update_supported() { + "configured" + } else { + "not_configured" + } +} + pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Value { let build_type = current_build_type(); let update_strategy = current_update_strategy(); let deployment_topology = current_deployment_topology(); let supported = self_update_supported_for(is_release_build(), update_strategy, deployment_topology); + let docker_supported = docker_web_update_supported(); + let can_execute_update = supported || docker_supported; let rollback_available = supported && find_rollback_target().is_some(); let task_status = read_update_task_status(); let base_dir = aether_base_dir(); @@ -397,7 +456,11 @@ pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Valu let data_dir = base_dir.join("data"); json!({ "supported": supported, - "enabled": supported, + "enabled": can_execute_update, + "can_execute_update": can_execute_update, + "execution_mode": if supported { "self" } else if docker_supported { "docker" } else { "manual" }, + "docker_web_update_supported": docker_supported, + "docker_update_status": docker_update_status_label(update_strategy, deployment_topology), "rollback_available": rollback_available, "task_status": task_status.phase, "task_error": task_status.error, @@ -411,14 +474,139 @@ pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Valu "data_dir": data_dir, "logs_dir": update_logs_dir(), "docker_update_command": docker_command, - "message": if supported { + "message": if can_execute_update { "一键更新可用" } else { - current_self_update_blocker() + current_web_update_blocker() }, }) } +#[derive(Debug, Deserialize)] +struct DockerUpdaterRunResponse { + started: Option, + status: Option, +} + +#[derive(Debug, Deserialize)] +struct DockerUpdaterStatus { + status: String, + error: Option, + output_tail: Option>, + exit_code: Option, +} + +pub(crate) async fn read_admin_docker_update_status() -> serde_json::Value { + match fetch_docker_updater_status().await { + Ok(status) => docker_status_payload(status), + Err(err) => json!({ + "phase": "failed", + "error": err, + "output": serde_json::Value::Null, + "progress_label": serde_json::Value::Null, + "downloaded_bytes": serde_json::Value::Null, + "total_bytes": serde_json::Value::Null, + "progress_percent": serde_json::Value::Null, + "docker_status": "unavailable", + }), + } +} + +pub(crate) async fn start_admin_docker_update_task( +) -> Result, GatewayError> { + if !docker_web_update_supported() { + return Ok(Err(( + http::StatusCode::PRECONDITION_REQUIRED, + json!({ "detail": current_web_update_blocker() }), + ))); + } + + let url = docker_updater_url().expect("checked by docker_web_update_supported"); + let token = docker_updater_token().expect("checked by docker_web_update_supported"); + let client = + build_direct_update_http_client(std::time::Duration::from_secs(10), "Docker updater")?; + let response = client + .post(format!("{url}/run")) + .bearer_auth(token) + .send() + .await + .map_err(|err| GatewayError::Internal(format!("Docker updater 请求失败: {err}")))?; + + if response.status() == reqwest::StatusCode::CONFLICT { + return Ok(Err(update_already_running_response())); + } + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Ok(Err(( + http::StatusCode::UNAUTHORIZED, + json!({ "detail": "Docker updater token 无效" }), + ))); + } + let response = response.error_for_status().map_err(|err| { + GatewayError::Internal(format!("Docker updater 返回错误: {err}")) + })?; + let payload = response + .json::() + .await + .map_err(|err| GatewayError::Internal(format!("Docker updater 响应无效: {err}")))?; + + Ok(Ok(json!({ + "message": "Docker 更新已启动,服务会在镜像拉取和容器重建期间短暂不可用", + "started": payload.started.unwrap_or(true), + "status": payload.status.unwrap_or_else(|| "running".to_string()), + "need_restart": true, + }))) +} + +async fn fetch_docker_updater_status() -> Result { + if !docker_web_update_supported() { + return Err(current_web_update_blocker().to_string()); + } + let url = docker_updater_url().ok_or_else(|| DOCKER_UPDATER_NOT_CONFIGURED_BLOCKER.to_string())?; + let token = + docker_updater_token().ok_or_else(|| DOCKER_UPDATER_NOT_CONFIGURED_BLOCKER.to_string())?; + let client = build_direct_update_http_client(std::time::Duration::from_secs(5), "Docker updater") + .map_err(|err| err.to_string())?; + let response = client + .get(format!("{url}/status")) + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("Docker updater 请求失败: {err}"))? + .error_for_status() + .map_err(|err| format!("Docker updater 返回错误: {err}"))?; + response + .json::() + .await + .map_err(|err| format!("Docker updater 响应无效: {err}")) +} + +fn docker_status_payload(status: DockerUpdaterStatus) -> serde_json::Value { + let output_tail = status.output_tail.unwrap_or_default(); + let output = if output_tail.is_empty() { + None + } else { + Some(output_tail.join("\n")) + }; + let phase = match status.status.as_str() { + "running" => "running", + "succeeded" => "prepared", + "failed" => "failed", + _ => "idle", + }; + json!({ + "phase": phase, + "error": status.error, + "output": output, + "output_tail": output_tail, + "progress_label": if phase == "running" { Some("正在执行 Docker 更新脚本") } else { None }, + "downloaded_bytes": serde_json::Value::Null, + "total_bytes": serde_json::Value::Null, + "progress_percent": serde_json::Value::Null, + "docker_status": status.status, + "docker_exit_code": status.exit_code, + }) +} + fn find_rollback_target() -> Option { let previous_path = aether_base_dir().join(PREVIOUS_RELEASE_FILENAME); let previous = std::fs::read_to_string(previous_path).ok()?; @@ -1020,6 +1208,36 @@ fn self_update_rejection_response() -> (http::StatusCode, serde_json::Value) { mod tests { use super::*; use flate2::Compression; + use std::sync::{Mutex, OnceLock}; + + fn env_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvSnapshot { + key: &'static str, + value: Option, + } + + impl EnvSnapshot { + fn capture(key: &'static str) -> Self { + Self { + key, + value: std::env::var(key).ok(), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + if let Some(value) = &self.value { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } fn temp_test_dir(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() @@ -1041,6 +1259,55 @@ mod tests { .expect("frontend index should be written"); } + #[test] + fn docker_web_update_support_requires_updater_configuration() { + let _guard = env_test_lock().lock().expect("env lock"); + let _strategy = EnvSnapshot::capture("AETHER_UPDATE_STRATEGY"); + let _topology = EnvSnapshot::capture("AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY"); + let _url = EnvSnapshot::capture("AETHER_DOCKER_UPDATER_URL"); + let _token = EnvSnapshot::capture("AETHER_UPDATER_TOKEN"); + + std::env::set_var("AETHER_UPDATE_STRATEGY", "docker"); + std::env::set_var("AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY", "single-node"); + std::env::remove_var("AETHER_DOCKER_UPDATER_URL"); + std::env::remove_var("AETHER_UPDATER_TOKEN"); + + assert!(!docker_web_update_supported()); + assert_eq!(current_web_update_blocker(), DOCKER_UPDATER_NOT_CONFIGURED_BLOCKER); + } + + #[test] + fn docker_web_update_supports_configured_single_node() { + let _guard = env_test_lock().lock().expect("env lock"); + let _strategy = EnvSnapshot::capture("AETHER_UPDATE_STRATEGY"); + let _topology = EnvSnapshot::capture("AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY"); + let _url = EnvSnapshot::capture("AETHER_DOCKER_UPDATER_URL"); + let _token = EnvSnapshot::capture("AETHER_UPDATER_TOKEN"); + + std::env::set_var("AETHER_UPDATE_STRATEGY", "docker"); + std::env::set_var("AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY", "single-node"); + std::env::set_var("AETHER_DOCKER_UPDATER_URL", "http://updater:8099"); + std::env::set_var("AETHER_UPDATER_TOKEN", "secret"); + + assert!(docker_web_update_supported()); + assert!(web_update_supported()); + assert_eq!(current_web_update_blocker(), "一键更新可用"); + } + + #[test] + fn docker_status_payload_maps_updater_status() { + let payload = docker_status_payload(DockerUpdaterStatus { + status: "running".to_string(), + error: None, + output_tail: Some(vec![">>> Pulling".to_string(), "done".to_string()]), + exit_code: None, + }); + + assert_eq!(payload["phase"], "running"); + assert_eq!(payload["docker_status"], "running"); + assert_eq!(payload["output"], ">>> Pulling\ndone"); + } + #[test] fn update_strategy_defaults_to_self_only_for_release_builds() { assert_eq!( diff --git a/apps/aether-updater/Cargo.toml b/apps/aether-updater/Cargo.toml new file mode 100644 index 000000000..1cdb2f92f --- /dev/null +++ b/apps/aether-updater/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "aether-updater" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Internal updater sidecar for Aether Docker Compose deployments" + +[dependencies] +axum.workspace = true +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/apps/aether-updater/src/main.rs b/apps/aether-updater/src/main.rs new file mode 100644 index 000000000..e3ab42aa8 --- /dev/null +++ b/apps/aether-updater/src/main.rs @@ -0,0 +1,275 @@ +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use chrono::Utc; +use serde::Serialize; +use serde_json::json; +use std::{ + collections::VecDeque, + env, + io::{BufRead, BufReader}, + net::SocketAddr, + path::PathBuf, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + thread, +}; + +const DEFAULT_BIND: &str = "0.0.0.0:8099"; +const DEFAULT_SCRIPT: &str = "/aether-deploy/update.sh"; +const DEFAULT_WORKDIR: &str = "/aether-deploy"; +const DEFAULT_TAIL_LINES: usize = 200; + +#[derive(Clone)] +struct AppState { + config: Arc, + run: Arc>, +} + +struct Config { + token: String, + script: PathBuf, + workdir: PathBuf, + tail_lines: usize, +} + +#[derive(Clone, Serialize)] +struct RunState { + status: &'static str, + started_at: Option, + finished_at: Option, + exit_code: Option, + error: Option, + output_tail: VecDeque, +} + +impl Default for RunState { + fn default() -> Self { + Self { + status: "idle", + started_at: None, + finished_at: None, + exit_code: None, + error: None, + output_tail: VecDeque::new(), + } + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "aether_updater=info,tower_http=info".into()), + ) + .init(); + + let config = match load_config() { + Ok(config) => Arc::new(config), + Err(err) => { + eprintln!("aether-updater configuration error: {err}"); + std::process::exit(2); + } + }; + let bind = env::var("AETHER_UPDATER_BIND").unwrap_or_else(|_| DEFAULT_BIND.to_string()); + let addr: SocketAddr = bind + .parse() + .unwrap_or_else(|err| panic!("invalid AETHER_UPDATER_BIND {bind}: {err}")); + + let state = AppState { + config, + run: Arc::new(Mutex::new(RunState::default())), + }; + let app = Router::new() + .route("/health", get(health)) + .route("/status", get(status)) + .route("/run", post(run_update)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("bind updater listener"); + tracing::info!(%addr, "aether updater listening"); + axum::serve(listener, app).await.expect("serve updater"); +} + +fn load_config() -> Result { + let token = env::var("AETHER_UPDATER_TOKEN") + .map_err(|_| "AETHER_UPDATER_TOKEN is required".to_string())?; + if token.trim().is_empty() { + return Err("AETHER_UPDATER_TOKEN must not be empty".to_string()); + } + let tail_lines = env::var("AETHER_UPDATER_TAIL_LINES") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_TAIL_LINES); + Ok(Config { + token, + script: env::var("AETHER_UPDATER_SCRIPT") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_SCRIPT)), + workdir: env::var("AETHER_UPDATER_WORKDIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_WORKDIR)), + tail_lines, + }) +} + +async fn health() -> impl IntoResponse { + Json(json!({ "ok": true })) +} + +async fn status(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = authorize(&state, &headers) { + return response; + } + let run = state.run.lock().expect("run state lock").clone(); + Json(run).into_response() +} + +async fn run_update(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = authorize(&state, &headers) { + return response; + } + + { + let mut run = state.run.lock().expect("run state lock"); + if run.status == "running" { + return ( + StatusCode::CONFLICT, + Json(json!({ "detail": "update_already_running" })), + ) + .into_response(); + } + *run = RunState { + status: "running", + started_at: Some(Utc::now().to_rfc3339()), + finished_at: None, + exit_code: None, + error: None, + output_tail: VecDeque::new(), + }; + } + + let worker_state = state.clone(); + thread::spawn(move || execute_update(worker_state)); + + Json(json!({ "started": true, "status": "running" })).into_response() +} + +fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> { + let bearer = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim); + let explicit = headers + .get("x-aether-updater-token") + .and_then(|value| value.to_str().ok()) + .map(str::trim); + if bearer == Some(state.config.token.as_str()) || explicit == Some(state.config.token.as_str()) { + return Ok(()); + } + Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "detail": "invalid_updater_token" })), + ) + .into_response()) +} + +fn execute_update(state: AppState) { + let result = run_fixed_script(&state); + let mut run = state.run.lock().expect("run state lock"); + run.finished_at = Some(Utc::now().to_rfc3339()); + match result { + Ok(code) if code == 0 => { + run.status = "succeeded"; + run.exit_code = Some(code); + } + Ok(code) => { + run.status = "failed"; + run.exit_code = Some(code); + run.error = Some(format!("update script exited with code {code}")); + } + Err(err) => { + run.status = "failed"; + run.error = Some(err); + } + } +} + +fn run_fixed_script(state: &AppState) -> Result { + if !state.config.script.is_file() { + return Err(format!( + "update script not found: {}", + state.config.script.display() + )); + } + if !state.config.workdir.is_dir() { + return Err(format!( + "update workdir not found: {}", + state.config.workdir.display() + )); + } + + append_output( + state, + format!(">>> Starting {}", state.config.script.display()), + ); + let mut child = Command::new(&state.config.script) + .current_dir(&state.config.workdir) + .env("AETHER_UPDATER_MANAGED", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to spawn update script: {err}"))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let mut readers = Vec::new(); + if let Some(stdout) = stdout { + let state = state.clone(); + readers.push(thread::spawn(move || read_stream_lines(state, stdout))); + } + if let Some(stderr) = stderr { + let state = state.clone(); + readers.push(thread::spawn(move || read_stream_lines(state, stderr))); + } + + let status = child + .wait() + .map_err(|err| format!("failed to wait for update script: {err}"))?; + for reader in readers { + let _ = reader.join(); + } + Ok(status.code().unwrap_or(1)) +} + +fn read_stream_lines(state: AppState, reader: R) +where + R: std::io::Read, +{ + for line in BufReader::new(reader).lines() { + match line { + Ok(line) => append_output(&state, line), + Err(err) => { + append_output(&state, format!("failed to read update output: {err}")); + break; + } + } + } +} + +fn append_output(state: &AppState, line: String) { + let mut run = state.run.lock().expect("run state lock"); + run.output_tail.push_back(line); + while run.output_tail.len() > state.config.tail_lines { + run.output_tail.pop_front(); + } +} diff --git a/docker-compose.single-node.yml b/docker-compose.single-node.yml index 66a67f840..1a9e682d7 100644 --- a/docker-compose.single-node.yml +++ b/docker-compose.single-node.yml @@ -10,6 +10,8 @@ services: AETHER_BASE_DIR: /opt/aether AETHER_UPDATE_STRATEGY: docker AETHER_DOCKER_UPDATE_COMMAND: ${AETHER_DOCKER_UPDATE_COMMAND:-./update.sh} + AETHER_DOCKER_UPDATER_URL: http://updater:8099 + AETHER_UPDATER_TOKEN: ${AETHER_UPDATER_TOKEN:-change-this-updater-token} AETHER_DATABASE_DRIVER: sqlite AETHER_DATABASE_URL: sqlite:///opt/aether/data/aether.db AETHER_RUNTIME_BACKEND: memory @@ -23,9 +25,36 @@ services: - "${APP_PORT:-8084}:${APP_PORT:-8084}" volumes: - ./data:/opt/aether/data + depends_on: + updater: + condition: service_started logging: driver: json-file options: max-size: "100m" max-file: "10" restart: unless-stopped + + updater: + image: ${UPDATER_IMAGE:-ghcr.io/fawney19/aether-updater:latest} + container_name: aether-updater + environment: + TZ: Asia/Shanghai + AETHER_UPDATER_TOKEN: ${AETHER_UPDATER_TOKEN:-change-this-updater-token} + AETHER_UPDATER_SCRIPT: ${AETHER_DEPLOY_DIR:-${PWD}}/update.sh + AETHER_UPDATER_WORKDIR: ${AETHER_DEPLOY_DIR:-${PWD}} + RUST_LOG: ${AETHER_UPDATER_LOG:-aether_updater=info} + volumes: + - ${AETHER_DEPLOY_DIR:-${PWD}}:${AETHER_DEPLOY_DIR:-${PWD}}:ro + - /var/run/docker.sock:/var/run/docker.sock + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:8099/health >/dev/null" ] + interval: 10s + timeout: 3s + retries: 3 + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 5103cc492..dd8a30d60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,8 @@ services: AETHER_BASE_DIR: /opt/aether AETHER_UPDATE_STRATEGY: docker AETHER_DOCKER_UPDATE_COMMAND: ${AETHER_DOCKER_UPDATE_COMMAND:-./update.sh} + AETHER_DOCKER_UPDATER_URL: http://updater:8099 + AETHER_UPDATER_TOKEN: ${AETHER_UPDATER_TOKEN:-change-this-updater-token} AETHER_LOG_DESTINATION: stdout AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty} APP_PORT: ${APP_PORT:-8084} @@ -95,6 +97,8 @@ services: condition: service_healthy redis: condition: service_healthy + updater: + condition: service_started ports: - "${APP_PORT:-8084}:${APP_PORT:-8084}" logging: @@ -104,6 +108,30 @@ services: max-file: "10" restart: unless-stopped + updater: + image: ${UPDATER_IMAGE:-ghcr.io/fawney19/aether-updater:latest} + container_name: aether-updater + environment: + TZ: Asia/Shanghai + AETHER_UPDATER_TOKEN: ${AETHER_UPDATER_TOKEN:-change-this-updater-token} + AETHER_UPDATER_SCRIPT: ${AETHER_DEPLOY_DIR:-${PWD}}/update.sh + AETHER_UPDATER_WORKDIR: ${AETHER_DEPLOY_DIR:-${PWD}} + RUST_LOG: ${AETHER_UPDATER_LOG:-aether_updater=info} + volumes: + - ${AETHER_DEPLOY_DIR:-${PWD}}:${AETHER_DEPLOY_DIR:-${PWD}}:ro + - /var/run/docker.sock:/var/run/docker.sock + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:8099/health >/dev/null" ] + interval: 10s + timeout: 3s + retries: 3 + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + restart: unless-stopped + volumes: postgres_data: mysql_data: diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 34c1a3c31..94d8e9e77 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -463,12 +463,16 @@ export interface CheckUpdateResponse { export interface SystemUpdateCapabilityResponse { supported: boolean + can_execute_update?: boolean + execution_mode?: 'self' | 'docker' | 'manual' | string build_type: string update_strategy?: 'self' | 'docker' | 'manual' | string strategy?: 'self' | 'docker' | 'manual' | string deployment_topology?: 'single-node' | 'multi-node' | string topology?: 'single-node' | 'multi-node' | string enabled: boolean + docker_web_update_supported?: boolean + docker_update_status?: string rollback_available: boolean task_status: string task_error: string | null @@ -484,10 +488,13 @@ export interface UpdateTaskStatusResponse { phase: string error: string | null output: string | null + output_tail?: string[] progress_label?: string | null downloaded_bytes?: number | null total_bytes?: number | null progress_percent?: number | null + docker_status?: string | null + docker_exit_code?: number | null } export interface UpdateHistoryEntry { diff --git a/frontend/src/composables/useSystemUpdate.ts b/frontend/src/composables/useSystemUpdate.ts new file mode 100644 index 000000000..6028d30a2 --- /dev/null +++ b/frontend/src/composables/useSystemUpdate.ts @@ -0,0 +1,546 @@ +import { computed, ref, watch } from 'vue' +import { useAuthStore } from '@/stores/auth' +import { useToast } from '@/composables/useToast' +import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type SystemUpdateCapabilityResponse, type UpdateTaskStatusResponse } from '@/api/admin' +import { parseApiError } from '@/utils/errorParser' +import { buildUpdateErrorStatus } from '@/utils/updateStatus' + +export type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting' + +const showUpdateDialog = ref(false) +const updateInfo = ref(null) +const versionStatus = ref(null) +const loadingVersionStatus = ref(false) +const applyingSystemUpdate = ref(false) +const updateSupported = ref(true) +const updateExecutionMode = ref('manual') +const updateStrategy = ref('manual') +const updateCapabilityMessage = ref(null) +const dockerUpdateCommand = ref(null) +const dockerUpdateStatus = ref(null) +const reconnectMessage = ref('等待服务恢复...') +const rollbackAvailable = ref(false) +const rollingBack = ref(false) +const updateTaskStatus = ref(null) +const updateDialogMode = ref<'latest' | 'selected'>('latest') +const systemUpdatePhase = ref(readStoredSystemUpdatePhase()) +const preparedUpdateVersion = ref( + readSessionStorageItem('aether_prepared_update_version') +) + +const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。' +const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。' +const MANUAL_UPDATE_HINT = '当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。' + +let versionStatusLoadPromise: Promise | null = null +let updateStatusPollTimer: number | null = null + +const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null) +const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value)) +const updateOutputTail = computed(() => { + const explicit = updateTaskStatus.value?.output_tail + if (Array.isArray(explicit)) return explicit + return updateTaskStatus.value?.output?.split('\n').filter(Boolean) ?? [] +}) +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) => { + setSessionStorageItem('aether_update_phase', val) +}) +watch(preparedUpdateVersion, (val) => { + if (val) { + setSessionStorageItem('aether_prepared_update_version', val) + } else { + removeSessionStorageItem('aether_prepared_update_version') + } +}) + +function readStoredSystemUpdatePhase(): SystemUpdatePhase { + const stored = readSessionStorageItem('aether_update_phase') + if (stored === 'restart' || stored === 'reconnecting') return stored + return 'download' +} + +function readSessionStorageItem(key: string): string | null { + try { + return sessionStorage.getItem(key) + } catch { + return null + } +} + +function setSessionStorageItem(key: string, value: string) { + try { + sessionStorage.setItem(key, value) + } catch { + // Keep the state in memory when sessionStorage is unavailable. + } +} + +function removeSessionStorageItem(key: string) { + try { + sessionStorage.removeItem(key) + } catch { + // Keep the state in memory when sessionStorage is unavailable. + } +} + +function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string { + if (!status) return updateExecutionMode.value === 'docker' ? '正在执行 Docker 更新脚本...' : '正在下载更新包...' + const label = status.progress_label + ? status.phase.startsWith('downloading') + ? `正在下载${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 'running': + return '正在执行 Docker 更新脚本' + case 'downloading': + return '正在下载更新包' + case 'downloading_checksum': + return '正在下载校验文件' + case 'verifying': + return '正在校验更新包' + case 'extracting': + return '正在解压更新包' + case 'prepared': + return updateExecutionMode.value === 'docker' ? 'Docker 更新已执行' : '更新包已准备完成' + 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 snapshot while the service is restarting. + } +} + +async function waitForPreparedUpdate(): Promise { + 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 + } +} + +function shouldShowUpdatePrompt(latestVersion: string): boolean { + const ignoreData = localStorage.getItem('aether_update_ignore') + if (!ignoreData) return true + + try { + const { version, until } = JSON.parse(ignoreData) + if (version === latestVersion && Date.now() < until) { + return false + } + } catch { + // Invalid ignore payloads should not suppress prompts. + } + return true +} + +async function loadVersionStatus(force = false) { + const authStore = useAuthStore() + if (authStore.user?.role !== 'admin') return null + if (versionStatusLoadPromise) return versionStatusLoadPromise + + loadingVersionStatus.value = true + versionStatusLoadPromise = (async () => { + try { + const [status, capability] = await Promise.all([ + adminApi.checkUpdate(force), + adminApi.getSystemUpdateCapability().catch(() => null), + ]) + if (capability) { + applyUpdateCapability(capability) + } + versionStatus.value = updateSupported.value === false && status.has_update + ? { + ...status, + updatable: false, + update_blocker: updateUnsupportedMessage(SOURCE_BUILD_UPDATE_HINT), + } + : status + syncSystemUpdatePhase(versionStatus.value) + return versionStatus.value + } catch (error) { + versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error) + return versionStatus.value + } finally { + loadingVersionStatus.value = false + versionStatusLoadPromise = null + } + })() + + return versionStatusLoadPromise +} + +function applyUpdateCapability(capability: SystemUpdateCapabilityResponse) { + const canExecute = capability.can_execute_update ?? capability.enabled ?? capability.supported + updateSupported.value = canExecute + rollbackAvailable.value = capability.supported && capability.rollback_available + updateStrategy.value = capability.update_strategy || capability.strategy || 'manual' + updateExecutionMode.value = capability.execution_mode || (capability.supported ? 'self' : updateStrategy.value) + updateCapabilityMessage.value = capability.message || null + dockerUpdateCommand.value = capability.docker_update_command || null + dockerUpdateStatus.value = capability.docker_update_status || null +} + +function updateUnsupportedMessage(fallback = MANUAL_UPDATE_HINT): string { + return updateCapabilityMessage.value || fallback +} + +function syncSystemUpdatePhase(status: CheckUpdateResponse | null) { + if (systemUpdatePhase.value === 'reconnecting') return + if (systemUpdatePhase.value === 'restart') { + if (!preparedUpdateVersion.value) { + systemUpdatePhase.value = 'download' + } + return + } + if (!status?.has_update) { + systemUpdatePhase.value = 'download' + preparedUpdateVersion.value = null + } +} + +function handleVersionRefresh() { + void loadVersionStatus(true) +} + +function openVersionReleasePage() { + if (versionStatus.value?.release_url) { + window.open(versionStatus.value.release_url, '_blank', 'noopener,noreferrer') + } +} + +function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse { + const currentVersion = + versionStatus.value?.current_version || + updateInfo.value?.current_version || + __APP_VERSION__ || + '' + const canUpdate = updateSupported.value + return { + current_version: currentVersion, + latest_version: release.version, + has_update: !release.is_current, + updatable: canUpdate && !release.is_current && release.updatable, + update_blocker: release.is_current + ? '当前已是这个版本' + : !canUpdate + ? updateUnsupportedMessage(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() { + if (applyingSystemUpdate.value) return + const { success, error: showError } = useToast() + applyingSystemUpdate.value = true + try { + const capability = await adminApi.getSystemUpdateCapability() + applyUpdateCapability(capability) + if (!updateSupported.value) { + showError(updateUnsupportedMessage('不支持在线更新'), '不支持在线更新') + return + } + + const targetStatus = updateInfo.value || versionStatus.value + if (targetStatus?.has_update && targetStatus.updatable === false) { + showError(targetStatus.update_blocker || '当前版本暂不支持在线更新', '无法在线更新') + return + } + + if (updateExecutionMode.value === 'docker') { + updateTaskStatus.value = null + startUpdateStatusPolling() + try { + const result = await adminApi.applySystemUpdate(targetStatus?.latest_version || null) + success(result.message || 'Docker 更新已启动') + systemUpdatePhase.value = 'reconnecting' + reconnectMessage.value = 'Docker 正在重建应用服务...' + showUpdateDialog.value = true + applyingSystemUpdate.value = false + await pollHealthUntilReady() + } finally { + stopUpdateStatusPolling() + } + return + } + + if (systemUpdatePhase.value === 'download') { + const targetVersion = targetStatus?.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 + } + + const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value) + success(result.message || '一键重启已启动') + systemUpdatePhase.value = 'reconnecting' + reconnectMessage.value = '服务正在重启...' + showUpdateDialog.value = true + applyingSystemUpdate.value = false + await pollHealthUntilReady() + } catch (err) { + const fallback = systemUpdatePhase.value === 'download' ? '启动更新失败' : '启动重启失败' + showError(parseApiError(err, fallback)) + } finally { + applyingSystemUpdate.value = false + } +} + +async function handleRollback() { + if (rollingBack.value) return + const { success, error: showError } = useToast() + 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 the app service is down. + } + + 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 may still be down. + } + } + + 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() { + const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28' + updateDialogMode.value = 'latest' + updateInfo.value = { + current_version: currentVersion, + latest_version: 'v0.7.0-rc99', + has_update: true, + release_url: 'https://github.com/fawney19/Aether/releases', + release_notes: [ + "### What's Changed", + '- 调整版本更新提示样式', + '- 修复开发分支版本误判', + '- 统一版本号显示格式', + ].join('\n'), + published_at: new Date().toISOString(), + updatable: true, + update_blocker: null, + error: null, + } + systemUpdatePhase.value = 'download' + preparedUpdateVersion.value = null + showUpdateDialog.value = true +} + +function showDebugVersionStatus(hasUpdate = true) { + const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28' + versionStatus.value = { + current_version: currentVersion, + latest_version: hasUpdate ? 'v0.7.0-rc99' : currentVersion, + has_update: hasUpdate, + release_url: hasUpdate ? 'https://github.com/fawney19/Aether/releases' : null, + release_notes: hasUpdate + ? [ + "### What's Changed", + '- 调整版本更新提示样式', + '- 修复开发分支版本误判', + '- 统一版本号显示格式', + ].join('\n') + : null, + published_at: hasUpdate ? new Date().toISOString() : null, + updatable: hasUpdate, + update_blocker: null, + error: null, + } + systemUpdatePhase.value = 'download' + preparedUpdateVersion.value = null +} + +async function checkForUpdate() { + const authStore = useAuthStore() + if (!authStore.canOperateAdmin) return + + const sessionKey = 'aether_update_checked' + if (sessionStorage.getItem(sessionKey)) return + sessionStorage.setItem(sessionKey, '1') + + const result = versionStatus.value ?? await loadVersionStatus() + if (result?.has_update && result.latest_version && shouldShowUpdatePrompt(result.latest_version)) { + updateDialogMode.value = 'latest' + updateInfo.value = result + showUpdateDialog.value = true + } +} + +export function useSystemUpdate() { + return { + showUpdateDialog, + updateInfo, + versionStatus, + loadingVersionStatus, + applyingSystemUpdate, + updateSupported, + updateExecutionMode, + updateStrategy, + dockerUpdateCommand, + dockerUpdateStatus, + reconnectMessage, + rollbackAvailable, + rollingBack, + updateTaskStatus, + updateOutputTail, + updateDialogMode, + systemUpdatePhase, + updateProgressText, + updateProgressPercent, + updateDialogTitle, + updateDialogVersionLabel, + updateDialogReleaseLinkLabel, + loadVersionStatus, + handleVersionRefresh, + openVersionReleasePage, + openReleaseUpdateDialog, + handleApplySystemUpdate, + handleRollback, + checkForUpdate, + refreshUpdateTaskStatus, + showDebugUpdateDialog, + showDebugVersionStatus, + } +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index a76c80e2a..065410b0e 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -434,11 +434,8 @@ import { useAuthStore } from '@/stores/auth' import { useModuleStore } from '@/stores/modules' import { useDarkMode } from '@/composables/useDarkMode' import { useSiteInfo } from '@/composables/useSiteInfo' -import { useToast } from '@/composables/useToast' import { isDemoMode } from '@/config/demo' -import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type SystemUpdateCapabilityResponse, type UpdateTaskStatusResponse } from '@/api/admin' import { announcementApi, type Announcement } from '@/api/announcements' -import { parseApiError } from '@/utils/errorParser' import Button from '@/components/ui/button.vue' import { Dialog } from '@/components/ui' import AppShell from '@/components/layout/AppShell.vue' @@ -446,7 +443,7 @@ import SidebarNav from '@/components/layout/SidebarNav.vue' import HeaderLogo from '@/components/HeaderLogo.vue' import UpdateDialog from '@/components/common/UpdateDialog.vue' import VersionButton from '@/components/common/VersionButton.vue' -import { buildUpdateErrorStatus } from '@/utils/updateStatus' +import { useSystemUpdate } from '@/composables/useSystemUpdate' import { Home, Users, @@ -489,15 +486,12 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools' import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch' import { sanitizeMarkdown } from '@/utils/sanitize' -type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting' - const router = useRouter() const route = useRoute() const authStore = useAuthStore() const moduleStore = useModuleStore() const { themeMode, toggleDarkMode } = useDarkMode() const { siteName, siteSubtitle } = useSiteInfo() -const { success, error: showError } = useToast() const isDemo = computed(() => isDemoMode()) const isAdmin = computed(() => authStore.user?.role === 'admin') @@ -513,487 +507,40 @@ const requiredAnnouncementOpen = computed({ }) const currentRequiredAnnouncement = computed(() => requiredAnnouncements.value[0] ?? null) -// 更新检查相关 -const showUpdateDialog = ref(false) -const updateInfo = ref(null) -const versionStatus = ref(null) -const loadingVersionStatus = ref(false) -const applyingSystemUpdate = ref(false) -const updateSupported = ref(true) -const updateStrategy = ref('manual') -const updateCapabilityMessage = ref(null) -const dockerUpdateCommand = ref(null) -const reconnectMessage = ref('等待服务恢复...') -const rollbackAvailable = ref(false) -const rollingBack = ref(false) -const updateTaskStatus = ref(null) -const updateDialogMode = ref<'latest' | 'selected'>('latest') -const systemUpdatePhase = ref(readStoredSystemUpdatePhase()) -const preparedUpdateVersion = ref( - readSessionStorageItem('aether_prepared_update_version') -) -const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。' -const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。' -const MANUAL_UPDATE_HINT = '当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。' -let versionStatusLoadPromise: Promise | 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) => { - setSessionStorageItem('aether_update_phase', val) -}) -watch(preparedUpdateVersion, (val) => { - if (val) { - setSessionStorageItem('aether_prepared_update_version', val) - } else { - removeSessionStorageItem('aether_prepared_update_version') - } -}) - -function readStoredSystemUpdatePhase(): SystemUpdatePhase { - const stored = readSessionStorageItem('aether_update_phase') - if (stored === 'restart' || stored === 'reconnecting') return stored - return 'download' -} - -function readSessionStorageItem(key: string): string | null { - try { - return sessionStorage.getItem(key) - } catch { - return null - } -} - -function setSessionStorageItem(key: string, value: string) { - try { - sessionStorage.setItem(key, value) - } catch { - // Ignore storage failures; update state still lives in memory for this page. - } -} - -function removeSessionStorageItem(key: string) { - try { - sessionStorage.removeItem(key) - } catch { - // Ignore storage failures; update state still lives in memory for this page. - } -} - -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 { - 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 - } -} +const { + showUpdateDialog, + updateInfo, + versionStatus, + loadingVersionStatus, + applyingSystemUpdate, + updateSupported, + updateStrategy, + dockerUpdateCommand, + reconnectMessage, + rollbackAvailable, + rollingBack, + systemUpdatePhase, + updateProgressText, + updateProgressPercent, + updateDialogTitle, + updateDialogVersionLabel, + updateDialogReleaseLinkLabel, + loadVersionStatus, + handleVersionRefresh, + openVersionReleasePage, + openReleaseUpdateDialog, + handleApplySystemUpdate, + handleRollback, + checkForUpdate, + showDebugUpdateDialog, + showDebugVersionStatus, +} = useSystemUpdate() // 路由变化时自动关闭移动端菜单 watch(() => route.path, () => { mobileMenuOpen.value = false }) -// 检查是否应该显示更新提示 -function shouldShowUpdatePrompt(latestVersion: string): boolean { - const ignoreKey = 'aether_update_ignore' - const ignoreData = localStorage.getItem(ignoreKey) - if (!ignoreData) return true - - try { - const { version, until } = JSON.parse(ignoreData) - // 如果忽略的是同一版本且未过期,则不显示 - if (version === latestVersion && Date.now() < until) { - return false - } - } catch { - // 解析失败,显示提示 - } - return true -} - -async function loadVersionStatus(force = false) { - if (!isAdmin.value) return null - if (versionStatusLoadPromise) return versionStatusLoadPromise - - loadingVersionStatus.value = true - versionStatusLoadPromise = (async () => { - try { - const [status, capability] = await Promise.all([ - adminApi.checkUpdate(force), - adminApi.getSystemUpdateCapability().catch(() => null), - ]) - if (capability) { - applyUpdateCapability(capability) - } - versionStatus.value = updateSupported.value === false && status.has_update - ? { - ...status, - updatable: false, - update_blocker: updateUnsupportedMessage(SOURCE_BUILD_UPDATE_HINT), - } - : status - syncSystemUpdatePhase(versionStatus.value) - return versionStatus.value - } catch (error) { - versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error) - return versionStatus.value - } finally { - loadingVersionStatus.value = false - versionStatusLoadPromise = null - } - })() - - return versionStatusLoadPromise -} - -function applyUpdateCapability(capability: SystemUpdateCapabilityResponse) { - updateSupported.value = capability.supported - rollbackAvailable.value = capability.supported && capability.rollback_available - updateStrategy.value = capability.update_strategy || capability.strategy || 'manual' - updateCapabilityMessage.value = capability.message || null - dockerUpdateCommand.value = capability.docker_update_command || null -} - -function updateUnsupportedMessage(fallback = MANUAL_UPDATE_HINT): string { - return updateCapabilityMessage.value || fallback -} - -function syncSystemUpdatePhase(status: CheckUpdateResponse | null) { - if (systemUpdatePhase.value === 'reconnecting') return - if (systemUpdatePhase.value === 'restart') { - if (!preparedUpdateVersion.value) { - systemUpdatePhase.value = 'download' - } - return - } - if (!status?.has_update) { - systemUpdatePhase.value = 'download' - preparedUpdateVersion.value = null - } -} - -function handleVersionRefresh() { - void loadVersionStatus(true) -} - -function openVersionReleasePage() { - if (versionStatus.value?.release_url) { - window.open(versionStatus.value.release_url, '_blank', 'noopener,noreferrer') - } -} - -function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse { - const currentVersion = - versionStatus.value?.current_version || - updateInfo.value?.current_version || - __APP_VERSION__ || - '' - const canSelfUpdate = updateSupported.value - return { - current_version: currentVersion, - latest_version: release.version, - has_update: !release.is_current, - updatable: canSelfUpdate && !release.is_current && release.updatable, - update_blocker: release.is_current - ? '当前已是这个版本' - : !canSelfUpdate - ? updateUnsupportedMessage(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() { - if (applyingSystemUpdate.value) return - applyingSystemUpdate.value = true - try { - const capability = await adminApi.getSystemUpdateCapability() - applyUpdateCapability(capability) - if (!capability.supported) { - showError( - updateUnsupportedMessage('不支持在线自更新'), - '不支持在线更新' - ) - return - } - - if (systemUpdatePhase.value === 'download') { - const targetStatus = updateInfo.value || versionStatus.value - if (targetStatus?.has_update && targetStatus.updatable === false) { - showError( - 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 - } - - const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value) - success(result.message || '一键重启已启动') - systemUpdatePhase.value = 'reconnecting' - reconnectMessage.value = '服务正在重启...' - showUpdateDialog.value = true - applyingSystemUpdate.value = false - await pollHealthUntilReady() - } catch (err) { - const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败' - showError(parseApiError(err, fallback)) - } finally { - applyingSystemUpdate.value = false - } -} - -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() { - const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28' - updateDialogMode.value = 'latest' - updateInfo.value = { - current_version: currentVersion, - latest_version: 'v0.7.0-rc99', - has_update: true, - release_url: 'https://github.com/fawney19/Aether/releases', - release_notes: [ - "### What's Changed", - '- 调整版本更新提示样式', - '- 修复开发分支版本误判', - '- 统一版本号显示格式', - ].join('\n'), - published_at: new Date().toISOString(), - updatable: true, - update_blocker: null, - error: null, - } - systemUpdatePhase.value = 'download' - preparedUpdateVersion.value = null - showUpdateDialog.value = true -} - -function showDebugVersionStatus(hasUpdate = true) { - const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28' - versionStatus.value = { - current_version: currentVersion, - latest_version: hasUpdate ? 'v0.7.0-rc99' : currentVersion, - has_update: hasUpdate, - release_url: hasUpdate ? 'https://github.com/fawney19/Aether/releases' : null, - release_notes: hasUpdate - ? [ - "### What's Changed", - '- 调整版本更新提示样式', - '- 修复开发分支版本误判', - '- 统一版本号显示格式', - ].join('\n') - : null, - published_at: hasUpdate ? new Date().toISOString() : null, - updatable: hasUpdate, - update_blocker: null, - error: null, - } - systemUpdatePhase.value = 'download' - preparedUpdateVersion.value = null -} - -// 检查更新 -async function checkForUpdate() { - // 只有管理员才检查更新 - if (!authStore.canOperateAdmin) return - - // 同一会话内只检查一次 - const sessionKey = 'aether_update_checked' - if (sessionStorage.getItem(sessionKey)) return - sessionStorage.setItem(sessionKey, '1') - - const result = versionStatus.value ?? await loadVersionStatus() - if (result?.has_update && result.latest_version) { - if (shouldShowUpdatePrompt(result.latest_version)) { - updateDialogMode.value = 'latest' - updateInfo.value = result - showUpdateDialog.value = true - } - } -} - function syncAuthNotice() { authStore.syncToken() showAuthError.value = !!authStore.user && !authStore.token diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index 8bf172b2e..dd6e59df8 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -164,6 +164,9 @@ :scheduled-tasks="scheduledTasks" /> + + + + +
+
+
+

+ 当前版本 +

+

+ {{ currentVersion }} +

+
+
+

+ 最新版本 +

+

+ {{ latestVersion }} +

+
+
+

+ 部署模式 +

+

+ {{ modeLabel }} +

+
+
+

+ 更新状态 +

+

+ {{ statusLabel }} +

+
+
+ +
+ 检查更新失败:{{ versionStatus.error }} +
+ +
+ {{ versionStatus.update_blocker || '当前部署暂不支持从 Web 执行更新。' }} + 可在服务器执行:{{ dockerUpdateCommand }} +
+ +
+
+ {{ updateProgressText }} + + {{ updateProgressPercent }}% + +
+
+
+
+
+ +
+
+ Docker updater 输出 + {{ updateTaskStatus?.docker_status || 'idle' }} +
+
{{ dockerOutputText }}
+
+ +
+ + + + + + + +
+
+ + + +