fix: stabilize system update restart

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 14:18:45 +08:00
co-authored by Codex
parent 328e5b03a7
commit a12c61b4f4
9 changed files with 403 additions and 45 deletions
+75 -14
View File
@@ -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)),
+1 -1
View File
@@ -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()
}
+34 -3
View File
@@ -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",
)))
}
+24 -1
View File
@@ -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);
+62 -6
View File
@@ -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
+2
View File
@@ -1,3 +1,5 @@
name: codex-proxy-rs
services:
codex-proxy-rs:
image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest}
+10 -1
View File
@@ -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 },
})
}
+23 -8
View File
@@ -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>