diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go index b25bd87e..0cadff47 100644 --- a/backend/internal/service/update_service.go +++ b/backend/internal/service/update_service.go @@ -125,6 +125,7 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf } // PerformUpdate downloads and applies the update +// Uses atomic file replacement pattern for safe in-place updates func (s *UpdateService) PerformUpdate(ctx context.Context) error { info, err := s.CheckUpdate(ctx, true) if err != nil { @@ -173,8 +174,11 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error { return fmt.Errorf("failed to resolve symlinks: %w", err) } - // Create temp directory for extraction - tempDir, err := os.MkdirTemp("", "sub2api-update-*") + exeDir := filepath.Dir(exePath) + + // Create temp directory in the SAME directory as executable + // This ensures os.Rename is atomic (same filesystem) + tempDir, err := os.MkdirTemp(exeDir, ".sub2api-update-*") if err != nil { return fmt.Errorf("failed to create temp dir: %w", err) } @@ -199,23 +203,36 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error { return fmt.Errorf("extraction failed: %w", err) } - // Backup current binary - backupFile := exePath + ".backup" - if err := os.Rename(exePath, backupFile); err != nil { - return fmt.Errorf("backup failed: %w", err) - } - - // Replace with new binary - if err := copyFile(newBinaryPath, exePath); err != nil { - os.Rename(backupFile, exePath) - return fmt.Errorf("replace failed: %w", err) - } - - // Make executable - if err := os.Chmod(exePath, 0755); err != nil { + // Set executable permission before replacement + if err := os.Chmod(newBinaryPath, 0755); err != nil { return fmt.Errorf("chmod failed: %w", err) } + // Atomic replacement using rename pattern: + // 1. Rename current -> backup (atomic on Unix) + // 2. Rename new -> current (atomic on Unix, same filesystem) + // If step 2 fails, restore backup + backupPath := exePath + ".backup" + + // Remove old backup if exists + os.Remove(backupPath) + + // Step 1: Move current binary to backup + if err := os.Rename(exePath, backupPath); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + // Step 2: Move new binary to target location (atomic, same filesystem) + if err := os.Rename(newBinaryPath, exePath); err != nil { + // Restore backup on failure + if restoreErr := os.Rename(backupPath, exePath); restoreErr != nil { + return fmt.Errorf("replace failed and restore failed: %w (restore error: %v)", err, restoreErr) + } + return fmt.Errorf("replace failed (restored backup): %w", err) + } + + // Success - backup file is kept for rollback capability + // It will be cleaned up on next successful update return nil } @@ -515,23 +532,6 @@ func (s *UpdateService) extractBinary(archivePath, destPath string) error { return err } -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -} - func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) { data, err := s.rdb.Get(ctx, updateCacheKey).Result() if err != nil { diff --git a/frontend/src/api/admin/system.ts b/frontend/src/api/admin/system.ts index 6f13856b..cb3e39a5 100644 --- a/frontend/src/api/admin/system.ts +++ b/frontend/src/api/admin/system.ts @@ -40,9 +40,42 @@ export async function checkUpdates(force = false): Promise { return data; } +export interface UpdateResult { + message: string; + need_restart: boolean; +} + +/** + * Perform system update + * Downloads and applies the latest version + */ +export async function performUpdate(): Promise { + const { data } = await apiClient.post('/admin/system/update'); + return data; +} + +/** + * Rollback to previous version + */ +export async function rollback(): Promise { + const { data } = await apiClient.post('/admin/system/rollback'); + return data; +} + +/** + * Restart the service + */ +export async function restartService(): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>('/admin/system/restart'); + return data; +} + export const systemAPI = { getVersion, checkUpdates, + performUpdate, + rollback, + restartService, }; export default systemAPI; diff --git a/frontend/src/components/common/VersionBadge.vue b/frontend/src/components/common/VersionBadge.vue index 8b86728a..93c77e0d 100644 --- a/frontend/src/components/common/VersionBadge.vue +++ b/frontend/src/components/common/VersionBadge.vue @@ -69,8 +69,63 @@

- -
+ +
+
+
+ + + +
+
+

{{ t('version.updateFailed') }}

+

{{ updateError }}

+
+
+ + + +
+ + +
+
+
+ + + +
+
+

{{ t('version.updateComplete') }}

+

{{ t('version.restartRequired') }}

+
+
+ + + +
+ + + - - - + + (null); const buildType = ref('source'); // "source" or "release" +// Update process states +const updating = ref(false); +const restarting = ref(false); +const needRestart = ref(false); +const updateError = ref(''); +const updateSuccess = ref(false); + // Only show update check for release builds (binary/docker deployment) const isReleaseBuild = computed(() => buildType.value === 'release'); @@ -200,6 +286,10 @@ async function refreshVersion(force = true) { // Show update indicator for all build types hasUpdate.value = data.has_update; releaseInfo.value = data.release_info || null; + // Reset update states when refreshing + updateError.value = ''; + updateSuccess.value = false; + needRestart.value = false; } catch (error) { console.error('Failed to check updates:', error); } finally { @@ -207,6 +297,45 @@ async function refreshVersion(force = true) { } } +async function handleUpdate() { + if (updating.value) return; + + updating.value = true; + updateError.value = ''; + updateSuccess.value = false; + + try { + const result = await performUpdate(); + updateSuccess.value = true; + needRestart.value = result.need_restart; + hasUpdate.value = false; + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } }; message?: string }; + updateError.value = err.response?.data?.message || err.message || t('version.updateFailed'); + } finally { + updating.value = false; + } +} + +async function handleRestart() { + if (restarting.value) return; + + restarting.value = true; + + try { + await restartService(); + // Service will restart, page will reload automatically or show disconnected + } catch (error) { + // Expected - connection will be lost during restart + console.log('Service restarting...'); + } + + // Show restarting state for a while, then reload + setTimeout(() => { + window.location.reload(); + }, 3000); +} + function handleClickOutside(event: MouseEvent) { const target = event.target as Node; const button = (event.target as Element).closest('button'); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f22f8ae8..e6465db1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1023,9 +1023,18 @@ export default { noReleaseNotes: 'No release notes', viewUpdate: 'View Update', viewRelease: 'View Release', + viewChangelog: 'View Changelog', refresh: 'Refresh', sourceMode: 'Source Build', - sourceModeHint: 'Update detection is disabled for source builds. Use git pull to update.', + sourceModeHint: 'Source build, use git pull to update', + updateNow: 'Update Now', + updating: 'Updating...', + updateComplete: 'Update Complete', + updateFailed: 'Update Failed', + restartRequired: 'Please restart the service to apply the update', + restartNow: 'Restart Now', + restarting: 'Restarting...', + retry: 'Retry', }, // User Subscriptions Page diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e6b83ab4..322fc9f4 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1202,9 +1202,18 @@ export default { noReleaseNotes: '暂无更新日志', viewUpdate: '查看更新', viewRelease: '查看发布', + viewChangelog: '查看更新日志', refresh: '刷新', sourceMode: '源码构建', - sourceModeHint: '源码构建模式不支持更新检测,请使用 git pull 更新代码。', + sourceModeHint: '源码构建请使用 git pull 更新', + updateNow: '立即更新', + updating: '正在更新...', + updateComplete: '更新完成', + updateFailed: '更新失败', + restartRequired: '请重启服务以应用更新', + restartNow: '立即重启', + restarting: '正在重启...', + retry: '重试', }, // User Subscriptions Page