From 471ccd2f3ef0d8a9458862532987f596b13dfd19 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Jun 2026 22:52:47 +0800 Subject: [PATCH 1/5] fix: make project update flow atomic --- astrbot/core/updator.py | 70 ++++++-- astrbot/core/utils/io.py | 33 +++- astrbot/dashboard/api/app.py | 2 + astrbot/dashboard/services/update_service.py | 109 +++++++++--- tests/test_dashboard.py | 166 ++++++++++++++++++- tests/test_fastapi_v1_dashboard.py | 6 + 6 files changed, 347 insertions(+), 39 deletions(-) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index fab293418e..833c00d8f8 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -1,6 +1,7 @@ import os import sys import time +from pathlib import Path import psutil @@ -151,6 +152,41 @@ async def update( proxy="", progress_callback=None, ) -> None: + zip_path = await self.download_update_package( + latest=latest, + version=version, + proxy=proxy, + progress_callback=progress_callback, + ) + self.apply_update_package(zip_path) + + if reboot: + self._reboot() + + async def download_update_package( + self, + latest=True, + version=None, + proxy="", + path: str | Path = "temp.zip", + progress_callback=None, + ) -> Path: + """Download an AstrBot core update package without applying it. + + Args: + latest: Whether to download the latest release. + version: Specific release tag or commit hash to download. + proxy: Optional GitHub proxy prefix. + path: Destination zip path. + progress_callback: Optional callback for download progress payloads. + + Returns: + Path to the downloaded update package. + + Raises: + Exception: If update metadata cannot resolve a package URL. + """ + update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) file_url = None @@ -181,16 +217,26 @@ async def update( proxy = proxy.removesuffix("/") file_url = f"{proxy}/{file_url}" - try: - await self._download_file( - file_url, - "temp.zip", - progress_callback=progress_callback, - ) - logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...") - self.unzip_file("temp.zip", self.MAIN_PATH) - except BaseException as e: - raise e + zip_path = Path(path) + await self._download_file( + file_url, + str(zip_path), + progress_callback=progress_callback, + ) + return zip_path - if reboot: - self._reboot() + def apply_update_package(self, zip_path: str | Path) -> None: + """Apply a previously downloaded AstrBot core update package. + + Args: + zip_path: Core update zip archive path. + + Returns: + None. + + Raises: + Exception: If the archive cannot be extracted or applied. + """ + + logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...") + self.unzip_file(str(zip_path), self.MAIN_PATH) diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index dcdc03ff24..7ae5e3656f 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -426,12 +426,27 @@ async def download_dashboard( version: str | None = None, proxy: str | None = None, progress_callback=None, + extract: bool = True, ) -> None: - """下载管理面板文件""" + """Download dashboard assets and optionally extract them. + + Args: + path: Destination zip path. Defaults to the AstrBot data directory. + extract_path: Directory where assets should be extracted. + latest: Whether to download the latest dashboard build. + version: Specific release tag or commit hash to download. + proxy: Optional download proxy prefix. + progress_callback: Optional callback for download progress payloads. + extract: Whether to extract the archive after download. + + Returns: + None. + """ if path is None: zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip" else: zip_path = Path(path).absolute() + ensure_dir(zip_path.parent) if latest or len(str(version)) != 40: ver_name = "latest" if latest else version @@ -484,5 +499,21 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + if extract: + extract_dashboard(zip_path, extract_path) + + +def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") -> None: + """Extract a downloaded dashboard archive. + + Args: + zip_path: Dashboard zip archive path. + extract_path: Directory where the archive contents should be extracted. + + Returns: + None. + """ + + ensure_dir(extract_path) with zipfile.ZipFile(zip_path, "r") as z: z.extractall(extract_path) diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py index f0b35819a7..f0c25ae6fd 100644 --- a/astrbot/dashboard/api/app.py +++ b/astrbot/dashboard/api/app.py @@ -48,6 +48,7 @@ call_check_migration_needed_v4, call_do_migration_v4, call_download_dashboard, + call_extract_dashboard, call_get_dashboard_version, call_pip_install, ) @@ -139,6 +140,7 @@ def create_dashboard_asgi_app( core_lifecycle.astrbot_updator, core_lifecycle, download_dashboard_func=call_download_dashboard, + extract_dashboard_func=call_extract_dashboard, get_dashboard_version_func=call_get_dashboard_version, pip_install_func=call_pip_install, check_migration_needed_func=call_check_migration_needed_v4, diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py index cd203bec42..e6c2c08fbe 100644 --- a/astrbot/dashboard/services/update_service.py +++ b/astrbot/dashboard/services/update_service.py @@ -1,20 +1,17 @@ from __future__ import annotations +import inspect import traceback import uuid +import zipfile from collections.abc import Awaitable, Callable from dataclasses import dataclass +from pathlib import Path from typing import Any -from astrbot.core import ( - DEMO_MODE as _DEMO_MODE, -) -from astrbot.core import ( - logger, -) -from astrbot.core import ( - pip_installer as _pip_installer, -) +from astrbot.core import DEMO_MODE as _DEMO_MODE +from astrbot.core import logger +from astrbot.core import pip_installer as _pip_installer from astrbot.core.config.default import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db.migration.helper import ( @@ -24,9 +21,16 @@ do_migration_v4 as _do_migration_v4, ) from astrbot.core.updator import AstrBotUpdator +from astrbot.core.utils.astrbot_path import ( + get_astrbot_data_path, + get_astrbot_system_tmp_path, +) from astrbot.core.utils.io import ( download_dashboard as _download_dashboard, ) +from astrbot.core.utils.io import ( + extract_dashboard as _extract_dashboard, +) from astrbot.core.utils.io import ( get_dashboard_version as _get_dashboard_version, ) @@ -34,6 +38,7 @@ DEMO_MODE = _DEMO_MODE pip_installer = _pip_installer download_dashboard = _download_dashboard +extract_dashboard = _extract_dashboard get_dashboard_version = _get_dashboard_version default_check_migration_needed_v4 = _check_migration_needed_v4 default_do_migration_v4 = _do_migration_v4 @@ -43,6 +48,13 @@ async def call_download_dashboard(*args, **kwargs): return await download_dashboard(*args, **kwargs) +async def call_extract_dashboard(*args, **kwargs): + result = extract_dashboard(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result + + async def call_get_dashboard_version(*args, **kwargs): return await get_dashboard_version(*args, **kwargs) @@ -78,6 +90,7 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, *, download_dashboard_func: Callable[..., Awaitable[Any]], + extract_dashboard_func: Callable[..., Awaitable[Any]], get_dashboard_version_func: Callable[..., Awaitable[str | None]], pip_install_func: Callable[..., Awaitable[Any]], check_migration_needed_func: Callable[..., Awaitable[bool]], @@ -88,6 +101,7 @@ def __init__( self.astrbot_updator = astrbot_updator self.core_lifecycle = core_lifecycle self.download_dashboard = download_dashboard_func + self.extract_dashboard = extract_dashboard_func self.get_dashboard_version = get_dashboard_version_func self.pip_install = pip_install_func self.check_migration_needed = check_migration_needed_func @@ -177,6 +191,11 @@ async def update_project(self, data: object) -> UpdateServiceResult: proxy = proxy.removesuffix("/") self._init_update_progress(progress_id, version) + update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates" + update_temp_dir.mkdir(parents=True, exist_ok=True) + update_token = uuid.uuid4().hex + dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip" + core_zip_path = update_temp_dir / f"{update_token}-core.zip" try: self._set_update_stage( progress_id, @@ -186,6 +205,7 @@ async def update_project(self, data: object) -> UpdateServiceResult: 0, ) await self.download_dashboard( + path=str(dashboard_zip_path), latest=latest, version=version, proxy=proxy or "", @@ -195,6 +215,7 @@ async def update_project(self, data: object) -> UpdateServiceResult: 0, 45, ), + extract=False, ) self._set_update_stage( progress_id, @@ -211,16 +232,19 @@ async def update_project(self, data: object) -> UpdateServiceResult: "正在下载 AstrBot 项目代码...", 45, ) - await self.astrbot_updator.update( - latest=latest, - version=version, - proxy=proxy or "", - progress_callback=self._make_progress_callback( - progress_id, - "core", - 45, - 45, - ), + core_zip_path = Path( + await self.astrbot_updator.download_update_package( + latest=latest, + version=version, + proxy=proxy or "", + path=core_zip_path, + progress_callback=self._make_progress_callback( + progress_id, + "core", + 45, + 45, + ), + ) ) self._set_update_stage( progress_id, @@ -230,6 +254,46 @@ async def update_project(self, data: object) -> UpdateServiceResult: 90, ) + self._set_update_stage( + progress_id, + "verify", + "running", + "下载完成,正在校验更新包...", + 90, + ) + for zip_path in (dashboard_zip_path, core_zip_path): + with zipfile.ZipFile(zip_path, "r") as archive: + corrupt_member = archive.testzip() + if corrupt_member: + raise UpdateServiceError(f"更新包校验失败: {corrupt_member}") + self._set_update_stage( + progress_id, + "verify", + "done", + "更新包校验完成。", + 91, + ) + + self._set_update_stage( + progress_id, + "apply", + "running", + "下载完成,正在应用更新...", + 91, + ) + self.astrbot_updator.apply_update_package(core_zip_path) + await self.extract_dashboard( + dashboard_zip_path, + Path(get_astrbot_data_path()), + ) + self._set_update_stage( + progress_id, + "apply", + "done", + "更新文件应用完成。", + 92, + ) + self._set_update_stage( progress_id, "dependencies", @@ -284,6 +348,13 @@ async def update_project(self, data: object) -> UpdateServiceResult: ) logger.error(f"/api/update_project: {traceback.format_exc()}") raise UpdateServiceError(exc.__str__()) from exc + finally: + for zip_path in (dashboard_zip_path, core_zip_path): + try: + if zip_path.exists(): + zip_path.unlink() + except Exception as cleanup_exc: + logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}") async def update_dashboard(self) -> UpdateServiceResult: try: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index ef3222ccb2..a162e4de16 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2570,31 +2570,56 @@ async def test_do_update( release_path = temp_release_dir / "astrbot" calls = [] - async def mock_update(*args, **kwargs): - """Mocks the update process by creating a directory in the temp path.""" - calls.append("core") + async def mock_download_core(*args, **kwargs): + calls.append("download-core") callback = kwargs.get("progress_callback") if callback: callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1}) + zip_path = kwargs["path"] + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("AstrBot-main/README.md", "core") + return zip_path + + def mock_apply_core(*args, **kwargs): + del args, kwargs + calls.append("apply-core") os.makedirs(release_path, exist_ok=True) async def mock_download_dashboard(*args, **kwargs): - """Mocks the dashboard download to prevent network access.""" - calls.append("dashboard") + calls.append("download-dashboard") callback = kwargs.get("progress_callback") if callback: callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1}) + with zipfile.ZipFile(kwargs["path"], "w") as zf: + zf.writestr("dist/index.html", "dashboard") return + def mock_extract_dashboard(*args, **kwargs): + del args, kwargs + calls.append("apply-dashboard") + async def mock_pip_install(*args, **kwargs): """Mocks pip install to prevent actual installation.""" return - monkeypatch.setattr(core_lifecycle_td.astrbot_updator, "update", mock_update) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_core, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "apply_update_package", + mock_apply_core, + ) monkeypatch.setattr( "astrbot.dashboard.services.update_service.download_dashboard", mock_download_dashboard, ) + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.extract_dashboard", + mock_extract_dashboard, + ) monkeypatch.setattr( "astrbot.dashboard.services.update_service.pip_installer.install", mock_pip_install, @@ -2609,7 +2634,12 @@ async def mock_pip_install(*args, **kwargs): data = await response.get_json() assert data["status"] == "ok" assert os.path.exists(release_path) - assert calls[:2] == ["dashboard", "core"] + assert calls[:4] == [ + "download-dashboard", + "download-core", + "apply-core", + "apply-dashboard", + ] progress_response = await test_client.get( "/api/update/progress?id=test-progress", @@ -2621,6 +2651,128 @@ async def mock_pip_install(*args, **kwargs): assert progress_data["data"]["overall_percent"] == 100 +@pytest.mark.asyncio +async def test_do_update_does_not_apply_files_when_core_download_fails( + app: FastAPIAppAdapter, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, +): + test_client = app.test_client() + calls = [] + + async def mock_download_dashboard(*args, **kwargs): + calls.append("download-dashboard") + callback = kwargs.get("progress_callback") + if callback: + callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1}) + + async def mock_download_core(*args, **kwargs): + del args, kwargs + calls.append("download-core") + raise RuntimeError("core download failed") + + def mock_apply_core(*args, **kwargs): + del args, kwargs + calls.append("apply-core") + + def mock_extract_dashboard(*args, **kwargs): + del args, kwargs + calls.append("apply-dashboard") + + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_core, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "apply_update_package", + mock_apply_core, + ) + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.download_dashboard", + mock_download_dashboard, + ) + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.extract_dashboard", + mock_extract_dashboard, + ) + + response = await test_client.post( + "/api/update/do", + headers=authenticated_header, + json={"version": "v3.4.0", "reboot": False, "progress_id": "atomic-fail"}, + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "error" + assert calls == ["download-dashboard", "download-core"] + + +@pytest.mark.asyncio +async def test_do_update_does_not_apply_files_when_package_verification_fails( + app: FastAPIAppAdapter, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, +): + test_client = app.test_client() + calls = [] + + async def mock_download_dashboard(*args, **kwargs): + del args + calls.append("download-dashboard") + Path(kwargs["path"]).write_bytes(b"not a zip") + + async def mock_download_core(*args, **kwargs): + del args + calls.append("download-core") + zip_path = kwargs["path"] + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("AstrBot-main/README.md", "core") + return zip_path + + def mock_apply_core(*args, **kwargs): + del args, kwargs + calls.append("apply-core") + + def mock_extract_dashboard(*args, **kwargs): + del args, kwargs + calls.append("apply-dashboard") + + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_core, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "apply_update_package", + mock_apply_core, + ) + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.download_dashboard", + mock_download_dashboard, + ) + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.extract_dashboard", + mock_extract_dashboard, + ) + + response = await test_client.post( + "/api/update/do", + headers=authenticated_header, + json={"version": "v3.4.0", "reboot": False, "progress_id": "invalid-zip"}, + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "error" + assert calls == ["download-dashboard", "download-core"] + + @pytest.mark.asyncio async def test_do_update_hides_internal_error_message_in_response_and_progress( app: FastAPIAppAdapter, diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 97ecb351c3..937139757a 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -540,6 +540,12 @@ async def get_releases(self): async def update(self, *_args, **_kwargs) -> None: return None + async def download_update_package(self, *_args, **kwargs): + return kwargs.get("path", "temp.zip") + + def apply_update_package(self, *_args, **_kwargs) -> None: + return None + class FakeAstrBotConfig(dict): def save_config(self, post_config: dict) -> None: From 0d261f1203bb3ae0870f64b4c02348dfbbd7d438 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Jun 2026 23:02:19 +0800 Subject: [PATCH 2/5] fix: address atomic update review feedback --- astrbot/core/updator.py | 2 ++ astrbot/core/utils/io.py | 11 +++++++-- astrbot/dashboard/services/update_service.py | 26 ++++++++++++++------ tests/test_dashboard.py | 14 +++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 833c00d8f8..ecd8d839d9 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -8,6 +8,7 @@ from astrbot.core import logger from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_path +from astrbot.core.utils.io import ensure_dir from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -218,6 +219,7 @@ async def download_update_package( file_url = f"{proxy}/{file_url}" zip_path = Path(path) + ensure_dir(zip_path.parent) await self._download_file( file_url, str(zip_path), diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 7ae5e3656f..e0a0f544f0 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -514,6 +514,13 @@ def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") - None. """ - ensure_dir(extract_path) + extract_root = Path(extract_path).resolve() + ensure_dir(extract_root) with zipfile.ZipFile(zip_path, "r") as z: - z.extractall(extract_path) + for member in z.infolist(): + target_path = (extract_root / member.filename).resolve() + if not target_path.is_relative_to(extract_root): + raise ValueError( + f"Unsafe dashboard archive path: {member.filename}", + ) + z.extract(member, extract_root) diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py index e6c2c08fbe..f0cdaf7868 100644 --- a/astrbot/dashboard/services/update_service.py +++ b/astrbot/dashboard/services/update_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import inspect import traceback import uuid @@ -49,7 +50,9 @@ async def call_download_dashboard(*args, **kwargs): async def call_extract_dashboard(*args, **kwargs): - result = extract_dashboard(*args, **kwargs) + if inspect.iscoroutinefunction(extract_dashboard): + return await extract_dashboard(*args, **kwargs) + result = await asyncio.to_thread(extract_dashboard, *args, **kwargs) if inspect.isawaitable(result): return await result return result @@ -90,7 +93,7 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, *, download_dashboard_func: Callable[..., Awaitable[Any]], - extract_dashboard_func: Callable[..., Awaitable[Any]], + extract_dashboard_func: Callable[..., Any], get_dashboard_version_func: Callable[..., Awaitable[str | None]], pip_install_func: Callable[..., Awaitable[Any]], check_migration_needed_func: Callable[..., Awaitable[bool]], @@ -261,11 +264,15 @@ async def update_project(self, data: object) -> UpdateServiceResult: "下载完成,正在校验更新包...", 90, ) - for zip_path in (dashboard_zip_path, core_zip_path): - with zipfile.ZipFile(zip_path, "r") as archive: - corrupt_member = archive.testzip() - if corrupt_member: - raise UpdateServiceError(f"更新包校验失败: {corrupt_member}") + + def _verify_update_packages() -> None: + for zip_path in (dashboard_zip_path, core_zip_path): + with zipfile.ZipFile(zip_path, "r") as archive: + corrupt_member = archive.testzip() + if corrupt_member: + raise UpdateServiceError(f"更新包校验失败: {corrupt_member}") + + await asyncio.to_thread(_verify_update_packages) self._set_update_stage( progress_id, "verify", @@ -281,7 +288,10 @@ async def update_project(self, data: object) -> UpdateServiceResult: "下载完成,正在应用更新...", 91, ) - self.astrbot_updator.apply_update_package(core_zip_path) + await asyncio.to_thread( + self.astrbot_updator.apply_update_package, + core_zip_path, + ) await self.extract_dashboard( dashboard_zip_path, Path(get_astrbot_data_path()), diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index a162e4de16..637b680f79 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2773,6 +2773,20 @@ def mock_extract_dashboard(*args, **kwargs): assert calls == ["download-dashboard", "download-core"] +def test_extract_dashboard_rejects_zip_path_traversal(tmp_path: Path): + from astrbot.core.utils.io import extract_dashboard + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + with zipfile.ZipFile(archive_path, "w") as zf: + zf.writestr("../evil.txt", "unsafe") + + with pytest.raises(ValueError, match="Unsafe dashboard archive path"): + extract_dashboard(archive_path, extract_path) + + assert not (tmp_path / "evil.txt").exists() + + @pytest.mark.asyncio async def test_do_update_hides_internal_error_message_in_response_and_progress( app: FastAPIAppAdapter, From a05eb6ff15821e8ea3e3d36222a33ac499992f45 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Jun 2026 23:16:47 +0800 Subject: [PATCH 3/5] fix: show update success after restart --- .../src/i18n/locales/en-US/core/header.json | 3 + .../src/i18n/locales/ru-RU/core/header.json | 3 + .../src/i18n/locales/zh-CN/core/header.json | 3 + .../full/vertical-header/VerticalHeader.vue | 116 +++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index a9d7f11410..f25aaaf93e 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -60,6 +60,9 @@ "restart": "Preparing restart...", "restarting": "Restarting AstrBot", "completed": "Update complete", + "successReady": "Update successful", + "autoReloadIn": "Refreshing automatically in {seconds}s", + "reloadNow": "Refresh now", "failed": "Update failed" }, "redirectConfirm": { diff --git a/dashboard/src/i18n/locales/ru-RU/core/header.json b/dashboard/src/i18n/locales/ru-RU/core/header.json index 5e676272c6..28aebb4a39 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/header.json +++ b/dashboard/src/i18n/locales/ru-RU/core/header.json @@ -60,6 +60,9 @@ "restart": "Подготовка перезапуска...", "restarting": "Перезапуск AstrBot", "completed": "Обновление завершено", + "successReady": "Обновление успешно", + "autoReloadIn": "Автоматическое обновление через {seconds} с", + "reloadNow": "Обновить сейчас", "failed": "Ошибка обновления" }, "redirectConfirm": { diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json index 4241ee9bce..61ae3c2e2e 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/header.json +++ b/dashboard/src/i18n/locales/zh-CN/core/header.json @@ -60,6 +60,9 @@ "restart": "正在准备重启...", "restarting": "正在重启 AstrBot", "completed": "更新完成", + "successReady": "更新成功", + "autoReloadIn": "{seconds} 秒后自动刷新", + "reloadNow": "立即刷新", "failed": "更新失败" }, "redirectConfirm": { diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 27aa182fdb..5dce24fda1 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -56,6 +56,10 @@ let showAdvancedUpdateSettings = ref(false); let restartWaiting = ref(false); let restartStartTime = ref(null); let restartPollTimer: ReturnType | null = null; +let restartCompleted = ref(false); +let restartReloadCountdown = ref(3); +let restartReloadTimer: ReturnType | null = null; +const RESTART_FEEDBACK_DELAY_SECONDS = 3; type DownloadStageStatus = "pending" | "running" | "done" | "error"; type DownloadStage = { status: DownloadStageStatus; @@ -564,6 +568,21 @@ function stopRestartPolling() { } } +function stopRestartReloadTimer() { + if (restartReloadTimer) { + clearInterval(restartReloadTimer); + restartReloadTimer = null; + } +} + +function resetRestartFeedbackState() { + stopRestartReloadTimer(); + stopRestartPolling(); + restartCompleted.value = false; + restartReloadCountdown.value = RESTART_FEEDBACK_DELAY_SECONDS; + restartWaiting.value = false; +} + async function fetchAstrBotStartTime() { const res = await statsApi.startTime(); const rawStartTime = res.data?.data?.start_time; @@ -574,8 +593,37 @@ async function fetchAstrBotStartTime() { return startTime; } +function reloadAfterUpdate() { + stopRestartReloadTimer(); + window.location.reload(); +} + +function showRestartCompleted() { + if (restartCompleted.value) { + return; + } + stopRestartReloadTimer(); + restartWaiting.value = false; + restartCompleted.value = true; + restartReloadCountdown.value = RESTART_FEEDBACK_DELAY_SECONDS; + updateProgress.value = { + ...updateProgress.value, + status: "success", + stage: "done", + message: t("core.header.updateDialog.progress.successReady"), + overall_percent: 100, + }; + restartReloadTimer = setInterval(() => { + if (restartReloadCountdown.value <= 1) { + reloadAfterUpdate(); + return; + } + restartReloadCountdown.value -= 1; + }, 1000); +} + function waitForAstrBotRestart(initialStartTime: number | string | null) { - if (restartWaiting.value) { + if (restartWaiting.value || restartCompleted.value) { return; } stopRestartPolling(); @@ -598,8 +646,7 @@ function waitForAstrBotRestart(initialStartTime: number | string | null) { currentStartTime !== initialStartTime ) { stopRestartPolling(); - restartWaiting.value = false; - window.location.reload(); + showRestartCompleted(); } } catch (_error) { // Backend may be unavailable while the process is restarting. @@ -659,6 +706,7 @@ async function switchVersion(targetVersion: string) { version: targetVersion, message: t("core.header.updateDialog.progress.preparing"), } as UpdateProgress; + resetRestartFeedbackState(); updateStatus.value = t("core.header.updateDialog.status.switching"); installLoading.value = true; @@ -686,7 +734,7 @@ async function switchVersion(targetVersion: string) { overall_percent: res.data.status === "ok" ? 100 : updateProgress.value.overall_percent, }; - if (res.data.status == "ok") { + if (res.data.status === "ok") { waitForAstrBotRestart(initialStartTime); } }) @@ -766,6 +814,7 @@ commonStore.getStartTime(); onUnmounted(() => { stopUpdateProgressPolling(); stopRestartPolling(); + stopRestartReloadTimer(); }); // 视图模式切换 @@ -1227,8 +1276,48 @@ onMounted(async () => {
-
+
+
+ +
+
+ {{ t("core.header.updateDialog.progress.successReady") }} +
+
+ {{ + t("core.header.updateDialog.progress.autoReloadIn", { + seconds: restartReloadCountdown, + }) + }} +
+
+
+ +
+ + mdi-refresh + {{ t("core.header.updateDialog.progress.reloadNow") }} + +
+
+ +
{ gap: 16px; } +.update-progress-panel--success { + border-color: rgba(var(--v-theme-success), 0.42); + background: rgba(var(--v-theme-success), 0.08); +} + +.update-success-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.update-success-header { + display: flex; + align-items: center; + gap: 12px; +} + .advanced-settings-toggle { display: inline-flex; align-items: center; From 0687e7d01590f6426f8b30e665d0f70be6862aec Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Jun 2026 23:24:37 +0800 Subject: [PATCH 4/5] fix: prevent update progress reset during restart --- .../layouts/full/vertical-header/VerticalHeader.vue | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 5dce24fda1..7fa5fafcf1 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -659,6 +659,13 @@ function waitForAstrBotRestart(initialStartTime: number | string | null) { } function applyUpdateProgress(payload: UpdateProgress) { + if ( + payload.status === "idle" && + payload.id === updateProgress.value.id && + updateProgress.value.status !== "idle" + ) { + return; + } updateProgress.value = { ...createEmptyUpdateProgress(), ...payload, @@ -667,6 +674,11 @@ function applyUpdateProgress(payload: UpdateProgress) { ...(payload.stages || {}), }, }; + if (payload.stage === "restart") { + stopUpdateProgressPolling(); + waitForAstrBotRestart(restartStartTime.value); + return; + } if (payload.status === "success" || payload.status === "error") { stopUpdateProgressPolling(); } From 57081bd8d8dee1dd826262ab869e41296efc135b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Jun 2026 23:28:46 +0800 Subject: [PATCH 5/5] fix: align update success feedback styling --- .../full/vertical-header/VerticalHeader.vue | 142 +++++++++++------- 1 file changed, 91 insertions(+), 51 deletions(-) diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 7fa5fafcf1..15cee82102 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -1290,46 +1290,37 @@ onMounted(async () => { class="update-progress-panel mt-5" :class="{ 'update-progress-panel--success': restartCompleted }" > -
-
- -
-
- {{ t("core.header.updateDialog.progress.successReady") }} -
-
- {{ - t("core.header.updateDialog.progress.autoReloadIn", { - seconds: restartReloadCountdown, - }) - }} -
-
-
- + -
- - mdi-refresh - {{ t("core.header.updateDialog.progress.reloadNow") }} - + size="46" + > +
+ {{ t("core.header.updateDialog.progress.successReady") }}
+
+ {{ + t("core.header.updateDialog.progress.autoReloadIn", { + seconds: restartReloadCountdown, + }) + }} +
+ + mdi-refresh + {{ t("core.header.updateDialog.progress.reloadNow") }} +
-
+
{ padding: 16px; } +.update-progress-panel { + overflow: hidden; + position: relative; + transition: + border-color 0.9s ease, + box-shadow 0.9s ease; +} + +.update-progress-panel::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(var(--v-theme-success), 0.16), + rgba(var(--v-theme-success), 0.07) + ); + opacity: 0; + pointer-events: none; + transition: opacity 1.1s ease; +} + +.update-progress-panel > * { + position: relative; + z-index: 1; +} + .release-message-preview { max-height: 220px; overflow: hidden; @@ -2052,20 +2070,50 @@ onMounted(async () => { } .update-progress-panel--success { - border-color: rgba(var(--v-theme-success), 0.42); - background: rgba(var(--v-theme-success), 0.08); + border-color: rgba(var(--v-theme-success), 0.48); + box-shadow: inset 0 0 0 1px rgba(var(--v-theme-success), 0.08); } -.update-success-panel { - display: flex; - flex-direction: column; - gap: 14px; +.update-progress-panel--success::before { + animation: update-success-green-in 1.2s ease-out; + opacity: 1; } -.update-success-header { +.update-feedback-panel { display: flex; + flex-direction: column; align-items: center; + justify-content: center; gap: 12px; + min-height: 150px; + padding: 18px 0 22px; + text-align: center; +} + +.update-feedback-panel--success { + animation: update-success-content-in 0.45s ease-out both; +} + +@keyframes update-success-green-in { + from { + opacity: 0; + transform: scale(1.04); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes update-success-content-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } .advanced-settings-toggle { @@ -2087,14 +2135,6 @@ onMounted(async () => { color: rgb(var(--v-theme-primary)); } -.restart-waiting-panel { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 18px 0 22px; -} - .update-stage-list { display: flex; flex-direction: column;