fix: refine system update workflow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 12:38:29 +08:00
co-authored by Codex
parent 21a6cf1dc5
commit 4f35230ab7
12 changed files with 146 additions and 87 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ use super::{
update_settings,
},
system::routes::{
check_updates, perform_update, restart, rollback, update_event_stream, update_status,
perform_update, restart, rollback, update_detail, update_event_stream, update_status,
version,
},
};
@@ -49,7 +49,7 @@ pub fn router() -> Router<AppState> {
post(regenerate_admin_api_key),
)
.route("/api/admin/system/version", get(version))
.route("/api/admin/system/check-updates", get(check_updates))
.route("/api/admin/system/update-detail", get(update_detail))
.route("/api/admin/system/update-events", get(update_event_stream))
.route("/api/admin/system/update", post(perform_update))
.route("/api/admin/system/update-status", get(update_status))
+1 -1
View File
@@ -1,5 +1,5 @@
//! 管理端系统路由入口。
pub(crate) use super::updater::{
check_updates, perform_update, restart, rollback, update_event_stream, update_status, version,
perform_update, restart, rollback, update_detail, update_event_stream, update_status, version,
};
+18 -9
View File
@@ -43,7 +43,7 @@ use archive::{extract_release_archive, replace_release_files, rollback_release_u
mod release;
use release::{
check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release,
GitHubRelease,
GitHubRelease, UpdateInfoData,
};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
@@ -58,8 +58,8 @@ static UPDATE_EVENT_SENDER: OnceLock<broadcast::Sender<SystemUpdateEvent>> = Onc
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CheckUpdateQuery {
force: Option<bool>,
pub(crate) struct UpdateDetailQuery {
refresh: Option<bool>,
}
#[derive(Debug, Serialize)]
@@ -71,6 +71,10 @@ struct VersionData {
deployment_mode: String,
deployment_mode_label: String,
update_channel: String,
latest_version: String,
has_update: bool,
update_cached: bool,
update_warning: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -123,19 +127,20 @@ struct SystemUpdateConfig {
/// `GET /api/admin/system/version`
pub(crate) async fn version(_auth: AdminAuth) -> Result<impl IntoResponse, AdminError> {
let config = SystemUpdateConfig::from_env();
let update_info = check_latest_release(&config, false).await;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(config.version_data()),
AdminEnvelope::ok(config.version_data(&update_info)),
))
}
/// `GET /api/admin/system/check-updates`
pub(crate) async fn check_updates(
/// `GET /api/admin/system/update-detail`
pub(crate) async fn update_detail(
_auth: AdminAuth,
Query(query): Query<CheckUpdateQuery>,
Query(query): Query<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError> {
let config = SystemUpdateConfig::from_env();
let info = check_latest_release(&config, query.force.unwrap_or(false)).await;
let info = check_latest_release(&config, query.refresh.unwrap_or(false)).await;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info)))
}
@@ -388,7 +393,7 @@ impl SystemUpdateConfig {
}
}
fn version_data(&self) -> VersionData {
fn version_data(&self, update_info: &UpdateInfoData) -> VersionData {
VersionData {
version: self.version.clone(),
git_sha: self.git_sha.clone(),
@@ -396,6 +401,10 @@ impl SystemUpdateConfig {
deployment_mode: self.deployment_mode.clone(),
deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(),
update_channel: self.update_channel.clone(),
latest_version: update_info.latest_version.clone(),
has_update: update_info.has_update,
update_cached: update_info.cached,
update_warning: update_info.warning.clone(),
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ const ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const CONTENT_SECURITY_POLICY: &str = concat!(
"default-src 'self'; ",
"script-src 'self'; ",
"style-src 'self'; ",
"style-src 'self' 'unsafe-inline'; ",
"img-src 'self' data:; ",
"font-src 'self' data:; ",
"connect-src 'self'; ",
+20 -13
View File
@@ -46,7 +46,10 @@ const VERSION_NEW: &str = "0.2.0";
#[tokio::test]
async fn version_should_return_backend_build_metadata() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
set_system_update_env("zyycn/codex-proxy-rs-version-route", "http://127.0.0.1:9");
let repository = "zyycn/codex-proxy-rs-version-route";
let github = MockServer::start().await;
mount_latest_release(&github, repository, "0.2.0").await;
set_system_update_env(repository, &github.uri());
std::env::set_var("CPR_GIT_SHA", "version-test-sha");
std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z");
let (app, _dir) = admin_system_test_app("system-version.sqlite").await;
@@ -72,22 +75,26 @@ async fn version_should_return_backend_build_metadata() {
&& body["data"]["deploymentMode"] == "docker"
&& body["data"]["deploymentModeLabel"] == "Docker"
&& body["data"]["updateChannel"] == "stable"
&& body["data"]["latestVersion"] == VERSION_NEW
&& body["data"]["hasUpdate"] == true
&& body["data"]["updateCached"] == false
&& body["data"]["updateWarning"].is_null()
);
}
#[tokio::test]
async fn check_updates_should_use_cached_release_when_not_forced() {
async fn update_detail_should_use_cached_release_when_not_refreshed() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-cache-route";
let success_server = MockServer::start().await;
mount_latest_release(&success_server, repository, "0.2.0").await;
set_system_update_env(repository, &success_server.uri());
let (app, _dir) = admin_system_test_app("system-cache.sqlite").await;
let initial = get_check_updates(&app, true, "req_system_cache_initial").await;
let initial = get_update_detail(&app, true, "req_system_cache_initial").await;
let failing_server = MockServer::start().await;
set_system_update_env(repository, &failing_server.uri());
let cached = get_check_updates(&app, false, "req_system_cache_cached").await;
let cached = get_update_detail(&app, false, "req_system_cache_cached").await;
assert!(
initial["hasUpdate"] == true
@@ -97,14 +104,14 @@ async fn check_updates_should_use_cached_release_when_not_forced() {
}
#[tokio::test]
async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-cache-forced-route";
let success_server = MockServer::start().await;
mount_latest_release(&success_server, repository, "0.3.0").await;
set_system_update_env(repository, &success_server.uri());
let (app, _dir) = admin_system_test_app("system-cache-forced.sqlite").await;
let initial = get_check_updates(&app, true, "req_system_cache_forced_initial").await;
let initial = get_update_detail(&app, true, "req_system_cache_forced_initial").await;
let failing_server = MockServer::start().await;
Mock::given(method("GET"))
@@ -113,7 +120,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
.mount(&failing_server)
.await;
set_system_update_env(repository, &failing_server.uri());
let fallback = get_check_updates(&app, true, "req_system_cache_forced_fallback").await;
let fallback = get_update_detail(&app, true, "req_system_cache_forced_fallback").await;
assert!(
initial["hasUpdate"] == true
@@ -124,7 +131,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
}
#[tokio::test]
async fn check_updates_should_report_source_build_as_unsupported() {
async fn update_detail_should_report_source_build_as_unsupported() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
let repository = "zyycn/codex-proxy-rs-source-route";
let github = MockServer::start().await;
@@ -133,7 +140,7 @@ async fn check_updates_should_report_source_build_as_unsupported() {
std::env::set_var("CPR_BUILD_TYPE", "source");
let (app, _dir) = admin_system_test_app("system-source-build.sqlite").await;
let info = get_check_updates(&app, true, "req_system_source_build").await;
let info = get_update_detail(&app, true, "req_system_source_build").await;
assert!(
info["currentVersion"] == VERSION_OLD
@@ -147,7 +154,7 @@ async fn check_updates_should_report_source_build_as_unsupported() {
}
#[tokio::test]
async fn check_updates_should_reject_untrusted_github_api_base() {
async fn update_detail_should_reject_untrusted_github_api_base() {
let _guard = SYSTEM_ENV_LOCK.lock().await;
set_system_update_env(
"zyycn/codex-proxy-rs-untrusted-api-base-route",
@@ -155,7 +162,7 @@ async fn check_updates_should_reject_untrusted_github_api_base() {
);
let (app, _dir) = admin_system_test_app("system-untrusted-api-base.sqlite").await;
let info = get_check_updates(&app, true, "req_system_untrusted_api_base").await;
let info = get_update_detail(&app, true, "req_system_untrusted_api_base").await;
assert!(
info["hasUpdate"] == false
@@ -763,13 +770,13 @@ async fn update_events_should_open_authenticated_sse_stream() {
assert!(response.status() == StatusCode::OK && content_type.starts_with("text/event-stream"));
}
async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> Value {
async fn get_update_detail(app: &axum::Router, refresh: bool, request_id: &str) -> Value {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/admin/system/check-updates?force={force}"))
.uri(format!("/api/admin/system/update-detail?refresh={refresh}"))
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", request_id)
.body(Body::empty())
+1 -1
View File
@@ -66,7 +66,7 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(
csp.contains("style-src 'self';") && !csp.contains("unsafe-inline"),
csp.contains("script-src 'self';") && csp.contains("style-src 'self' 'unsafe-inline';"),
"unexpected content security policy: {csp}"
);
assert_eq!(
+1 -1
View File
@@ -180,7 +180,7 @@ GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。
核心接口:
- `GET /api/admin/system/version`
- `GET /api/admin/system/check-updates`
- `GET /api/admin/system/update-detail`
- `GET /api/admin/system/update-events`
- `GET /api/admin/system/update-status`
- `POST /api/admin/system/update`
+7 -3
View File
@@ -10,6 +10,10 @@ export interface SystemVersion {
deploymentMode: string
deploymentModeLabel: string
updateChannel: string
latestVersion: string
hasUpdate: boolean
updateCached: boolean
updateWarning?: string
}
export interface SystemUpdateInfo {
@@ -49,11 +53,11 @@ export function getSystemVersion() {
})
}
export function checkSystemUpdates(force = false) {
export function getSystemUpdateDetail(refresh = false) {
return request<SystemUpdateInfo>({
url: '/api/admin/system/check-updates',
url: '/api/admin/system/update-detail',
method: 'GET',
params: { force },
params: { refresh },
})
}
+32 -12
View File
@@ -2,8 +2,8 @@ import { computed, ref, shallowRef } from 'vue'
import {
SYSTEM_UPDATE_EVENTS_URL,
checkSystemUpdates,
getSystemVersion,
getSystemUpdateDetail,
performSystemUpdate,
restartSystem,
type SystemUpdateInfo,
@@ -38,9 +38,11 @@ const updateStreamError = shallowRef('')
const updateEventSource = shallowRef<EventSource | null>(null)
let restartTimer: number | undefined
let loadVersionPromise: Promise<SystemVersion> | undefined
let loadSystemPromise: Promise<void> | undefined
const maxUpdateLogs = 200
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate))
const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release')
const canUpdate = computed(
() =>
@@ -121,30 +123,47 @@ function disconnectUpdateEvents() {
updateStreaming.value = false
}
async function loadSystem(force = false) {
if (loading.value) return
async function loadSystem(refresh = false) {
if (loadSystemPromise) return loadSystemPromise
loading.value = true
try {
const [versionData, updateData] = await Promise.all([
getSystemVersion(),
checkSystemUpdates(force),
])
version.value = versionData
loadSystemPromise = (async () => {
const updateData = await getSystemUpdateDetail(refresh)
updateInfo.value = updateData
if (!version.value) {
version.value = await getSystemVersion()
}
loadedOnce.value = true
})()
try {
await loadSystemPromise
} finally {
loading.value = false
loadSystemPromise = undefined
}
}
async function checkUpdates(force = true) {
async function loadVersion() {
if (loadVersionPromise) return loadVersionPromise
loadVersionPromise = getSystemVersion()
try {
const versionData = await loadVersionPromise
version.value = versionData
return versionData
} finally {
loadVersionPromise = undefined
}
}
async function checkUpdates(refresh = true) {
if (checking.value) return updateInfo.value
checking.value = true
resetUpdateResult()
try {
updateInfo.value = await checkSystemUpdates(force)
updateInfo.value = await getSystemUpdateDetail(refresh)
if (!version.value) {
version.value = await getSystemVersion()
}
@@ -268,6 +287,7 @@ export function useSystemUpdate() {
hasUpdate,
isReleaseBuild,
canUpdate,
loadVersion,
loadSystem,
checkUpdates,
updateNow,
+33 -9
View File
@@ -33,7 +33,7 @@ const uiStore = useUiStore()
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const preferredMotion = usePreferredReducedMotion()
const { version, hasUpdate, loadSystem } = useSystemUpdate()
const { version, hasUpdate, loadedOnce, loadVersion, loadSystem } = useSystemUpdate()
const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
@@ -52,6 +52,24 @@ function navigate(path: string) {
router.push(path)
}
const systemUpdateOpening = shallowRef(false)
async function openSystemUpdate() {
if (systemUpdateOpen.value || systemUpdateOpening.value) return
systemUpdateOpening.value = true
try {
if (!loadedOnce.value) {
await loadSystem(false)
}
} catch {
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
} finally {
systemUpdateOpening.value = false
systemUpdateOpen.value = true
}
}
async function handleLogout() {
await authStore.logout()
router.push('/login')
@@ -77,7 +95,9 @@ const systemUpdateOpen = shallowRef(false)
const themeToggleLabel = computed(() =>
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
)
const versionLabel = computed(() => `v${version.value?.version || '...'}`)
const versionText = computed(() => version.value?.version.trim() ?? '')
const hasVersionLabel = computed(() => versionText.value.length > 0)
const versionLabel = computed(() => `v${versionText.value}`)
const updateButtonLabel = computed(() =>
hasUpdate.value ? '发现新版本,打开系统更新' : '打开系统更新',
)
@@ -200,7 +220,7 @@ onMounted(() => {
})
}
animateSidebarLabels(Boolean(props.collapsed))
void loadSystem(false).catch(() => undefined)
void loadVersion().catch(() => undefined)
})
watch(
@@ -250,15 +270,18 @@ watch(
Rust build
</span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-[720] cursor-pointer transition-colors"
:class="
:class="[
hasUpdate
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)'
"
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)',
systemUpdateOpening ? 'cursor-wait opacity-70' : '',
]"
:title="updateButtonLabel"
@click="systemUpdateOpen = true"
:disabled="systemUpdateOpening"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
@@ -309,12 +332,13 @@ watch(
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
<BaseButton
v-if="collapsed && hasUpdate"
v-if="collapsed"
icon-only
:variant="hasUpdate ? 'success' : 'ghost'"
size="default"
:label="updateButtonLabel"
@click="systemUpdateOpen = true"
:loading="systemUpdateOpening"
@click="openSystemUpdate"
>
<ArrowUpCircle :size="19" />
</BaseButton>
@@ -12,6 +12,7 @@ import {
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.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'
@@ -124,9 +125,6 @@ const updateLogRows = computed(() =>
})),
)
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || updateSuccess.value || updateLogRows.value.length > 0,
)
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
const streamStatusLabel = computed(() => {
@@ -289,9 +287,21 @@ onUnmounted(() => {
</section>
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
{{ displayValue(updateInfo?.latestVersion) }}
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
<div class="release-notes" v-html="renderedReleaseNotes" />
</BaseScrollbar>
</section>
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
@@ -309,8 +319,13 @@ onUnmounted(() => {
</span>
</header>
<BaseScrollbar ref="updateLogScrollbar" height="260px" view-class="min-h-full px-4 pb-4">
<div v-if="updateLogRows.length" class="grid gap-2">
<BaseScrollbar
v-if="updateLogRows.length"
ref="updateLogScrollbar"
height="260px"
view-class="min-h-full px-4 pb-4"
>
<div class="grid gap-2">
<div
v-for="log in updateLogRows"
:key="log.id"
@@ -328,25 +343,10 @@ onUnmounted(() => {
</p>
</div>
</div>
<p v-else class="m-0 py-8 text-center text-[12px] font-[650] text-(--cp-text-muted)">
暂无进度
</p>
</BaseScrollbar>
</section>
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
{{ displayValue(updateInfo?.latestVersion) }}
</span>
<div v-else class="grid h-30 place-items-center px-4 pb-4">
<BaseEmpty title="暂无进度" :icon="Terminal" compact plain />
</div>
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
<div class="release-notes" v-html="renderedReleaseNotes" />
</BaseScrollbar>
</section>
</div>
+6 -11
View File
@@ -1,26 +1,21 @@
import DOMPurify from 'dompurify'
import { Marked, Renderer } from 'marked'
const renderer = new Renderer()
const defaultLinkRenderer = renderer.link.bind(renderer)
renderer.link = (token) => {
const html = defaultLinkRenderer(token)
return html.replace('<a ', '<a target="_blank" rel="noreferrer" ')
}
import { Marked } from 'marked'
const marked = new Marked({
async: false,
breaks: false,
gfm: true,
renderer,
})
export function renderMarkdown(source?: string | null) {
const value = source?.trim()
if (!value) return ''
return DOMPurify.sanitize(marked.parse(value, { async: false }), {
const html = marked
.parse(value, { async: false })
.replaceAll('<a ', '<a target="_blank" rel="noreferrer" ')
return DOMPurify.sanitize(html, {
ADD_ATTR: ['target', 'rel'],
})
}