mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat: support admin online update and deploy flow
This commit is contained in:
@@ -120,9 +120,16 @@
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@preview-release="openReleaseUpdateDialog"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
@@ -306,9 +313,16 @@
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@preview-release="openReleaseUpdateDialog"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@@ -391,9 +405,21 @@
|
||||
:release-url="updateInfo.release_url"
|
||||
:release-notes="updateInfo.release_notes"
|
||||
:published-at="updateInfo.published_at"
|
||||
:dialog-title="updateDialogTitle"
|
||||
:version-label="updateDialogVersionLabel"
|
||||
:release-link-label="updateDialogReleaseLinkLabel"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:updatable="updateInfo.updatable"
|
||||
:update-blocker="updateInfo.update_blocker"
|
||||
:reconnect-message="reconnectMessage"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -408,7 +434,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type UpdateTaskStatusResponse } from '@/api/admin'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -460,7 +486,7 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
type SystemUpdatePhase = 'download' | 'restart'
|
||||
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -490,11 +516,38 @@ const updateInfo = ref<CheckUpdateResponse | null>(null)
|
||||
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
||||
const loadingVersionStatus = ref(false)
|
||||
const applyingSystemUpdate = ref(false)
|
||||
const updateSupported = ref(true)
|
||||
const reconnectMessage = ref('等待服务恢复...')
|
||||
const rollbackAvailable = ref(false)
|
||||
const rollingBack = ref(false)
|
||||
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
|
||||
const updateDialogMode = ref<'latest' | 'selected'>('latest')
|
||||
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
||||
const preparedUpdateVersion = ref<string | null>(
|
||||
readSessionStorageItem('aether_prepared_update_version')
|
||||
)
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||
let 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)
|
||||
@@ -508,7 +561,9 @@ watch(preparedUpdateVersion, (val) => {
|
||||
})
|
||||
|
||||
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
|
||||
return readSessionStorageItem('aether_update_phase') === 'restart' ? 'restart' : 'download'
|
||||
const stored = readSessionStorageItem('aether_update_phase')
|
||||
if (stored === 'restart' || stored === 'reconnecting') return stored
|
||||
return 'download'
|
||||
}
|
||||
|
||||
function readSessionStorageItem(key: string): string | null {
|
||||
@@ -535,6 +590,80 @@ function removeSessionStorageItem(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
|
||||
if (!status) return '正在下载更新包...'
|
||||
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
|
||||
const downloaded = status.downloaded_bytes
|
||||
const total = status.total_bytes
|
||||
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
|
||||
return `${label} ${formatFileSize(downloaded)} / ${formatFileSize(total)}`
|
||||
}
|
||||
if (typeof downloaded === 'number' && downloaded > 0) {
|
||||
return `${label} ${formatFileSize(downloaded)}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
function formatUpdateTaskPhase(phase: string): string {
|
||||
switch (phase) {
|
||||
case 'downloading':
|
||||
return '正在下载更新包'
|
||||
case 'downloading_checksum':
|
||||
return '正在下载校验文件'
|
||||
case 'verifying':
|
||||
return '正在校验更新包'
|
||||
case 'extracting':
|
||||
return '正在解压更新包'
|
||||
case 'prepared':
|
||||
return '更新包已准备完成'
|
||||
default:
|
||||
return '正在准备更新'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
async function refreshUpdateTaskStatus() {
|
||||
try {
|
||||
updateTaskStatus.value = await adminApi.getUpdateStatus()
|
||||
} catch {
|
||||
// Keep the last progress snapshot while the request is in flight or the service restarts.
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
|
||||
const deadline = Date.now() + 10 * 60 * 1000
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await refreshUpdateTaskStatus()
|
||||
const status = updateTaskStatus.value
|
||||
if (status?.phase === 'prepared') return status
|
||||
if (status?.phase === 'failed') {
|
||||
throw new Error(status.error || '下载更新失败')
|
||||
}
|
||||
}
|
||||
throw new Error('下载更新超时')
|
||||
}
|
||||
|
||||
function startUpdateStatusPolling() {
|
||||
stopUpdateStatusPolling()
|
||||
void refreshUpdateTaskStatus()
|
||||
updateStatusPollTimer = window.setInterval(() => {
|
||||
void refreshUpdateTaskStatus()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopUpdateStatusPolling() {
|
||||
if (updateStatusPollTimer !== null) {
|
||||
window.clearInterval(updateStatusPollTimer)
|
||||
updateStatusPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化时自动关闭移动端菜单
|
||||
watch(() => route.path, () => {
|
||||
mobileMenuOpen.value = false
|
||||
@@ -558,14 +687,28 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadVersionStatus() {
|
||||
async function loadVersionStatus(force = false) {
|
||||
if (!isAdmin.value) return null
|
||||
if (versionStatusLoadPromise) return versionStatusLoadPromise
|
||||
|
||||
loadingVersionStatus.value = true
|
||||
versionStatusLoadPromise = (async () => {
|
||||
try {
|
||||
versionStatus.value = await adminApi.checkUpdate()
|
||||
const [status, capability] = await Promise.all([
|
||||
adminApi.checkUpdate(force),
|
||||
adminApi.getSystemUpdateCapability().catch(() => null),
|
||||
])
|
||||
if (capability) {
|
||||
rollbackAvailable.value = capability.rollback_available
|
||||
updateSupported.value = capability.supported
|
||||
}
|
||||
versionStatus.value = capability?.supported === false && status.has_update
|
||||
? {
|
||||
...status,
|
||||
updatable: false,
|
||||
update_blocker: SOURCE_BUILD_UPDATE_HINT,
|
||||
}
|
||||
: status
|
||||
syncSystemUpdatePhase(versionStatus.value)
|
||||
return versionStatus.value
|
||||
} catch (error) {
|
||||
@@ -581,23 +724,21 @@ async function loadVersionStatus() {
|
||||
}
|
||||
|
||||
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
||||
if (!status?.has_update) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
if (systemUpdatePhase.value === 'reconnecting') return
|
||||
if (systemUpdatePhase.value === 'restart') {
|
||||
if (!preparedUpdateVersion.value) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
systemUpdatePhase.value === 'restart' &&
|
||||
(!preparedUpdateVersion.value || preparedUpdateVersion.value !== status.latest_version)
|
||||
) {
|
||||
if (!status?.has_update) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionRefresh() {
|
||||
void loadVersionStatus()
|
||||
void loadVersionStatus(true)
|
||||
}
|
||||
|
||||
function openVersionReleasePage() {
|
||||
@@ -606,30 +747,88 @@ function openVersionReleasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse {
|
||||
const currentVersion =
|
||||
versionStatus.value?.current_version ||
|
||||
updateInfo.value?.current_version ||
|
||||
__APP_VERSION__ ||
|
||||
''
|
||||
const sourceBuild = !updateSupported.value
|
||||
return {
|
||||
current_version: currentVersion,
|
||||
latest_version: release.version,
|
||||
has_update: !release.is_current,
|
||||
updatable: !sourceBuild && !release.is_current && release.updatable,
|
||||
update_blocker: release.is_current
|
||||
? '当前已是这个版本'
|
||||
: sourceBuild
|
||||
? SOURCE_BUILD_RELEASE_HINT
|
||||
: release.update_blocker,
|
||||
release_url: release.release_url,
|
||||
release_notes: release.release_notes,
|
||||
published_at: release.published_at,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function openReleaseUpdateDialog(release: ReleaseEntry) {
|
||||
updateDialogMode.value = 'selected'
|
||||
updateInfo.value = buildUpdateInfoFromRelease(release)
|
||||
if (systemUpdatePhase.value !== 'reconnecting') {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
showUpdateDialog.value = true
|
||||
}
|
||||
|
||||
async function handleApplySystemUpdate() {
|
||||
if (applyingSystemUpdate.value) return
|
||||
applyingSystemUpdate.value = true
|
||||
try {
|
||||
const capability = await adminApi.getSystemUpdateCapability()
|
||||
if (!capability.enabled) {
|
||||
rollbackAvailable.value = capability.rollback_available
|
||||
if (!capability.supported) {
|
||||
updateSupported.value = false
|
||||
showError(
|
||||
capability.detail || `一键更新未启用,请先在部署环境配置 ${capability.command_env}`,
|
||||
'无法启动更新'
|
||||
SOURCE_BUILD_UPDATE_HINT,
|
||||
'不支持在线更新'
|
||||
)
|
||||
return
|
||||
}
|
||||
updateSupported.value = true
|
||||
|
||||
if (systemUpdatePhase.value === 'download') {
|
||||
const result = await adminApi.prepareSystemUpdate()
|
||||
preparedUpdateVersion.value = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
|
||||
systemUpdatePhase.value = 'restart'
|
||||
success(result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
||||
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()
|
||||
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
|
||||
success(result.message || '一键重启已启动')
|
||||
showUpdateDialog.value = false
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '服务正在重启...'
|
||||
showUpdateDialog.value = true
|
||||
applyingSystemUpdate.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
||||
showError(parseApiError(err, fallback))
|
||||
@@ -638,8 +837,82 @@ async function handleApplySystemUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback() {
|
||||
if (rollingBack.value) return
|
||||
rollingBack.value = true
|
||||
try {
|
||||
const result = await adminApi.rollbackSystemUpdate()
|
||||
success(result.message || '回滚已启动')
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '正在回滚到上一版本...'
|
||||
showUpdateDialog.value = true
|
||||
rollingBack.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '回滚失败'))
|
||||
} finally {
|
||||
rollingBack.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealthUntilReady() {
|
||||
const maxAttempts = 60
|
||||
const intervalMs = 2000
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (i < 3) {
|
||||
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
continue
|
||||
}
|
||||
|
||||
const elapsed = i * 2
|
||||
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
|
||||
try {
|
||||
const resp = await fetch('/_gateway/health', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (resp.ok) {
|
||||
reconnectMessage.value = '服务已恢复,正在刷新...'
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
window.location.replace(buildFreshReloadUrl())
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// expected while service is down
|
||||
}
|
||||
|
||||
// After 30 seconds, start checking if the task actually failed
|
||||
if (i > 15) {
|
||||
try {
|
||||
const status = await adminApi.getUpdateStatus()
|
||||
if (status.phase === 'failed' && status.error) {
|
||||
reconnectMessage.value = `更新失败: ${status.error}`
|
||||
systemUpdatePhase.value = 'download'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// service still down, continue polling
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
}
|
||||
|
||||
reconnectMessage.value = '等待超时,请手动刷新页面'
|
||||
systemUpdatePhase.value = 'download'
|
||||
}
|
||||
|
||||
function buildFreshReloadUrl(): string {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('__aether_reload', Date.now().toString())
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function showDebugUpdateDialog() {
|
||||
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',
|
||||
@@ -652,6 +925,8 @@ function showDebugUpdateDialog() {
|
||||
'- 统一版本号显示格式',
|
||||
].join('\n'),
|
||||
published_at: new Date().toISOString(),
|
||||
updatable: true,
|
||||
update_blocker: null,
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
@@ -675,6 +950,8 @@ function showDebugVersionStatus(hasUpdate = true) {
|
||||
].join('\n')
|
||||
: null,
|
||||
published_at: hasUpdate ? new Date().toISOString() : null,
|
||||
updatable: hasUpdate,
|
||||
update_blocker: null,
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
@@ -694,6 +971,7 @@ async function checkForUpdate() {
|
||||
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
|
||||
}
|
||||
@@ -786,6 +1064,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopUpdateStatusPolling()
|
||||
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
|
||||
delete window.__aetherShowUpdateDialog
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user