mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: stabilize system update restart
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -10,7 +10,7 @@ use std::{
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
extract::{Json, Query},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
@@ -25,7 +25,7 @@ use tokio::sync::{broadcast, Mutex};
|
||||
use crate::{
|
||||
admin::auth::session::AdminAuth,
|
||||
admin::response::{AdminEnvelope, AdminError, AdminResponse},
|
||||
runtime::shutdown::request_shutdown,
|
||||
runtime::shutdown::{request_process_restart, request_shutdown},
|
||||
};
|
||||
|
||||
use super::state::{
|
||||
@@ -42,8 +42,8 @@ mod archive;
|
||||
use archive::{extract_release_archive, replace_release_files, rollback_release_update};
|
||||
mod release;
|
||||
use release::{
|
||||
check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release,
|
||||
GitHubRelease, UpdateInfoData,
|
||||
check_latest_release, fetch_latest_release, normalize_version_tag, select_release_archive,
|
||||
update_info_from_release, GitHubRelease, UpdateInfoData,
|
||||
};
|
||||
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
@@ -62,6 +62,12 @@ pub(crate) struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateRequest {
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionData {
|
||||
@@ -168,7 +174,10 @@ pub(crate) async fn update_event_stream(
|
||||
}
|
||||
|
||||
/// `POST /api/admin/system/update`
|
||||
pub(crate) async fn perform_update(_auth: AdminAuth) -> Result<impl IntoResponse, AdminError> {
|
||||
pub(crate) async fn perform_update(
|
||||
_auth: AdminAuth,
|
||||
payload: Option<Json<UpdateRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let _guard = SYSTEM_OPERATION_LOCK
|
||||
.try_lock()
|
||||
.map_err(|_| AdminError::conflict("System update already running"))?;
|
||||
@@ -182,6 +191,7 @@ pub(crate) async fn perform_update(_auth: AdminAuth) -> Result<impl IntoResponse
|
||||
);
|
||||
return Err(AdminError::conflict(reason));
|
||||
}
|
||||
let confirmed_target_version = confirmed_update_target(payload)?;
|
||||
|
||||
emit_update_event(
|
||||
UpdateLogLevel::Info,
|
||||
@@ -202,6 +212,19 @@ pub(crate) async fn perform_update(_auth: AdminAuth) -> Result<impl IntoResponse
|
||||
AdminError::bad_gateway(error)
|
||||
})?;
|
||||
let info = update_info_from_release(&config, release.clone());
|
||||
if info.latest_version != confirmed_target_version {
|
||||
let message = format!(
|
||||
"远端最新版本已变更为 v{},请重新检查并确认",
|
||||
info.latest_version
|
||||
);
|
||||
emit_update_event(
|
||||
UpdateLogLevel::Warning,
|
||||
None,
|
||||
Some("release"),
|
||||
message.clone(),
|
||||
);
|
||||
return Err(AdminError::conflict(message));
|
||||
}
|
||||
if !info.has_update {
|
||||
emit_update_event(
|
||||
UpdateLogLevel::Warning,
|
||||
@@ -265,6 +288,17 @@ pub(crate) async fn perform_update(_auth: AdminAuth) -> Result<impl IntoResponse
|
||||
))
|
||||
}
|
||||
|
||||
fn confirmed_update_target(payload: Option<Json<UpdateRequest>>) -> Result<String, AdminError> {
|
||||
let Some(Json(payload)) = payload else {
|
||||
return Err(AdminError::conflict("更新前需要确认目标版本"));
|
||||
};
|
||||
let target_version = normalize_version_tag(&payload.target_version);
|
||||
if target_version.is_empty() {
|
||||
return Err(AdminError::bad_request("目标版本不能为空"));
|
||||
}
|
||||
Ok(target_version)
|
||||
}
|
||||
|
||||
/// `GET /api/admin/system/update-status`
|
||||
pub(crate) async fn update_status(_auth: AdminAuth) -> Result<impl IntoResponse, AdminError> {
|
||||
let config = SystemUpdateConfig::from_env();
|
||||
@@ -323,11 +357,15 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result<impl IntoResponse, Admin
|
||||
}
|
||||
|
||||
let config = SystemUpdateConfig::from_env();
|
||||
let message = schedule_restart(&config)?;
|
||||
let plan = schedule_restart(&config)?;
|
||||
let message = plan.message;
|
||||
|
||||
tokio::spawn(async {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
request_shutdown();
|
||||
match plan.action {
|
||||
RestartAction::Exec(executable_path) => request_process_restart(executable_path),
|
||||
RestartAction::Shutdown => request_shutdown(),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
@@ -339,13 +377,29 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result<impl IntoResponse, Admin
|
||||
))
|
||||
}
|
||||
|
||||
fn schedule_restart(config: &SystemUpdateConfig) -> Result<&'static str, AdminError> {
|
||||
struct RestartPlan {
|
||||
message: &'static str,
|
||||
action: RestartAction,
|
||||
}
|
||||
|
||||
enum RestartAction {
|
||||
Exec(PathBuf),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
fn schedule_restart(config: &SystemUpdateConfig) -> Result<RestartPlan, AdminError> {
|
||||
if config.deployment_mode == "docker" {
|
||||
return Ok("已安排重启");
|
||||
return Ok(RestartPlan {
|
||||
message: "已安排进程内重启",
|
||||
action: RestartAction::Exec(config.executable_path()?),
|
||||
});
|
||||
}
|
||||
|
||||
spawn_replacement_process(config)?;
|
||||
Ok("已安排自重启")
|
||||
Ok(RestartPlan {
|
||||
message: "已安排自重启",
|
||||
action: RestartAction::Shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_replacement_process(config: &SystemUpdateConfig) -> Result<(), AdminError> {
|
||||
@@ -362,6 +416,14 @@ fn spawn_replacement_process(config: &SystemUpdateConfig) -> Result<(), AdminErr
|
||||
impl SystemUpdateConfig {
|
||||
fn from_env() -> Self {
|
||||
let update_repository = env_string("CPR_UPDATE_REPOSITORY");
|
||||
let deployment_mode =
|
||||
env_string("CPR_DEPLOYMENT_MODE").unwrap_or_else(|| "source".to_string());
|
||||
let executable_path = env_string("CPR_UPDATE_EXE_PATH")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
(deployment_mode == "docker")
|
||||
.then(|| PathBuf::from("/app/bin").join(APP_BINARY_NAME))
|
||||
});
|
||||
let state_file = env_string("CPR_UPDATE_STATE_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/app/data/update-state.json"));
|
||||
@@ -375,15 +437,14 @@ impl SystemUpdateConfig {
|
||||
version: build_version(),
|
||||
git_sha: build_git_sha(),
|
||||
build_time: build_time(),
|
||||
deployment_mode: env_string("CPR_DEPLOYMENT_MODE")
|
||||
.unwrap_or_else(|| "source".to_string()),
|
||||
deployment_mode,
|
||||
build_type: build_type(),
|
||||
update_channel: env_string("CPR_UPDATE_CHANNEL")
|
||||
.unwrap_or_else(|| "stable".to_string()),
|
||||
update_repository,
|
||||
github_api_base: env_string("CPR_GITHUB_API_BASE")
|
||||
.unwrap_or_else(|| GITHUB_API_BASE.to_string()),
|
||||
executable_path: env_string("CPR_UPDATE_EXE_PATH").map(PathBuf::from),
|
||||
executable_path,
|
||||
web_dist_dir: env_string("CPR_WEB_DIST_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)),
|
||||
|
||||
@@ -276,6 +276,6 @@ fn asset_matches_current_platform(name: &str, os_aliases: &[&str], arch_aliases:
|
||||
&& arch_aliases.iter().any(|arch| name.contains(arch))
|
||||
}
|
||||
|
||||
fn normalize_version_tag(version: &str) -> String {
|
||||
pub(super) fn normalize_version_tag(version: &str) -> String {
|
||||
version.trim().trim_start_matches('v').to_string()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{env, error::Error, time::Duration};
|
||||
use std::{env, error::Error, process::Command, time::Duration};
|
||||
|
||||
use axum::Router;
|
||||
use chrono::Utc;
|
||||
@@ -10,7 +10,7 @@ use crate::infra::database::connect_sqlite;
|
||||
use crate::infra::logging::{init_tracing, LogGuard, RotationConfig};
|
||||
use crate::infra::paths::{ensure_data_dir, load_or_create_installation_id};
|
||||
use crate::runtime::services::{BackgroundTaskStores, Services};
|
||||
use crate::runtime::shutdown::shutdown_signal;
|
||||
use crate::runtime::shutdown::{restart_executable_path, shutdown_signal};
|
||||
use crate::runtime::state::{AppState, RuntimeConfig};
|
||||
use crate::runtime::tasks::coordinator::TaskCoordinator;
|
||||
use crate::upstream::fingerprint::{Fingerprint, FingerprintRepository};
|
||||
@@ -23,7 +23,7 @@ pub async fn run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let config = AppConfig::load()?;
|
||||
let host = config.server.host.clone();
|
||||
let port = config.server.port;
|
||||
let _log_guard = init_logging(&config)?;
|
||||
let log_guard = init_logging(&config)?;
|
||||
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
|
||||
let (app, task_coordinator) = build_application(config).await?;
|
||||
|
||||
@@ -37,6 +37,15 @@ pub async fn run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
task_coordinator.shutdown().await;
|
||||
serve_result?;
|
||||
|
||||
if let Some(executable_path) = restart_executable_path() {
|
||||
tracing::info!(
|
||||
executable = %executable_path.display(),
|
||||
"正在以更新后的二进制替换当前进程"
|
||||
);
|
||||
drop(log_guard);
|
||||
exec_replacement_process(executable_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -132,3 +141,25 @@ async fn wait_for_scheduled_restart() {
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn exec_replacement_process(
|
||||
executable_path: std::path::PathBuf,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let error = Command::new(executable_path)
|
||||
.args(env::args_os().skip(1))
|
||||
.exec();
|
||||
Err(Box::new(error))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn exec_replacement_process(
|
||||
_executable_path: std::path::PathBuf,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"process exec restart is only supported on Unix",
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
//! 优雅关闭信号处理。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
OnceLock,
|
||||
},
|
||||
};
|
||||
|
||||
use tokio::signal;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
static SHUTDOWN_REQUESTS: OnceLock<broadcast::Sender<()>> = OnceLock::new();
|
||||
static RESTART_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
static RESTART_EXECUTABLE_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// 等待进程关闭信号(Ctrl+C 或 SIGTERM)。
|
||||
pub async fn shutdown_signal() {
|
||||
@@ -39,6 +47,21 @@ pub fn request_shutdown() {
|
||||
let _ = shutdown_sender().send(());
|
||||
}
|
||||
|
||||
/// 请求进程完成优雅关闭后以新二进制替换当前进程。
|
||||
pub fn request_process_restart(executable_path: PathBuf) {
|
||||
let _ = RESTART_EXECUTABLE_PATH.set(executable_path);
|
||||
RESTART_REQUESTED.store(true, Ordering::SeqCst);
|
||||
request_shutdown();
|
||||
}
|
||||
|
||||
/// 获取待 exec 的重启目标。
|
||||
pub fn restart_executable_path() -> Option<PathBuf> {
|
||||
RESTART_REQUESTED
|
||||
.load(Ordering::SeqCst)
|
||||
.then(|| RESTART_EXECUTABLE_PATH.get().cloned())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn shutdown_sender() -> &'static broadcast::Sender<()> {
|
||||
SHUTDOWN_REQUESTS.get_or_init(|| {
|
||||
let (sender, _receiver) = broadcast::channel(16);
|
||||
|
||||
@@ -172,7 +172,7 @@ async fn update_detail_should_reject_untrusted_github_api_base() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_should_request_graceful_shutdown() {
|
||||
async fn restart_should_request_process_restart_inside_docker() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
set_system_update_env("zyycn/codex-proxy-rs-restart-route", "http://127.0.0.1:9");
|
||||
std::env::set_var("CPR_ENABLE_SELF_RESTART", "true");
|
||||
@@ -201,6 +201,7 @@ async fn restart_should_request_graceful_shutdown() {
|
||||
body["data"]["operationId"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("sysop-restart-"))
|
||||
&& body["data"]["message"] == "已安排进程内重启"
|
||||
&& shutdown.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -310,7 +311,8 @@ async fn update_should_replace_local_release_files_with_latest_asset() {
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_latest")
|
||||
.body(Body::empty())
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -335,6 +337,52 @@ async fn update_should_replace_local_release_files_with_latest_asset() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_reject_when_confirmed_target_differs_from_remote_latest() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-update-target-changed-route";
|
||||
let initial_github = MockServer::start().await;
|
||||
let latest_github = MockServer::start().await;
|
||||
let deploy = tempfile::tempdir().unwrap();
|
||||
let exe_path = deploy.path().join("codex-proxy-rs");
|
||||
let web_dist = deploy.path().join("web/dist");
|
||||
std::fs::create_dir_all(&web_dist).unwrap();
|
||||
std::fs::write(&exe_path, "old-binary").unwrap();
|
||||
std::fs::write(web_dist.join("index.html"), "old-web").unwrap();
|
||||
mount_latest_release(&initial_github, repository, "0.4.0").await;
|
||||
mount_latest_release(&latest_github, repository, "0.5.0").await;
|
||||
set_system_update_env(repository, &initial_github.uri());
|
||||
set_system_update_paths(deploy.path(), &exe_path, &web_dist);
|
||||
let (app, _dir) = admin_system_test_app("system-update-target-changed.sqlite").await;
|
||||
let initial = get_update_detail(&app, true, "req_system_update_target_initial").await;
|
||||
set_system_update_env(repository, &latest_github.uri());
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_target_changed")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert!(
|
||||
initial["latestVersion"] == "0.4.0"
|
||||
&& status == StatusCode::CONFLICT
|
||||
&& body["message"] == "远端最新版本已变更为 v0.5.0,请重新检查并确认"
|
||||
&& std::fs::read_to_string(&exe_path).unwrap() == "old-binary"
|
||||
&& std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "old-web"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_reject_untrusted_github_api_base() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
@@ -564,7 +612,8 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_binary_backup_failure")
|
||||
.body(Body::empty())
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -622,7 +671,8 @@ async fn update_should_remove_stale_file_lock_and_continue() {
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_stale_lock")
|
||||
.body(Body::empty())
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -666,7 +716,8 @@ async fn update_should_replace_web_assets_across_filesystems() {
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_cross_device_update")
|
||||
.body(Body::empty())
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -788,6 +839,10 @@ async fn get_update_detail(app: &axum::Router, refresh: bool, request_id: &str)
|
||||
response_json(response).await["data"].clone()
|
||||
}
|
||||
|
||||
fn confirmed_update_body(version: &str) -> Body {
|
||||
Body::from(json!({ "targetVersion": version }).to_string())
|
||||
}
|
||||
|
||||
async fn assert_update_failure_preserves_files(
|
||||
app: &axum::Router,
|
||||
request_id: &str,
|
||||
@@ -804,7 +859,8 @@ async fn assert_update_failure_preserves_files(
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", request_id)
|
||||
.body(Body::empty())
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(confirmed_update_body("0.4.0"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: codex-proxy-rs
|
||||
|
||||
services:
|
||||
codex-proxy-rs:
|
||||
image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest}
|
||||
|
||||
@@ -2,6 +2,7 @@ import request from '../request'
|
||||
import { API_BASE_URL } from '../constants'
|
||||
|
||||
export const SYSTEM_UPDATE_EVENTS_URL = `${API_BASE_URL}/api/admin/system/update-events`
|
||||
const SYSTEM_HEALTH_URL = `${API_BASE_URL}/healthz`
|
||||
|
||||
export interface SystemVersion {
|
||||
version: string
|
||||
@@ -53,6 +54,13 @@ export function getSystemVersion() {
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkSystemHealth() {
|
||||
const response = await fetch(SYSTEM_HEALTH_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('service is not ready')
|
||||
}
|
||||
}
|
||||
|
||||
export function getSystemUpdateDetail(refresh = false) {
|
||||
return request<SystemUpdateInfo>({
|
||||
url: '/api/admin/system/update-detail',
|
||||
@@ -61,10 +69,11 @@ export function getSystemUpdateDetail(refresh = false) {
|
||||
})
|
||||
}
|
||||
|
||||
export function performSystemUpdate() {
|
||||
export function performSystemUpdate(targetVersion: string) {
|
||||
return request<SystemUpdateStarted>({
|
||||
url: '/api/admin/system/update',
|
||||
method: 'POST',
|
||||
data: { targetVersion },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import {
|
||||
SYSTEM_UPDATE_EVENTS_URL,
|
||||
checkSystemHealth,
|
||||
getSystemVersion,
|
||||
getSystemUpdateDetail,
|
||||
performSystemUpdate,
|
||||
@@ -41,6 +42,9 @@ let restartTimer: number | undefined
|
||||
let loadVersionPromise: Promise<SystemVersion> | undefined
|
||||
let loadSystemPromise: Promise<void> | undefined
|
||||
const maxUpdateLogs = 200
|
||||
const restartCountdownSeconds = 8
|
||||
const restartReadyPollAttempts = 60
|
||||
const restartReadyPollIntervalMs = 1000
|
||||
|
||||
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate))
|
||||
const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release')
|
||||
@@ -174,9 +178,10 @@ async function checkUpdates(refresh = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNow() {
|
||||
async function updateNow(targetVersion: string) {
|
||||
const currentInfo = updateInfo.value
|
||||
if (!canUpdate.value || !currentInfo || updating.value) return null
|
||||
const confirmedTargetVersion = targetVersion.trim()
|
||||
if (!canUpdate.value || !currentInfo || updating.value || !confirmedTargetVersion) return null
|
||||
|
||||
clearUpdateLogs()
|
||||
connectUpdateEvents()
|
||||
@@ -184,7 +189,7 @@ async function updateNow() {
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
try {
|
||||
const result = await performSystemUpdate()
|
||||
const result = await performSystemUpdate(confirmedTargetVersion)
|
||||
updateSuccess.value = true
|
||||
needRestart.value = result.needRestart
|
||||
updateInfo.value = {
|
||||
@@ -214,18 +219,28 @@ function clearRestartTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServiceAndReload() {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
restartCountdown.value = 0
|
||||
|
||||
for (let attempt = 0; attempt < restartReadyPollAttempts; attempt += 1) {
|
||||
try {
|
||||
await getSystemVersion()
|
||||
await checkSystemHealth()
|
||||
window.location.reload()
|
||||
return
|
||||
} catch {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000))
|
||||
if (attempt < restartReadyPollAttempts - 1) {
|
||||
await delay(restartReadyPollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateError.value = '服务重启后暂不可用,请检查进程是否已被自重启或外部守护进程拉起'
|
||||
updateError.value = '服务重启恢复超时,请检查进程是否已被自重启或外部守护进程拉起'
|
||||
restarting.value = false
|
||||
restartCountdown.value = 0
|
||||
}
|
||||
@@ -243,7 +258,7 @@ async function restartNow() {
|
||||
if (restarting.value) return
|
||||
|
||||
restarting.value = true
|
||||
restartCountdown.value = 8
|
||||
restartCountdown.value = restartCountdownSeconds
|
||||
updateError.value = ''
|
||||
clearRestartTimer()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
} from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import type { SystemUpdateInfo } from '@/api'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
@@ -48,6 +50,11 @@ const {
|
||||
} = useSystemUpdate()
|
||||
|
||||
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
|
||||
const updateConfirmOpen = shallowRef(false)
|
||||
const updateConfirmInfo = shallowRef<SystemUpdateInfo | null>(null)
|
||||
const updateConfirmPreviousTarget = shallowRef('')
|
||||
const preparingUpdate = shallowRef(false)
|
||||
let updateLogPinTimer: number | undefined
|
||||
|
||||
const statusView = computed(() => {
|
||||
if (updating.value) {
|
||||
@@ -127,12 +134,42 @@ const updateLogRows = computed(() =>
|
||||
|
||||
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
)
|
||||
|
||||
const updateConfirmRows = computed(() => [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: version.value?.version ? `v${version.value.version}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'previous',
|
||||
label: '已显示目标',
|
||||
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '远端最新目标',
|
||||
value: updateConfirmInfo.value?.latestVersion
|
||||
? `v${updateConfirmInfo.value.latestVersion}`
|
||||
: '-',
|
||||
},
|
||||
])
|
||||
|
||||
const streamStatusLabel = computed(() => {
|
||||
if (updateStreaming.value) return '实时'
|
||||
if (updateStreamError.value) return '断开'
|
||||
return '待连接'
|
||||
})
|
||||
|
||||
const restartButtonLabel = computed(() => {
|
||||
if (!restarting.value) return '立即重启'
|
||||
if (restartCountdown.value > 0) return `重启中 ${restartCountdown.value}s`
|
||||
return '等待恢复'
|
||||
})
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
return value ? String(value) : '-'
|
||||
}
|
||||
@@ -157,6 +194,37 @@ function logTextClass(level: SystemUpdateLogLevel) {
|
||||
return 'text-(--cp-text-primary)'
|
||||
}
|
||||
|
||||
function normalizeUpdateVersion(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
async function scrollUpdateLogsToBottom() {
|
||||
await nextTick()
|
||||
await updateLogScrollbar.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
function pinUpdateLogsToBottom() {
|
||||
window.requestAnimationFrame(() => {
|
||||
void scrollUpdateLogsToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
function startUpdateLogPinning() {
|
||||
if (updateLogPinTimer !== undefined) return
|
||||
|
||||
pinUpdateLogsToBottom()
|
||||
updateLogPinTimer = window.setInterval(pinUpdateLogsToBottom, 250)
|
||||
}
|
||||
|
||||
function stopUpdateLogPinning() {
|
||||
if (updateLogPinTimer === undefined) return
|
||||
|
||||
window.clearInterval(updateLogPinTimer)
|
||||
updateLogPinTimer = undefined
|
||||
}
|
||||
|
||||
async function handleCheckUpdates(force = true) {
|
||||
try {
|
||||
const data = await checkUpdates(force)
|
||||
@@ -166,9 +234,39 @@ async function handleCheckUpdates(force = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
async function handleUpdateRequest() {
|
||||
if (preparingUpdate.value || updating.value) return
|
||||
|
||||
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
|
||||
preparingUpdate.value = true
|
||||
try {
|
||||
const result = await updateNow()
|
||||
const data = await checkUpdates(true)
|
||||
if (!data?.hasUpdate) {
|
||||
toast.success('当前已是最新版本')
|
||||
return
|
||||
}
|
||||
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
|
||||
if (!remoteTargetVersion) {
|
||||
toast.error('远端目标版本为空')
|
||||
return
|
||||
}
|
||||
if (previousTargetVersion && previousTargetVersion !== remoteTargetVersion) {
|
||||
updateConfirmPreviousTarget.value = previousTargetVersion
|
||||
updateConfirmInfo.value = data
|
||||
updateConfirmOpen.value = true
|
||||
return
|
||||
}
|
||||
await runConfirmedUpdate(remoteTargetVersion)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '检查更新失败')
|
||||
} finally {
|
||||
preparingUpdate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfirmedUpdate(targetVersion: string) {
|
||||
try {
|
||||
const result = await updateNow(targetVersion)
|
||||
if (result?.needRestart) {
|
||||
toast.success('更新完成,请重启服务')
|
||||
}
|
||||
@@ -177,6 +275,15 @@ async function handleUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmUpdate() {
|
||||
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
|
||||
if (!targetVersion) return
|
||||
|
||||
updateConfirmOpen.value = false
|
||||
await nextTick()
|
||||
await runConfirmedUpdate(targetVersion)
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
try {
|
||||
await restartNow()
|
||||
@@ -204,13 +311,31 @@ watch(open, (visible) => {
|
||||
|
||||
watch(
|
||||
() => updateLogs.value.at(-1)?.id,
|
||||
async () => {
|
||||
await nextTick()
|
||||
await updateLogScrollbar.value?.scrollToBottom()
|
||||
() => {
|
||||
pinUpdateLogsToBottom()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => updateLogRows.value.length,
|
||||
() => {
|
||||
pinUpdateLogsToBottom()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => updating.value || restarting.value || updateStreaming.value,
|
||||
(active) => {
|
||||
if (active) {
|
||||
startUpdateLogPinning()
|
||||
return
|
||||
}
|
||||
stopUpdateLogPinning()
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopUpdateLogPinning()
|
||||
clearRestartTimer()
|
||||
disconnectUpdateEvents()
|
||||
})
|
||||
@@ -301,7 +426,10 @@ onUnmounted(() => {
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
|
||||
<section
|
||||
v-if="showUpdateProgress"
|
||||
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
|
||||
@@ -372,14 +500,14 @@ onUnmounted(() => {
|
||||
<template #icon>
|
||||
<Power class="size-4" />
|
||||
</template>
|
||||
{{ restarting ? `重启中 ${restartCountdown}s` : '立即重启' }}
|
||||
{{ restartButtonLabel }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
variant="success"
|
||||
:loading="updating"
|
||||
:disabled="!canUpdate"
|
||||
@click="handleUpdate"
|
||||
:loading="preparingUpdate || updating"
|
||||
:disabled="!canUpdate || preparingUpdate"
|
||||
@click="handleUpdateRequest"
|
||||
>
|
||||
<template #icon>
|
||||
<ArrowUpCircle class="size-4" />
|
||||
@@ -388,6 +516,39 @@ onUnmounted(() => {
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="updateConfirmOpen"
|
||||
title="远端目标版本已变化"
|
||||
description="检测到远端 latest 与当前显示的目标版本不一致。"
|
||||
variant="warning"
|
||||
confirm-text="确认更新"
|
||||
:loading="updating"
|
||||
:confirm-disabled="!updateConfirmInfo?.latestVersion"
|
||||
@confirm="handleConfirmUpdate"
|
||||
>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) p-3">
|
||||
<div
|
||||
v-for="item in updateConfirmRows"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<span class="text-[12px] leading-none font-[720] text-(--cp-text-muted)">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
class="truncate font-mono text-[13px] leading-none font-[760] text-(--cp-text-primary)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-[12px] leading-relaxed font-[650] text-(--cp-text-muted)">
|
||||
点击确认后弹窗会关闭,并按远端最新目标版本开始更新。
|
||||
</p>
|
||||
</div>
|
||||
</BaseConfirmModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user