From c8f8ee180c315c9b911bf65241aa8b99199772ce Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Tue, 11 Aug 2026 09:23:23 +0800 Subject: [PATCH 01/11] feat: WordPress external MySQL install profile, databases module, and app store enhancements - Add install profile service for WordPress external MySQL deployments - Introduce databases management module in console - Extend app store page with channel/category browsing and richer cards - Enhance my-apps detail page with custom fields and style refinements - Update Dockerfile and runtime asset sync for install profiles - Expand app manager, settings, and common check services - Add i18n resources for databases and app store - Include install profile tests --- ...ordpress-external-mysql-install-profile.md | 75 ++++ apphub/requirements.txt | 3 +- apphub/src/api/v1/routers/app.py | 41 +- apphub/src/api/v1/routers/settings.py | 28 +- apphub/src/core/api_key_auth.py | 3 + .../src/external/nginx_proxy_manager_api.py | 2 +- apphub/src/schemas/appInstall.py | 21 +- apphub/src/services/app_manager.py | 116 +++++- apphub/src/services/common_check.py | 21 +- apphub/src/services/install_profile.py | 126 ++++++ apphub/src/services/settings_manager.py | 4 +- apphub/tests/test_install_profiles.py | 142 +++++++ .../test_settings_internal_product_edition.py | 58 ++- console/src/app/router/app-route-boundary.tsx | 57 ++- console/src/app/router/index.tsx | 13 +- console/src/app/shell/app-shell.tsx | 5 +- console/src/app/shell/shell-navigation.ts | 4 + .../src/features/app-store/app-store-model.ts | 5 + .../src/features/app-store/app-store-page.tsx | 320 ++++++++++++++- .../features/app-store/use-app-store-apps.ts | 4 +- .../src/features/databases/databases-page.css | 102 +++++ .../src/features/databases/databases-page.tsx | 369 ++++++++++++++++++ .../features/my-apps/my-app-detail-page.css | 90 ++++- .../features/my-apps/my-app-detail-page.tsx | 57 ++- console/src/main.tsx | 17 +- console/src/shared/i18n/resources.ts | 92 +++++ docker/Dockerfile | 2 +- .../scripts/platform-sync-runtime-assets.py | 41 +- scripts/generate_appstore_install_metadata.py | 39 +- 29 files changed, 1795 insertions(+), 62 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-wordpress-external-mysql-install-profile.md create mode 100644 apphub/src/services/install_profile.py create mode 100644 apphub/tests/test_install_profiles.py create mode 100644 console/src/features/databases/databases-page.css create mode 100644 console/src/features/databases/databases-page.tsx diff --git a/_bmad-output/implementation-artifacts/spec-wordpress-external-mysql-install-profile.md b/_bmad-output/implementation-artifacts/spec-wordpress-external-mysql-install-profile.md new file mode 100644 index 000000000..67ff37a9b --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-wordpress-external-mysql-install-profile.md @@ -0,0 +1,75 @@ +--- +title: 'WordPress 外部 MySQL 安装模式' +type: 'feature' +created: '2026-08-06' +status: 'in-progress' +baseline_commit: 'a1748d3a' +context: + - '{project-root}/docs/wordpress-external-mysql-pilot.md' +--- + + + +## Intent + +**Problem:** App Store 已能从 Docker Library 发现 WordPress 的 `external-mysql` profile,但 Console 无法选择该模式,AppHub 也无法安全物化模板或初始化外部数据库。 + +**Approach:** 保持默认安装请求、模板和下游 Gitea/Portainer 流程不变;为可选 profile 增加通用前端选择、受限后端解析和 WordPress 外部 MySQL 初始化。 + +## Boundaries & Constraints + +**Always:** 默认模式不发送 profile 字段且使用原始 `docker-compose.yml`/`.env`;profile 必须由本地完整模板对重新验证;最终仓库固定只有 `docker-compose.yml` 和 `.env`;管理员密码不得写入日志、状态、响应或最终 `.env`;运行账号使用现有 `W9_POWER_PASSWORD` 生成逻辑。 + +**Ask First:** 支持远程 Endpoint、其他数据库引擎、TLS、已安装应用迁移、外部资源自动清理、持久化管理员凭据。 + +**Never:** 为 WordPress 在 Console 硬编码字段或模式;信任客户端 profile 字段白名单;修改默认 WordPress 的安装与部署语义;删除外部数据库或账号。 + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|---------------|----------------------------|----------------| +| 默认安装 | 无 `profile` | 原请求与默认模板保持不变 | 沿用现有安装错误 | +| 外部 MySQL | 完整 profile 对及合规参数 | 物化外部模板,建库和低权限账号,部署 WordPress | 管理员密码不持久化 | +| 伪造 profile | profile 缺失、名称非法或字段超出 `.env.` | 在启动异步安装前拒绝 | 400,不回显秘密 | +| 数据库失败 | 无法连接、同名数据库、权限不足 | 不创建 Git/Stack,返回可操作的无秘密错误 | 不清理已创建外部资源 | + + + +## Code Map + +- `console/src/features/app-store/app-store-model.ts` -- App Store 前端模型。 +- `console/src/features/app-store/use-app-store-apps.ts` -- 合并静态安装 metadata。 +- `console/src/features/app-store/app-store-page.tsx` -- 通用安装表单和请求。 +- `apphub/src/schemas/appInstall.py` -- 安装 API 请求模型。 +- `apphub/src/services/install_profile.py` -- profile 白名单解析、模板物化和敏感字段处理。 +- AppHub 不连接外部 MySQL;用户负责预先创建目标数据库及可用账号。 +- `apphub/src/services/common_check.py` -- 同步请求校验。 +- `apphub/src/services/app_manager.py` -- 临时工作区物化与 provision 调用。 + +## Tasks & Acceptance + +**Execution:** +- [ ] `console/src/features/app-store/*` -- 读取 profiles、选择模式、按命名约定隐藏密码,并仅在选择 profile 时提交 profile 和 profile_settings。 +- [ ] `apphub/src/schemas/appInstall.py`、`apphub/src/services/common_check.py` -- 增加可选 profile 契约,并在异步任务前校验本地模板白名单和本地 Endpoint。 +- [ ] `apphub/src/services/install_profile.py` -- 以 profile 配对文件覆盖临时工作区的固定 compose/.env,且移除管理员凭据。 +- [x] 外部 MySQL 仅物化用户提供的已有数据库连接信息;不新增数据库驱动、不建库、不建用户或授权。 +- [ ] `apphub/src/services/app_manager.py` -- 仅在 profile 存在时执行物化和 provision,之后复用既有推送/部署流程。 +- [ ] `apphub/tests/` 与 Console 测试 -- 覆盖默认兼容、profile 白名单、模板归一化、秘密剔除和请求负载。 + +**Acceptance Criteria:** +- Given 默认 WordPress 安装,when 用户提交,then 请求、生成文件和部署行为与改动前一致。 +- Given `external-mysql`,when 用户填写合规参数,then 最终 `.env` 含运行连接信息与新生成的 `W9_POWER_PASSWORD`,但不含管理员账号或密码。 +- Given 未知 profile 或额外 profile 字段,when 请求安装,then API 在创建安装任务前以 400 拒绝。 +- Given 外部 MySQL 初始化失败,when 后端报告错误,then 错误、日志和状态中不含管理员密码。 + +## Design Notes + +`settings` 继续表示默认模板字段;`profile_settings` 只表示选择 profile 后的模板字段。profile 物化发生在复制整个应用目录之后、初始化 `.env` 之前,因此后续 Git、镜像拉取和 Portainer 无需识别额外文件名。 + +## Verification + +**Commands:** +- `cd apphub && pytest tests/test_install_profiles.py` -- profile 校验和物化通过。 +- `cd console && npm run build` -- TypeScript 构建通过。 +- `docker compose -f docker/docker-compose.dev.yml up -d --build` -- 运行容器包含改动。 +- `docker exec websoft9-dev ...` -- 真实 App Store 入口验证默认及外部模式。 \ No newline at end of file diff --git a/apphub/requirements.txt b/apphub/requirements.txt index 28c8ff1c1..de69ba977 100755 --- a/apphub/requirements.txt +++ b/apphub/requirements.txt @@ -13,4 +13,5 @@ docker tenacity aiodocker paramiko -python-multipart \ No newline at end of file +python-multipart +PyMySQL \ No newline at end of file diff --git a/apphub/src/api/v1/routers/app.py b/apphub/src/api/v1/routers/app.py index 1c8579ec9..cd6c4c652 100755 --- a/apphub/src/api/v1/routers/app.py +++ b/apphub/src/api/v1/routers/app.py @@ -12,7 +12,7 @@ from src.schemas.appCatalog import AppCatalogResponse from src.schemas.appComposeInstall import ComposeInstallAcceptedResponse, ComposeInstallRequest, ComposeValidationRequest, ComposeValidationResponse from src.schemas.appInstallAcceptedResponse import AppInstallAcceptedResponse -from src.schemas.appInstall import appInstall +from src.schemas.appInstall import ExternalMySQLConnectionTestRequest, appInstall from src.schemas.appPhpInfo import AppPhpInfoResponse from src.schemas.appPhpMigration import AppPhpMigrationRequest from src.schemas.appResponse import AppResponse @@ -127,6 +127,23 @@ async def event_generator(): }, ) +@router.get( + "/databases", + summary="List External Databases", + description="List external databases across installed apps.", + responses={ + 200: {"description": "Successful Response"}, + 400: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + }, +) +def get_external_databases( + endpointId: int = Query(None, description="Endpoint ID to get databases from. If not set, get databases from the local endpoint"), + locale: str = Query("en", description="Language used to resolve installed app media", regex="^(zh|en)(-[A-Za-z]{2})?$"), +): + return AppManger().get_external_databases(endpointId, locale) + + @router.get( "/apps/{app_id}", summary="Inspect App", @@ -346,6 +363,28 @@ async def apps_install( ) +@router.post( + "/apps/install/external-mysql/test-connection", + summary="Test External MySQL Connection", + responses={ + 200: {"model": dict}, + 400: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + }, +) +def test_external_mysql_install_connection(payload: ExternalMySQLConnectionTestRequest): + from src.services.install_profile import test_external_mysql_connection + + test_external_mysql_connection( + payload.host, + payload.port, + payload.database_name, + payload.username, + payload.password, + ) + return {"status": "success"} + + @router.post( "/apps/install/compose", summary="Install Custom Compose Application", diff --git a/apphub/src/api/v1/routers/settings.py b/apphub/src/api/v1/routers/settings.py index e6981a71f..31460021b 100755 --- a/apphub/src/api/v1/routers/settings.py +++ b/apphub/src/api/v1/routers/settings.py @@ -2,7 +2,7 @@ from typing import Optional import requests -from fastapi import APIRouter, Query, Path, Cookie +from fastapi import APIRouter, BackgroundTasks, Cookie, Path, Query, Request, Response from src.schemas.appSettings import AppSettings, PlatformGatewayBatchUpdateRequest, GenerateSelfSignedCertRequest, ApplyLetsEncryptCertRequest, UploadCertRequest from src.schemas.errorResponse import ErrorResponse from src.schemas.productRuntimeState import ProductEditionStateResponse @@ -102,15 +102,37 @@ def update_settings( 500: {"model": ErrorResponse}, } ) -def apply_platform_gateway_settings(payload: PlatformGatewayBatchUpdateRequest): - return SettingsManager().write_platform_gateway_settings( +def apply_platform_gateway_settings( + payload: PlatformGatewayBatchUpdateRequest, + request: Request, + response: Response, + background_tasks: BackgroundTasks, +): + manager = SettingsManager() + was_https_enabled = manager._is_platform_https_enabled() + will_enable_https = manager._parse_bool(payload.https_enabled) + request_is_https = (request.headers.get("x-forwarded-proto") or request.url.scheme) == "https" + + result = manager.write_platform_gateway_settings( bound_domain=payload.bound_domain, https_enabled=payload.https_enabled, force_https=payload.force_https, ssl_cert=payload.ssl_cert, ssl_key=payload.ssl_key, + restart_gateway=False, ) + if was_https_enabled and not will_enable_https and request_is_https: + response.delete_cookie( + key=PRODUCT_AUTH_COOKIE_NAME, + path="/", + samesite="lax", + secure=True, + ) + + background_tasks.add_task(manager._restart_platform_gateway) + return result + @router.post( "/settings/platform_gateway/generate-self-signed-cert", diff --git a/apphub/src/core/api_key_auth.py b/apphub/src/core/api_key_auth.py index b48f104ba..c5e9cb681 100644 --- a/apphub/src/core/api_key_auth.py +++ b/apphub/src/core/api_key_auth.py @@ -39,6 +39,9 @@ def should_skip_api_key_auth(path: str) -> bool: if normalized_path == "/apps" or normalized_path.startswith("/apps/"): return True + if normalized_path == "/databases" or normalized_path.startswith("/databases/"): + return True + if normalized_path == "/settings" or normalized_path.startswith("/settings/"): return True diff --git a/apphub/src/external/nginx_proxy_manager_api.py b/apphub/src/external/nginx_proxy_manager_api.py index c01e99b5a..4ee4ab376 100755 --- a/apphub/src/external/nginx_proxy_manager_api.py +++ b/apphub/src/external/nginx_proxy_manager_api.py @@ -138,7 +138,7 @@ def create_proxy_host( "advanced_config": advanced_config, "block_exploits": False, "caching_enabled": False, - "allow_websocket_upgrade": False, + "allow_websocket_upgrade": True, "http2_support": False, "hsts_enabled": False, "hsts_subdomains": False, diff --git a/apphub/src/schemas/appInstall.py b/apphub/src/schemas/appInstall.py index 616820fd2..3938c8bbf 100755 --- a/apphub/src/schemas/appInstall.py +++ b/apphub/src/schemas/appInstall.py @@ -34,6 +34,7 @@ class appInstall(BaseModel): If proxy_enabled is false, provide the host machine's IP address.(e.g., ["192.168.1.1"])""", example=["wordpress.example1.com", "wordpress.example2.com"]) settings: Optional[dict] = Field(None, description="The settings for the app", example={"W9_HTTP_PORT_SET": "9001"}) + profile: Optional[str] = Field(None, description="Optional locally published installation profile") @validator('app_name') def validate_app_name(cls, v): @@ -47,7 +48,15 @@ def validate_app_id(cls, v): if not pattern.match(v): raise CustomException(400,"Invalid Request","The app_id must be a combination of 2 to 20 lowercase letters and numbers, and cannot start with a number.") return v - + + @validator('profile') + def validate_profile(cls, v): + if v is None: + return v + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", v): + raise CustomException(400, "Invalid Request", "Invalid installation profile.") + return v + @validator('domain_names', each_item=True) def validate_domain_name(cls, v): if not v.strip(): @@ -65,4 +74,12 @@ def validate_domain_names(cls, v,values): if v and len(set(v)) != len(v): raise CustomException(400,"Invalid Request","Duplicate entries found in 'domain_names'. All domains must be unique.") - return v \ No newline at end of file + return v + + +class ExternalMySQLConnectionTestRequest(BaseModel): + host: str = Field(..., min_length=1) + port: int = Field(..., ge=1, le=65535) + database_name: str = Field(..., min_length=1) + username: str = Field(..., min_length=1) + password: str = Field(..., min_length=1) \ No newline at end of file diff --git a/apphub/src/services/app_manager.py b/apphub/src/services/app_manager.py index 2dff29229..09addc78c 100644 --- a/apphub/src/services/app_manager.py +++ b/apphub/src/services/app_manager.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 from typing import Any, Dict, List import json @@ -33,6 +35,7 @@ from src.core.logger import logger from src.services.integration_credentials import IntegrationCredentialProvider from src.services.proxy_manager import ProxyManager +from src.services.install_profile import get_port_check_settings, materialize_profile_template from src.utils.async_utils import AsyncWrapper from src.utils.file_manager import FileHelper from src.utils.password_generator import PasswordGenerator @@ -141,6 +144,38 @@ def _read_app_name_from_gitea_env(self, app_id: str) -> str | None: return None return None + def _read_safe_app_env_from_gitea(self, app_id: str) -> dict[str, str]: + allowed_keys = { + "W9_APP_NAME", + "W9_DATABASE_MODE", + "W9_DB_EXPOSE", + "W9_DB_HOST_SET", + "W9_DB_PORT_SET", + "W9_DB_NAME_SET", + "W9_DB_USER_SET", + "W9_DB_PASSWORD_SET", + "WORDPRESS_DB_HOST", + "WORDPRESS_DB_NAME", + "WORDPRESS_DB_USER", + "WORDPRESS_DB_PASSWORD", + } + try: + env_content = GiteaManager().get_file_raw_from_repo(app_id, ".env") + if not env_content: + return {} + environment: dict[str, str] = {} + for line in env_content.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if key in allowed_keys: + environment[key] = value.strip().strip("'\"") + return environment + except Exception: + return {} + def _enrich_proxy_hosts(self, proxy_hosts: list[dict] | None, w9_url_replace: str | bool = False, w9_url: str | None = None) -> list[dict]: enriched_hosts: list[dict] = [] for proxy_host in proxy_hosts or []: @@ -753,9 +788,12 @@ def create_installation_tracking(self, app_install: appInstall) -> Tuple[str, st # install requests see them before Docker containers are actually started. reserved_ports: set = set() try: - library_path = ConfigManager("system.ini").get_value("docker_library", "path") - env_path = os.path.join(library_path, app_install.app_name, ".env") - if os.path.exists(env_path): + if app_install.profile != "external-mysql": + library_path = ConfigManager("system.ini").get_value("docker_library", "path") + env_path = os.path.join(library_path, app_install.app_name, ".env") + else: + env_path = None + if env_path and os.path.exists(env_path): with open(env_path) as _f: for _line in _f: _line = _line.strip() @@ -769,8 +807,9 @@ def create_installation_tracking(self, app_install: appInstall) -> Tuple[str, st pass except Exception as _e: logger.warning(f"Port reservation: could not read template .env: {_e}") - if app_install.settings: - for _key, _val in app_install.settings.items(): + port_check_settings = get_port_check_settings(app_install.profile, app_install.settings) + if port_check_settings: + for _key, _val in port_check_settings.items(): if 'PORT_SET' in _key: try: reserved_ports.add(int(_val)) @@ -1117,6 +1156,7 @@ def get_app_by_id(self,app_id:str,endpointId:int = None, locale: str = "en"): return appResponse else: app_name = None + inactive_env = self._read_safe_app_env_from_gitea(app_id) compose_metadata = self._read_compose_metadata_safe(app_id) inactive_app_dist = str(compose_metadata.get("dist") or "").strip() metadata_name = compose_metadata.get("app_name") @@ -1125,7 +1165,7 @@ def get_app_by_id(self,app_id:str,endpointId:int = None, locale: str = "en"): # Fallback: read W9_APP_NAME from Gitea .env when metadata is missing # (app-store apps may not have compose-metadata.json) if not app_name: - app_name = self._read_app_name_from_gitea_env(app_id) + app_name = inactive_env.get("W9_APP_NAME") or self._read_app_name_from_gitea_env(app_id) metadata_version = compose_metadata.get("version") inactive_app_version = str(metadata_version or "").strip() is_php_app, is_monitor_app = self._get_capability_flags(app_name) @@ -1148,7 +1188,7 @@ def get_app_by_id(self,app_id:str,endpointId:int = None, locale: str = "en"): gitConfig = gitConfig, containers = app_containers, volumes = app_volumes, - env = {}, + env = inactive_env, error = display_error, ) return appResponse @@ -1157,7 +1197,60 @@ def get_app_by_id(self,app_id:str,endpointId:int = None, locale: str = "en"): except Exception as e: logger.error(f"Get app by app_id:{app_id} error:{e}") raise CustomException() - + + def get_external_databases(self, endpointId: int | None = None, locale: str = "en") -> list[dict[str, str]]: + """ + Collect external database information from all installed apps. + + Returns a list of external database records across apps where + W9_DATABASE_MODE is 'external'. + """ + try: + apps = self.get_apps(endpointId, locale) + except Exception as exc: + logger.warning(f"Failed to list apps for external databases: {exc}") + return [] + + # Group by database identity to merge apps sharing the same external database + groups: dict[tuple[str, str, str, str, str], list[dict]] = {} + + for app in apps: + env = app.env or {} + # Fallback for Inactive apps: read env from Gitea .env + if not env.get("W9_DATABASE_MODE") and app.gitConfig: + env = self._read_safe_app_env_from_gitea(app.app_id) + if env.get("W9_DATABASE_MODE") != "external": + continue + + db_expose = (env.get("W9_DB_EXPOSE") or "").strip() + db_type = db_expose.split(",")[0].strip() if db_expose else "mysql" + db_host = env.get("W9_DB_HOST_SET") or env.get("WORDPRESS_DB_HOST") or "" + db_port = env.get("W9_DB_PORT_SET") or "" + db_name = env.get("W9_DB_NAME_SET") or env.get("WORDPRESS_DB_NAME") or "" + db_user = env.get("W9_DB_USER_SET") or env.get("WORDPRESS_DB_USER") or "" + db_password = env.get("W9_DB_PASSWORD_SET") or env.get("WORDPRESS_DB_PASSWORD") or "" + + address = f"{db_host}:{db_port}" if db_host and db_port else db_host or "-" + group_key = (db_type, address, db_name, db_user, db_password) + groups.setdefault(group_key, []).append({ + "app_id": app.app_id, + "app_name": app.app_name or app.app_id, + "status": app.status, + }) + + databases: list[dict] = [] + for (db_type, address, db_name, db_user, db_password), app_refs in groups.items(): + databases.append({ + "type": db_type, + "address": address, + "database_name": db_name or "-", + "username": db_user or "-", + "password": db_password or "-", + "apps": app_refs, + }) + + return databases + def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app_id: str = None, tracking_id: str = None): """ Install app @@ -1177,6 +1270,7 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app proxy_enabled = appInstall.proxy_enabled domain_names = appInstall.domain_names settings = appInstall.settings + profile = appInstall.profile # Check the endpointId is exists. if endpointId is None: @@ -1231,6 +1325,8 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app # Copy the entire directory. shutil.copytree(local_path, app_tmp_dir_path) + materialize_profile_template(app_tmp_dir_path, profile) + # Modify the env file env_file_path = f"{app_tmp_dir_path}/.env" envHelper = EnvHelper(env_file_path) @@ -1287,14 +1383,14 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app # Rollback: remove repo in gitea giteaManager.remove_repo(app_id) # modify app status: error - modify_app_information(app_uuid,e.details) + modify_app_information(app_uuid, e.details) remove_installation_logs(app_uuid) raise except Exception as e: # Rollback: remove repo in gitea giteaManager.remove_repo(app_id) # modify app status: error - modify_app_information(app_uuid,"Initialize repo error") + modify_app_information(app_uuid, "Initialize repo error") remove_installation_logs(app_uuid) logger.error(f"Initialize repo error:{e}") raise CustomException() diff --git a/apphub/src/services/common_check.py b/apphub/src/services/common_check.py index 3459866c9..b4bccd6c4 100755 --- a/apphub/src/services/common_check.py +++ b/apphub/src/services/common_check.py @@ -10,6 +10,7 @@ from src.services.proxy_manager import ProxyManager from src.services.app_status import appInstalling,appInstallingError from src.services.product_metadata import read_product_edition +from src.services.install_profile import get_port_check_settings, test_external_mysql_connection, validate_profile_settings def _get_host_bound_ports() -> set: @@ -238,6 +239,7 @@ def check_endpointId(endpointId:int, portainerManager): details="EndpointId Not Found" ) + def check_apps_number(endpointId:int): """ Check the apps number is exceed the maximum number of apps @@ -289,6 +291,21 @@ def install_validate(appInstall:appInstall,endpointId:int): # Check the app_name and app_version is exists in docker library check_appName_and_appVersion(app_name, app_version) + library_path = ConfigManager("system.ini").get_value("docker_library", "path") + validate_profile_settings( + os.path.join(library_path, app_name), + appInstall.profile, + appInstall.settings, + ) + if appInstall.profile == "external-mysql": + settings = appInstall.settings or {} + test_external_mysql_connection( + settings["W9_DB_HOST_SET"], + settings["W9_DB_PORT_SET"], + settings["W9_DB_NAME_SET"], + settings["W9_DB_USER_SET"], + settings["W9_DB_PASSWORD_SET"], + ) # Check the app_id is exists in gitea and portainer check_appId(app_id, endpointId, giteaManager, portainerManager) @@ -302,8 +319,8 @@ def install_validate(appInstall:appInstall,endpointId:int): # Check the apps number is exceed the maximum number of apps check_apps_number(endpointId) - # Check port conflicts for any W9_*PORT_SET settings (includes template defaults) - check_port_conflicts(appInstall.settings, app_name) + port_check_settings = get_port_check_settings(appInstall.profile, appInstall.settings) + check_port_conflicts(port_check_settings, None if appInstall.profile == "external-mysql" else app_name) except CustomException as e: raise e except Exception as e: diff --git a/apphub/src/services/install_profile.py b/apphub/src/services/install_profile.py new file mode 100644 index 000000000..68c8da941 --- /dev/null +++ b/apphub/src/services/install_profile.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +from src.core.exception import CustomException + + +_PROFILE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") +_PROFILE_COMPOSE_PATTERN = re.compile(r"^docker-compose\.([a-z0-9][a-z0-9-]*)\.yml$") +EXTERNAL_MYSQL_CONNECTION_SETTING_KEYS = frozenset({ + "W9_DB_HOST_SET", + "W9_DB_PORT_SET", + "W9_DB_NAME_SET", + "W9_DB_USER_SET", + "W9_DB_PASSWORD_SET", +}) + + +def get_port_check_settings(profile: str | None, settings: dict | None) -> dict: + if profile != "external-mysql": + return settings or {} + return { + key: value + for key, value in (settings or {}).items() + if key not in EXTERNAL_MYSQL_CONNECTION_SETTING_KEYS + } + + +def _load_template_settings(env_path: Path) -> set[str]: + settings: set[str] = set() + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _ = line.split("=", 1) + key = key.strip() + if key.startswith("W9_") and key.endswith("_SET"): + settings.add(key) + return settings + + +def get_profile_template(app_directory: str | Path, profile: str) -> tuple[Path, Path]: + if not isinstance(profile, str) or not _PROFILE_NAME_PATTERN.fullmatch(profile): + raise CustomException(400, "Invalid Request", "Invalid installation profile.") + + app_path = Path(app_directory) + compose_path = app_path / f"docker-compose.{profile}.yml" + env_path = app_path / f".env.{profile}" + if not compose_path.is_file() or not env_path.is_file(): + raise CustomException(400, "Invalid Request", "The selected installation profile is not available locally.") + return compose_path, env_path + + +def validate_profile_settings(app_directory: str | Path, profile: str | None, settings: dict | None) -> None: + if profile is None: + return + + _, env_path = get_profile_template(app_directory, profile) + expected_keys = _load_template_settings(env_path) + supplied_settings = settings or {} + if set(supplied_settings) != expected_keys: + raise CustomException(400, "Invalid Request", "Profile settings do not match the selected installation profile.") + if any(not isinstance(value, str) for value in supplied_settings.values()): + raise CustomException(400, "Invalid Request", "Profile settings must be strings.") + + +def test_external_mysql_connection(host: str, port: int | str, database_name: str, username: str, password: str) -> None: + if not all(isinstance(value, str) and value.strip() for value in (host, database_name, username, password)): + raise CustomException(400, "Invalid Request", "External MySQL connection information is required.") + if "://" in host or any(character.isspace() for character in host): + raise CustomException(400, "Invalid Request", "External MySQL host is invalid.") + + try: + normalized_port = int(port) + except (TypeError, ValueError) as exc: + raise CustomException(400, "Invalid Request", "External MySQL port is invalid.") from exc + if normalized_port < 1 or normalized_port > 65535: + raise CustomException(400, "Invalid Request", "External MySQL port is invalid.") + + try: + import pymysql + except ImportError as exc: + raise CustomException(503, "External MySQL Connection Unavailable", "The MySQL connection test is not available.") from exc + + try: + connection = pymysql.connect( + host=host, + port=normalized_port, + user=username, + password=password, + database=database_name, + connect_timeout=8, + read_timeout=8, + write_timeout=8, + autocommit=True, + ) + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + finally: + connection.close() + except pymysql.MySQLError as exc: + raise CustomException(400, "External MySQL Connection Failed", "Unable to connect to the specified MySQL database.") from exc + + +def materialize_profile_template(workspace_directory: str | Path, profile: str | None) -> None: + if profile is None: + return + + workspace_path = Path(workspace_directory) + compose_path, env_path = get_profile_template(workspace_path, profile) + shutil.copyfile(compose_path, workspace_path / "docker-compose.yml") + shutil.copyfile(env_path, workspace_path / ".env") + + profile_names = [] + for candidate in workspace_path.iterdir(): + match = _PROFILE_COMPOSE_PATTERN.fullmatch(candidate.name) + if match and candidate.is_file(): + profile_names.append(match.group(1)) + candidate.unlink() + for profile_name in profile_names: + candidate = workspace_path / f".env.{profile_name}" + if candidate.is_file(): + candidate.unlink() \ No newline at end of file diff --git a/apphub/src/services/settings_manager.py b/apphub/src/services/settings_manager.py index 828aa4f5b..5de0290ea 100755 --- a/apphub/src/services/settings_manager.py +++ b/apphub/src/services/settings_manager.py @@ -474,6 +474,7 @@ def write_platform_gateway_settings( force_https: str, ssl_cert: str = "", ssl_key: str = "", + restart_gateway: bool = True, ) -> Dict[str, str]: self.config.read(self.config_file_path) @@ -520,7 +521,8 @@ def write_platform_gateway_settings( with open(self.config_file_path, "w") as configfile: self.config.write(configfile) - self._restart_platform_gateway() + if restart_gateway: + self._restart_platform_gateway() return self.read_section("platform_gateway") def _is_default_platform_certificate(self) -> bool: diff --git a/apphub/tests/test_install_profiles.py b/apphub/tests/test_install_profiles.py new file mode 100644 index 000000000..f4b74e2dd --- /dev/null +++ b/apphub/tests/test_install_profiles.py @@ -0,0 +1,142 @@ +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.core.exception import CustomException +from src.services.install_profile import ( + get_port_check_settings, + materialize_profile_template, + test_external_mysql_connection as check_external_mysql_connection, + validate_profile_settings, +) + + +PROFILE_SETTINGS = { + "W9_HTTP_PORT_SET": "9001", + "W9_DB_HOST_SET": "mysql.example.internal", + "W9_DB_PORT_SET": "3306", + "W9_DB_NAME_SET": "wordpress_demo", + "W9_DB_USER_SET": "wordpress_user", + "W9_DB_PASSWORD_SET": "database-secret", +} + + +def _profile_template(app_dir: Path) -> None: + app_dir.mkdir() + (app_dir / "docker-compose.yml").write_text("services:\n mysql: {}\n", encoding="utf-8") + (app_dir / ".env").write_text("W9_POWER_PASSWORD=\n", encoding="utf-8") + (app_dir / "docker-compose.external-mysql.yml").write_text( + "services:\n wordpress:\n ports:\n - $W9_HTTP_PORT_SET:80\n", + encoding="utf-8", + ) + (app_dir / ".env.external-mysql").write_text( + "\n".join(f"{key}={value if key != 'W9_DB_PASSWORD_SET' else ''}" for key, value in PROFILE_SETTINGS.items()), + encoding="utf-8", + ) + + +def test_profile_validation_uses_the_local_template_whitelist(tmp_path): + _profile_template(tmp_path / "wordpress") + + validate_profile_settings(tmp_path / "wordpress", "external-mysql", PROFILE_SETTINGS) + + with pytest.raises(CustomException) as exc_info: + validate_profile_settings(tmp_path / "wordpress", "external-mysql", {**PROFILE_SETTINGS, "UNAPPROVED": "value"}) + + assert exc_info.value.status_code == 400 + assert "database-secret" not in exc_info.value.details + + +def test_profile_validation_rejects_settings_from_a_different_template(tmp_path): + _profile_template(tmp_path / "wordpress") + + with pytest.raises(CustomException) as exc_info: + validate_profile_settings( + tmp_path / "wordpress", + "external-mysql", + {"W9_HTTP_PORT_SET": "9001"}, + ) + + assert exc_info.value.status_code == 400 + + +def test_external_mysql_connection_settings_are_excluded_from_port_checks(): + assert get_port_check_settings("external-mysql", PROFILE_SETTINGS) == {"W9_HTTP_PORT_SET": "9001"} + assert get_port_check_settings(None, PROFILE_SETTINGS) == PROFILE_SETTINGS + + +def test_external_mysql_connection_test_is_read_only(monkeypatch): + calls = [] + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return None + + def execute(self, statement): + calls.append(("execute", statement)) + + class FakeConnection: + def cursor(self): + return FakeCursor() + + def close(self): + calls.append(("close",)) + + class FakePyMySQL: + MySQLError = Exception + + @staticmethod + def connect(**kwargs): + calls.append(("connect", kwargs)) + return FakeConnection() + + monkeypatch.setitem(sys.modules, "pymysql", FakePyMySQL) + + check_external_mysql_connection( + host="mysql.example.internal", + port=3306, + database_name="wordpress_demo", + username="wordpress_user", + password="database-secret", + ) + + assert calls == [ + ("connect", { + "host": "mysql.example.internal", + "port": 3306, + "user": "wordpress_user", + "password": "database-secret", + "database": "wordpress_demo", + "connect_timeout": 8, + "read_timeout": 8, + "write_timeout": 8, + "autocommit": True, + }), + ("execute", "SELECT 1"), + ("close",), + ] + + +def test_profile_materialization_preserves_the_selected_template(tmp_path): + workspace = tmp_path / "wordpress" + _profile_template(workspace) + (workspace / ".env.example").write_text("DOCUMENTATION_ONLY=true\n", encoding="utf-8") + + materialize_profile_template(workspace, "external-mysql") + + assert "wordpress" in (workspace / "docker-compose.yml").read_text(encoding="utf-8") + assert not list(workspace.glob("docker-compose.*.yml")) + assert not (workspace / ".env.external-mysql").exists() + assert (workspace / ".env.example").exists() + env_content = (workspace / ".env").read_text(encoding="utf-8") + assert "W9_DB_USER_SET=wordpress_user" in env_content + assert "W9_DB_PASSWORD_SET=" in env_content + assert "W9_POWER_PASSWORD" not in env_content \ No newline at end of file diff --git a/apphub/tests/test_settings_internal_product_edition.py b/apphub/tests/test_settings_internal_product_edition.py index 9c7994a31..ed6e87ebc 100644 --- a/apphub/tests/test_settings_internal_product_edition.py +++ b/apphub/tests/test_settings_internal_product_edition.py @@ -162,4 +162,60 @@ def test_upgrade_status_keeps_stable_release_recommendation(monkeypatch): status = settings_router.get_upgrade_status() - assert status["upgrade_available"] is True \ No newline at end of file + assert status["upgrade_available"] is True + + +def test_disabling_https_clears_secure_product_session_before_gateway_restart(monkeypatch): + app = create_test_app() + client = TestClient(app) + manager = _GatewaySettingsManager(https_enabled=True) + monkeypatch.setattr(settings_router, "SettingsManager", lambda: manager) + + response = client.put( + "/settings/platform_gateway/apply", + headers={"X-Forwarded-Proto": "https"}, + json={"bound_domain": "", "https_enabled": "false", "force_https": "false", "ssl_cert": "", "ssl_key": ""}, + ) + + assert response.status_code == 200 + assert "Max-Age=0" in response.headers["set-cookie"] + assert "Secure" in response.headers["set-cookie"] + assert manager.restart_calls == 1 + assert manager.write_calls == [{"restart_gateway": False}] + + +def test_other_gateway_settings_changes_do_not_clear_product_session(monkeypatch): + app = create_test_app() + client = TestClient(app) + manager = _GatewaySettingsManager(https_enabled=True) + monkeypatch.setattr(settings_router, "SettingsManager", lambda: manager) + + response = client.put( + "/settings/platform_gateway/apply", + headers={"X-Forwarded-Proto": "https"}, + json={"bound_domain": "", "https_enabled": "true", "force_https": "false", "ssl_cert": "", "ssl_key": ""}, + ) + + assert response.status_code == 200 + assert "set-cookie" not in response.headers + assert manager.restart_calls == 1 + + +class _GatewaySettingsManager: + def __init__(self, *, https_enabled: bool): + self.https_enabled = https_enabled + self.restart_calls = 0 + self.write_calls = [] + + def _is_platform_https_enabled(self): + return self.https_enabled + + def _parse_bool(self, value: str): + return value == "true" + + def write_platform_gateway_settings(self, **kwargs): + self.write_calls.append({"restart_gateway": kwargs["restart_gateway"]}) + return {"platform_gateway": "updated"} + + def _restart_platform_gateway(self): + self.restart_calls += 1 \ No newline at end of file diff --git a/console/src/app/router/app-route-boundary.tsx b/console/src/app/router/app-route-boundary.tsx index 692801738..be4ccb5bb 100644 --- a/console/src/app/router/app-route-boundary.tsx +++ b/console/src/app/router/app-route-boundary.tsx @@ -1,5 +1,60 @@ -import { Outlet } from 'react-router-dom' +import { Box, Button, Stack, Typography } from '@mui/material' +import { useEffect } from 'react' +import { Outlet, useRouteError } from 'react-router-dom' + +const CHUNK_RELOAD_KEY = 'websoft9:chunk-load-reload' +const CHUNK_RELOAD_WINDOW_MS = 30_000 + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error ?? '') +} + +function isChunkLoadError(message: string) { + return ( + message.includes('dynamically imported module') || + message.includes('error loading dynamically imported module') || + message.includes('Failed to fetch dynamically imported module') + ) +} + +function shouldRetryChunkLoad() { + const lastReloadAt = Number(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) ?? '0') + return !Number.isFinite(lastReloadAt) || Date.now() - lastReloadAt > CHUNK_RELOAD_WINDOW_MS +} export function AppRouteBoundary() { return +} + +export function AppRouteErrorBoundary() { + const error = useRouteError() + const message = getErrorMessage(error) + const shouldReload = isChunkLoadError(message) && shouldRetryChunkLoad() + + useEffect(() => { + if (!shouldReload) { + return + } + + window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now())) + window.location.reload() + }, [message, shouldReload]) + + if (shouldReload) { + return null + } + + return ( + + + + 页面加载失败 + 页面资源未能加载,可能是系统已更新或网络暂时异常。 + + + + + + + ) } \ No newline at end of file diff --git a/console/src/app/router/index.tsx b/console/src/app/router/index.tsx index 4c76196dd..81b6bdfe6 100644 --- a/console/src/app/router/index.tsx +++ b/console/src/app/router/index.tsx @@ -1,7 +1,7 @@ import { lazy, type ComponentType, type LazyExoticComponent } from 'react' import { Navigate, createBrowserRouter, type RouteObject } from 'react-router-dom' -import { AppRouteBoundary } from './app-route-boundary' +import { AppRouteBoundary, AppRouteErrorBoundary } from './app-route-boundary' import { ShellPlaceholderPage } from '../pages/shell-placeholder-page' import { AppShell } from '../shell/app-shell' import { shellNavigationItems, type ShellPageKey } from '../shell/shell-navigation' @@ -33,6 +33,7 @@ const SettingsPage = lazyPage(() => import('../../features/settings/settings-pag const FilesPage = lazyPage(() => import('../../features/files/files-page'), 'FilesPage') const LogsPage = lazyPage(() => import('../../features/logs/logs-page'), 'LogsPage') const OverviewPage = lazyPage(() => import('../../features/overview/overview-page'), 'OverviewPage') +const DatabasesPage = lazyPage(() => import('../../features/databases/databases-page'), 'DatabasesPage') const ServicesPage = lazyPage(() => import('../../features/services/services-page'), 'ServicesPage') const TerminalPage = lazyPage(() => import('../../features/terminal/terminal-page'), 'TerminalPage') const UsersPage = lazyPage(() => import('../../features/users/users-page'), 'UsersPage') @@ -86,6 +87,8 @@ function preloadInitialRoute(pathname: string) { preloaders.push(LogsPage.preload) } else if (pathname === '/services') { preloaders.push(ServicesPage.preload) + } else if (pathname === '/databases') { + preloaders.push(DatabasesPage.preload) } else if (pathname === '/applications/deploy') { preloaders.push(ApplicationsDeployPage.preload) } else if (pathname === '/applications/custom-install') { @@ -192,6 +195,13 @@ export function createAppRouter() { } } + if (item.segment === 'databases') { + return { + path: item.segment, + element: , + } + } + if (item.segment === 'dashboard') { return { path: item.segment, @@ -211,6 +221,7 @@ export function createAppRouter() { { path: '/', element: , + errorElement: , children: [ { path: 'auth/setup', diff --git a/console/src/app/shell/app-shell.tsx b/console/src/app/shell/app-shell.tsx index 49c38b2a9..4eaf937ac 100644 --- a/console/src/app/shell/app-shell.tsx +++ b/console/src/app/shell/app-shell.tsx @@ -43,7 +43,7 @@ type PlatformBrandState = { const navigationSections = [ { key: 'system', - segments: ['dashboard', 'applications', 'containers', 'gateway', 'repository'], + segments: ['dashboard', 'applications', 'databases', 'containers', 'gateway', 'repository'], }, { key: 'tools', @@ -78,6 +78,8 @@ function ShellNavIcon({ segment }: { segment: AppNavIconSegment }) { return case 'custom-install': return + case 'databases': + return case 'containers': return case 'gateway': @@ -211,6 +213,7 @@ export function AppShell() { location.pathname === '/dashboard' || location.pathname === '/terminal' || location.pathname === '/services' || + location.pathname === '/databases' || location.pathname === '/logs' || location.pathname === '/users' || location.pathname === '/settings' diff --git a/console/src/app/shell/shell-navigation.ts b/console/src/app/shell/shell-navigation.ts index b9e5c25dc..7bd8f1ef3 100644 --- a/console/src/app/shell/shell-navigation.ts +++ b/console/src/app/shell/shell-navigation.ts @@ -11,6 +11,10 @@ export const shellNavigationItems = [ segment: 'myapps', pageKey: 'myApps', }, + { + segment: 'databases', + pageKey: 'databases', + }, { segment: 'containers', pageKey: 'containers', diff --git a/console/src/features/app-store/app-store-model.ts b/console/src/features/app-store/app-store-model.ts index fc0e6f86d..72dfff102 100644 --- a/console/src/features/app-store/app-store-model.ts +++ b/console/src/features/app-store/app-store-model.ts @@ -26,6 +26,10 @@ export type AppStoreDistribution = { value?: string | string[] } +export type AppStoreInstallProfile = { + settings?: Record +} + export type AppStoreApp = { key?: string hot?: number @@ -45,6 +49,7 @@ export type AppStoreApp = { } settings?: Record is_web_app?: boolean + profiles?: Record production?: boolean relatedAppsCollection?: { items?: { key?: string; trademark?: string }[] diff --git a/console/src/features/app-store/app-store-page.tsx b/console/src/features/app-store/app-store-page.tsx index 4d441691b..76e534be4 100644 --- a/console/src/features/app-store/app-store-page.tsx +++ b/console/src/features/app-store/app-store-page.tsx @@ -11,6 +11,7 @@ import { DialogContent, DialogTitle, IconButton, + InputAdornment, Link, MenuItem, Stack, @@ -86,6 +87,18 @@ function normalizeCustomDomain(value: string) { return value.trim().toLowerCase() } +function DatabasePasswordVisibilityIcon({ visible }: { visible: boolean }) { + return visible ? ( + + + + ) : ( + + + + ) +} + function normalizeMountPath(value: string) { return value.trim().replace(/\\+/g, '/').replace(/^\.\//, '') } @@ -116,11 +129,12 @@ function getInstallPortsValidationMessage( settings: Record, t: (key: string, options?: Record) => string, locale: string, + profile: string | null, ) { const usedPorts = new Set() for (const [key, rawValue] of Object.entries(settings)) { - if (!key.toLowerCase().includes('port')) { + if (!key.toLowerCase().includes('port') || (profile === 'external-mysql' && key === 'W9_DB_PORT_SET')) { continue } @@ -294,6 +308,7 @@ async function installApp( settings: Record, domainNames: string[], proxyEnabled: boolean, + profile: string | null, ) { const distribution = getPreferredAppStoreInstallDistribution(app) const response = await fetch('/api/apps/install', { @@ -313,6 +328,7 @@ async function installApp( proxy_enabled: proxyEnabled, domain_names: domainNames, settings, + ...(profile ? { profile } : {}), }), }) @@ -334,6 +350,19 @@ async function installApp( return response.json().catch(() => null) as Promise } +async function testExternalMySQLConnection(settings: Record) { + return requestJson<{ status: string }>('/api/apps/install/external-mysql/test-connection', { + method: 'POST', + body: JSON.stringify({ + host: settings.W9_DB_HOST_SET, + port: Number(settings.W9_DB_PORT_SET), + database_name: settings.W9_DB_NAME_SET, + username: settings.W9_DB_USER_SET, + password: settings.W9_DB_PASSWORD_SET, + }), + }) +} + function AppLogo({ app, locale }: { app: AppStoreApp; locale: string }) { const containerRef = useRef(null) const [sourceIndex, setSourceIndex] = useState(0) @@ -885,7 +914,12 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = const [installName, setInstallName] = useState('') const [selectedVersion, setSelectedVersion] = useState('latest') const [installSettings, setInstallSettings] = useState>({}) + const [selectedInstallProfile, setSelectedInstallProfile] = useState(null) + const [profileInstallSettings, setProfileInstallSettings] = useState>>({}) + const [isTestingDatabase, setIsTestingDatabase] = useState(false) + const [isDatabasePasswordVisible, setIsDatabasePasswordVisible] = useState(false) const [installError, setInstallError] = useState(null) + const [installToastRevision, setInstallToastRevision] = useState(0) const [installFieldErrors, setInstallFieldErrors] = useState({}) const [installFeedback, setInstallFeedback] = useState(null) const [isSubmittingInstall, setIsSubmittingInstall] = useState(false) @@ -972,10 +1006,71 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = const canonicalSelectedApp = apps.find((app) => (app.key ?? '').toLowerCase() === selectedAppKey) return canonicalSelectedApp?.settings ?? selectedApp?.settings ?? {} }, [apps, selectedApp]) + const selectedAppProfiles = useMemo(() => { + const selectedAppKey = (selectedApp?.key ?? '').toLowerCase() + const canonicalSelectedApp = selectedAppKey + ? apps.find((app) => (app.key ?? '').toLowerCase() === selectedAppKey) + : undefined + return canonicalSelectedApp?.profiles ?? selectedApp?.profiles ?? {} + }, [apps, selectedApp]) + const selectedProfileTemplateSettings = selectedInstallProfile + ? selectedAppProfiles[selectedInstallProfile]?.settings ?? {} + : {} + const isExternalMySQLProfile = selectedInstallProfile === 'external-mysql' + const externalMySQLSettingKeys = [ + 'W9_DB_HOST_SET', + 'W9_DB_PORT_SET', + 'W9_DB_NAME_SET', + 'W9_DB_USER_SET', + 'W9_DB_PASSWORD_SET', + ] const effectiveInstallSettings = useMemo( - () => (Object.keys(installSettings).length > 0 ? installSettings : selectedAppSettings), - [installSettings, selectedAppSettings], + () => { + if (!selectedInstallProfile) { + return Object.keys(installSettings).length > 0 ? installSettings : selectedAppSettings + } + + return profileInstallSettings[selectedInstallProfile] ?? selectedProfileTemplateSettings + }, + [installSettings, profileInstallSettings, selectedInstallProfile, selectedProfileTemplateSettings], + ) + const sharedInstallSettings = Object.entries(effectiveInstallSettings).filter( + ([key]) => !isExternalMySQLProfile || !externalMySQLSettingKeys.includes(key), ) + const displayedProfileInstallSettings = externalMySQLSettingKeys.map( + (key) => [key, effectiveInstallSettings[key] ?? ''] as [string, string], + ) + const isChineseLocale = resolvedLocale.toLowerCase().startsWith('zh') + + function getDatabaseSettingLabel(key: string) { + const labels: Record = { + W9_DB_HOST_SET: { zh: '数据库主机', en: 'Database host' }, + W9_DB_PORT_SET: { zh: '数据库端口', en: 'Database port' }, + W9_DB_NAME_SET: { zh: '数据库名称', en: 'Database name' }, + W9_DB_USER_SET: { zh: '数据库用户', en: 'Database user' }, + W9_DB_PASSWORD_SET: { zh: '数据库密码', en: 'Database password' }, + } + return labels[key]?.[isChineseLocale ? 'zh' : 'en'] ?? getInstallSettingLabel(key, t, resolvedLocale) + } + + function getExternalDatabaseValidationErrors(settings: Record) { + const errors: Record = {} + const requiredKeys = ['W9_DB_HOST_SET', 'W9_DB_PORT_SET', 'W9_DB_NAME_SET', 'W9_DB_USER_SET', 'W9_DB_PASSWORD_SET'] + for (const key of requiredKeys) { + if (!settings[key]?.trim()) { + errors[key] = isChineseLocale ? `${getDatabaseSettingLabel(key)}不能为空` : `${getDatabaseSettingLabel(key)} is required` + } + } + const host = settings.W9_DB_HOST_SET?.trim() ?? '' + if (host && (host.includes('://') || /\s/.test(host))) { + errors.W9_DB_HOST_SET = isChineseLocale ? '数据库主机格式无效' : 'Database host is invalid' + } + const port = Number(settings.W9_DB_PORT_SET) + if (settings.W9_DB_PORT_SET?.trim() && (!Number.isInteger(port) || port < 1 || port > 65535)) { + errors.W9_DB_PORT_SET = isChineseLocale ? '数据库端口必须在 1 到 65535 之间' : 'Database port must be between 1 and 65535' + } + return errors + } const installedOfficialAppNames = useMemo(() => { const myApps = myAppsData ?? [] @@ -1264,6 +1359,12 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = setInstallName(normalizeInstallName(selectedApp.trademark ?? selectedApp.key ?? '')) setSelectedVersion(distribution.versions[0] ?? 'latest') setInstallSettings({ ...(selectedApp.settings ?? {}) }) + setSelectedInstallProfile(null) + setProfileInstallSettings( + Object.fromEntries( + Object.entries(selectedApp.profiles ?? {}).map(([profile, metadata]) => [profile, { ...(metadata.settings ?? {}) }]), + ), + ) setInstallError(null) setInstallFieldErrors({}) setIsDomainEnabled(Boolean(wildcardDomain)) @@ -1423,7 +1524,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = seenDomains.add(domain) } - const portsValidationMessage = getInstallPortsValidationMessage(effectiveInstallSettings, t, locale) + const portsValidationMessage = getInstallPortsValidationMessage(effectiveInstallSettings, t, locale, selectedInstallProfile) if (portsValidationMessage) { setInstallFieldErrors({ settings: { [portsValidationMessage.key]: portsValidationMessage.message } }) setInstallError(portsValidationMessage.message) @@ -1431,6 +1532,16 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = return } + if (selectedInstallProfile === 'external-mysql') { + const databaseErrors = getExternalDatabaseValidationErrors(effectiveInstallSettings) + if (Object.keys(databaseErrors).length > 0) { + setInstallFieldErrors({ settings: databaseErrors }) + setInstallError(Object.values(databaseErrors)[0] ?? null) + installSettingInputRefs.current[Object.keys(databaseErrors)[0]]?.focus() + return + } + } + setIsSubmittingInstall(true) setInstallError(null) @@ -1459,6 +1570,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = effectiveInstallSettings, domainNames.length > 0 ? domainNames : [currentHostname], proxyEnabled, + selectedInstallProfile, ) setSelectedApp(null) setIsInstallMode(false) @@ -1475,6 +1587,9 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = if (/Exceed the maximum number of apps/i.test(message)) { message = t('appStorePage.install.feedback.maxApps') } + if (selectedInstallProfile === 'external-mysql' && /Unable to connect to the specified MySQL database\.?/i.test(message)) { + message = t('appStorePage.install.databaseConnection.failed') + } // Detect port conflict error from backend and show i18n-friendly message const portMatch = message.match(/Port\s+(\d+)\s+is already in use/i) if (portMatch) { @@ -1486,6 +1601,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = installSettingInputRefs.current[matchedSettingKey]?.focus() } } + setInstallToastRevision((currentValue) => currentValue + 1) setInstallError(message) } finally { setIsSubmittingInstall(false) @@ -2682,7 +2798,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = {/* ── Related Recommendations ── */} - {relatedApps.length > 0 ? ( + {relatedApps.length > 0 && !deferredSearchValue && selectedMainCatalogKey === 'all' && selectedSubCatalogKey === 'all' ? ( <> + + + + {t('appStorePage.install.latestVersionWarning')} + + ) : undefined} sx={{ ...installDialogFieldSx, '& .MuiSelect-select': { ...appStoreControlTextSx, color: palette.text, }, + ...(selectedVersion.toLowerCase() === 'latest' ? { + '& .MuiFormHelperText-root': { + color: palette.warning, + }, + } : {}), }} slotProps={{ select: { @@ -3255,6 +3384,14 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = fullWidth size="small" value={availableVersions[0] ?? selectedVersion} + helperText={(availableVersions[0] ?? selectedVersion).toLowerCase() === 'latest' ? ( + + + + + {t('appStorePage.install.latestVersionWarning')} + + ) : undefined} slotProps={{ input: { readOnly: true, @@ -3271,12 +3408,17 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = color: palette.text, WebkitTextFillColor: palette.text, }, + ...((availableVersions[0] ?? selectedVersion).toLowerCase() === 'latest' ? { + '& .MuiFormHelperText-root': { + color: palette.warning, + }, + } : {}), }} /> )} - {Object.entries(effectiveInstallSettings).map(([key, value]) => ( + {sharedInstallSettings.map(([key, value]) => ( {(() => { @@ -3290,6 +3432,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = installSettingInputRefs.current[key] = element }} size="small" + type={key.endsWith('_PASSWORD_SET') ? 'password' : 'text'} value={value} onChange={(event) => { setInstallFieldErrors((currentValue) => ({ @@ -3297,10 +3440,20 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = settings: currentValue.settings ? { ...currentValue.settings, [key]: undefined } : currentValue.settings, })) setInstallError(null) - setInstallSettings((currentValue) => ({ - ...currentValue, - [key]: event.target.value, - })) + if (selectedInstallProfile) { + setProfileInstallSettings((currentValue) => ({ + ...currentValue, + [selectedInstallProfile]: { + ...(currentValue[selectedInstallProfile] ?? selectedProfileTemplateSettings), + [key]: event.target.value, + }, + })) + } else { + setInstallSettings((currentValue) => ({ + ...currentValue, + [key]: event.target.value, + })) + } }} slotProps={{ htmlInput: key.toLowerCase().includes('port') @@ -3325,6 +3478,152 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = ))} + {Object.keys(selectedAppProfiles).length > 0 ? ( + + + {isChineseLocale ? '应用数据库' : 'Application database'} + + { + setInstallError(null) + setInstallFieldErrors({}) + setSelectedInstallProfile(event.target.value || null) + }} + sx={{ + ...installDialogFieldSx, + '& .MuiSelect-select': { ...appStoreControlTextSx, color: palette.text }, + }} + slotProps={{ select: { MenuProps: installDialogSelectMenuProps, displayEmpty: true } }} + > + {isChineseLocale ? '系统内置' : 'System built-in'} + {Object.keys(selectedAppProfiles).map((profile) => ( + + {profile === 'external-mysql' ? (isChineseLocale ? '自定义' : 'Custom') : profile.replace(/-/g, ' ')} + + ))} + + + {isExternalMySQLProfile ? ( + + + {isChineseLocale ? '数据库连接信息' : 'Database connection'} + + + {displayedProfileInstallSettings.map(([key, value]) => ( + + {getDatabaseSettingLabel(key)} + + { + installSettingInputRefs.current[key] = element + }} + size="small" + type={key === 'W9_DB_PASSWORD_SET' && !isDatabasePasswordVisible ? 'password' : 'text'} + value={value} + onChange={(event) => { + setInstallFieldErrors((currentValue) => ({ + ...currentValue, + settings: currentValue.settings ? { ...currentValue.settings, [key]: undefined } : currentValue.settings, + })) + setInstallError(null) + setProfileInstallSettings((currentValue) => ({ + ...currentValue, + [selectedInstallProfile]: { + ...(currentValue[selectedInstallProfile] ?? selectedProfileTemplateSettings), + [key]: event.target.value, + }, + })) + }} + slotProps={{ + input: key === 'W9_DB_PASSWORD_SET' ? { + endAdornment: ( + + setIsDatabasePasswordVisible((currentValue) => !currentValue)} + > + + + + ), + } : undefined, + htmlInput: key.toLowerCase().includes('port') + ? { inputMode: 'numeric', pattern: '[0-9]*' } + : undefined, + }} + sx={{ + ...installDialogFieldSx, + '& .MuiInputBase-input': { + ...appStoreControlTextSx, + color: palette.text, + WebkitTextFillColor: palette.text, + }, + }} + /> + {key === 'W9_DB_PASSWORD_SET' ? ( + + ) : null} + + + ))} + + + ) : null} + + ) : null} + )} @@ -3473,6 +3772,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = { setInstallFeedback(null) diff --git a/console/src/features/app-store/use-app-store-apps.ts b/console/src/features/app-store/use-app-store-apps.ts index 366d59fd3..2f25aa45d 100644 --- a/console/src/features/app-store/use-app-store-apps.ts +++ b/console/src/features/app-store/use-app-store-apps.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import type { AppStoreApp } from './app-store-model' +import type { AppStoreApp, AppStoreInstallProfile } from './app-store-model' type AppStoreError = Error & { statusCode?: number @@ -10,6 +10,7 @@ type AppStoreError = Error & { type AppStoreInstallMetadata = { settings?: Record is_web_app?: boolean + profiles?: Record } type AppStoreInstallMetadataManifest = { @@ -54,6 +55,7 @@ function mergeInstallMetadata(apps: AppStoreApp[], metadataManifest: AppStoreIns ...app, settings: installMetadata.settings ?? app.settings ?? {}, is_web_app: installMetadata.is_web_app ?? app.is_web_app ?? false, + profiles: installMetadata.profiles ?? app.profiles, } }) } diff --git a/console/src/features/databases/databases-page.css b/console/src/features/databases/databases-page.css new file mode 100644 index 000000000..bd53f5e6f --- /dev/null +++ b/console/src/features/databases/databases-page.css @@ -0,0 +1,102 @@ +.databases-page-shell { + font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +.databases-search-box { + position: relative; + display: flex; + align-items: center; +} + +.databases-search-input { + width: 100%; + height: 36px; + padding: 0 36px 0 12px; + border: 1px solid var(--databases-search-border, rgba(203, 213, 225, 0.9)); + border-radius: 4px; + background: var(--databases-search-bg, #ffffff); + color: var(--databases-search-text, #0f172a); + font-family: inherit; + font-size: 14px; + outline: none; +} + +.app-shell-root--dark .databases-search-input { + --databases-search-border: rgba(71, 85, 105, 0.82); + --databases-search-bg: #0f172a; + --databases-search-text: #e5edf5; +} + +.databases-search-input::placeholder { + color: #94a3b8; +} + +.databases-search-input:focus { + border-color: #1767d1; + box-shadow: 0 0 0 2px rgba(23, 103, 209, 0.15); +} + +.databases-search-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + display: inline-flex; + align-items: center; + pointer-events: none; +} + +.databases-password-text { + flex: 1; + min-width: 36px; +} + +.databases-icon-btn { + background: none; + border: none; + color: inherit; + opacity: 0.55; + cursor: pointer; + padding: 2px 4px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 3px; + transition: opacity 120ms ease, background-color 120ms ease; +} + +.databases-icon-btn:hover { + opacity: 1; + background-color: rgba(145, 158, 171, 0.12); +} + +.databases-icon-btn svg { + width: 16px; + height: 16px; +} + +.databases-app-link { + color: var(--databases-accent, #1767d1); + cursor: pointer; + text-decoration: none; +} + +.databases-app-link:hover { + text-decoration: underline; +} + +.app-shell-root--dark .databases-app-link { + --databases-accent: #60a5fa; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/console/src/features/databases/databases-page.tsx b/console/src/features/databases/databases-page.tsx new file mode 100644 index 000000000..592e61e79 --- /dev/null +++ b/console/src/features/databases/databases-page.tsx @@ -0,0 +1,369 @@ +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + IconButton, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' + +import { markMyAppsDetailOverlayIntent } from '../my-apps/my-app-detail-overlay-intent' +import { useAppColorMode } from '../../app/providers/color-mode' +import { useProductAuth } from '../product-auth/product-auth-provider' +import { PageDescriptionHeader } from '../../shared/design-system/page-description-header' +import './databases-page.css' + +type ExternalDatabaseAppRef = { + app_id: string + app_name: string + status: number +} + +type ExternalDatabaseRecord = { + type: string + address: string + database_name: string + username: string + password: string + apps: ExternalDatabaseAppRef[] +} + +function EyeIcon() { + return ( + + + + ) +} + +function EyeOffIcon() { + return ( + + + + ) +} + +function CopyIcon() { + return ( + + + + ) +} + +function RefreshIcon() { + return ( + + + + ) +} + +async function requestJson(input: string, init?: RequestInit): Promise { + const response = await fetch(input, { + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + + const payload = (await response.json().catch(() => null)) as { details?: string; message?: string } | T | null + if (!response.ok) { + const errorMessage = + payload && typeof payload === 'object' && 'details' in payload + ? payload.details ?? payload.message ?? `HTTP ${response.status}` + : `HTTP ${response.status}` + throw new Error(errorMessage) + } + + return payload as T +} + +async function copyTextWithFallback(value: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value) + return + } catch { + // Fall through to execCommand + } + } + + const textarea = document.createElement('textarea') + textarea.value = value + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'absolute' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + + const copied = document.execCommand('copy') + document.body.removeChild(textarea) + + if (!copied) { + throw new Error('Copy failed') + } +} + +export function DatabasesPage() { + const { t } = useTranslation('shell') + const { colorMode } = useAppColorMode() + const isDarkMode = colorMode === 'dark' + const { status } = useProductAuth() + const navigate = useNavigate() + const pageShellRef = useRef(null) + const [searchValue, setSearchValue] = useState('') + const [showPasswords, setShowPasswords] = useState>({}) + + const { data, error, isLoading, isFetching, refetch } = useQuery({ + queryKey: ['external-databases'], + queryFn: async () => { + const result = await requestJson('/api/databases') + return result + }, + enabled: Boolean(status?.enabled && status?.authenticated), + staleTime: 15_000, + refetchOnWindowFocus: false, + }) + + const databases = data ?? [] + + const filteredDatabases = useMemo(() => { + const normalizedQuery = searchValue.trim().toLowerCase() + if (!normalizedQuery) return databases + + return databases.filter( + (db) => + db.type.toLowerCase().includes(normalizedQuery) || + db.address.toLowerCase().includes(normalizedQuery) || + db.database_name.toLowerCase().includes(normalizedQuery) || + db.username.toLowerCase().includes(normalizedQuery) || + db.apps.some((a) => a.app_id.toLowerCase().includes(normalizedQuery)), + ) + }, [databases, searchValue]) + + const palette = { + pageBg: isDarkMode ? '#0f172a' : '#ffffff', + cardBg: isDarkMode ? '#111827' : '#ffffff', + tableHead: isDarkMode ? '#162033' : '#f8fafc', + text: isDarkMode ? '#f8fafc' : '#0f172a', + subtleText: isDarkMode ? '#94a3b8' : '#64748b', + actionText: isDarkMode ? '#f8fafc' : '#475569', + border: isDarkMode ? 'rgba(71, 85, 105, 0.65)' : 'rgba(226, 232, 240, 0.95)', + borderStrong: isDarkMode ? 'rgba(148, 163, 184, 0.2)' : 'rgba(203, 213, 225, 0.9)', + idleBg: isDarkMode ? '#111827' : '#ffffff', + idleHover: isDarkMode ? '#162033' : '#f8fafc', + buttonHover: isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(145, 158, 171, 0.12)', + accent: isDarkMode ? '#60a5fa' : '#1767d1', + } as const + + return ( + + + {!status?.enabled ? {t('servicesPage.states.authDisabled')} : null} + + {error ? ( + refetch()}> + {t('databasesPage.actions.retry')} + + } + severity="error" + > + {error.message || t('databasesPage.states.loadError')} + + ) : null} + + + + + + + + setSearchValue(e.target.value)} + /> + + + refetch()} + disabled={isFetching} + size="small" + title={t('databasesPage.actions.refresh')} + sx={{ + color: palette.actionText, + '&:hover': { backgroundColor: palette.buttonHover }, + animation: isFetching ? 'spin 1s linear infinite' : 'none', + }} + > + + + + + {(isLoading || isFetching) ? ( + + + {t('databasesPage.states.loading')} + + ) : ( + + {/* Table header — always visible when not loading */} + + {t('databasesPage.columns.type')} + {t('databasesPage.columns.address')} + {t('databasesPage.columns.name')} + {t('databasesPage.columns.username')} + {t('databasesPage.columns.password')} + {t('databasesPage.columns.appName')} + + + {!isLoading && !isFetching && data && databases.length === 0 ? ( + + {t('databasesPage.states.empty')} + + ) : null} + + {!isLoading && !isFetching && data && databases.length > 0 && filteredDatabases.length === 0 ? ( + + {t('databasesPage.states.noResults')} + + ) : null} + + {filteredDatabases.length > 0 && filteredDatabases.map((db, index) => { + const passwordKey = `${db.type}-${db.address}-${index}` + const isPasswordVisible = Boolean(showPasswords[passwordKey]) + return ( + + {db.type} + {db.address} + {db.database_name} + {db.username} + + + {isPasswordVisible ? db.password : '•'.repeat(Math.min(db.password.length, 16))} + + + + + + {db.apps.map((appRef, i) => ( + + { + if (appRef.status === 1) { + markMyAppsDetailOverlayIntent(appRef.app_id) + navigate(`/myapps/${encodeURIComponent(appRef.app_id)}`) + } else { + navigate('/myapps') + } + }} + title={appRef.app_id} + > + {appRef.app_id} + + {i < db.apps.length - 1 ? ( + , + ) : null} + + ))} + + + ) + })} + + )} + + + + + ) +} diff --git a/console/src/features/my-apps/my-app-detail-page.css b/console/src/features/my-apps/my-app-detail-page.css index a675488f3..19a4b352c 100644 --- a/console/src/features/my-apps/my-app-detail-page.css +++ b/console/src/features/my-apps/my-app-detail-page.css @@ -796,6 +796,7 @@ .myapps-volume-runtime-card { overflow: hidden; + padding: 6px 16px 12px; } .myapps-volume-database-card-body { @@ -1053,7 +1054,7 @@ .myapps-database-table th, .myapps-database-table td { - padding: 18px 10px; + padding: 10px; border-bottom: 1px solid var(--myapps-detail-border); vertical-align: middle; text-align: left; @@ -1070,6 +1071,44 @@ color: var(--myapps-detail-muted); } +.myapps-database-table-external { + table-layout: fixed; +} + +.myapps-database-table-external th:nth-child(1), +.myapps-database-table-external td:nth-child(1) { + width: 12%; +} + +.myapps-database-table-external th:nth-child(2), +.myapps-database-table-external td:nth-child(2) { + width: 9%; +} + +.myapps-database-table-external th:nth-child(3), +.myapps-database-table-external td:nth-child(3) { + width: 22%; +} + +.myapps-database-table-external th:nth-child(4), +.myapps-database-table-external td:nth-child(4) { + width: 15%; +} + +.myapps-database-table-external th:nth-child(5), +.myapps-database-table-external td:nth-child(5) { + width: 12%; +} + +.myapps-database-table-external th:nth-child(6), +.myapps-database-table-external td:nth-child(6) { + width: 30%; +} + +.myapps-database-table-external td { + overflow-wrap: anywhere; +} + .myapps-overview-data-table { width: 100%; border-collapse: collapse; @@ -1102,6 +1141,12 @@ gap: 4px; } +.myapps-database-table-external .myapps-database-password-cell { + justify-content: flex-start; + flex-wrap: nowrap; + white-space: nowrap; +} + .myapps-database-password-text { font-family: monospace; letter-spacing: 2px; @@ -1314,7 +1359,14 @@ .myapps-backup-detail-table th, .myapps-backup-detail-table td { - padding: 18px 10px; + padding: 10px 14px; +} + +.myapps-backup-detail-table th, +.myapps-volume-runtime-card .myapps-detail-table th, +.myapps-database-table th { + padding-top: 3px; + padding-bottom: 3px; } .myapps-detail-table { @@ -1340,6 +1392,11 @@ color: var(--myapps-detail-muted); } +.myapps-volume-runtime-card .myapps-detail-table th, +.myapps-volume-runtime-card .myapps-detail-table td { + padding: 10px 14px; +} + .myapps-cell-center { text-align: center !important; } @@ -1374,6 +1431,10 @@ text-align: center !important; } +.myapps-backup-detail-table .myapps-empty-cell { + padding: 16px 12px !important; +} + .myapps-empty-cell-placeholder { color: transparent !important; } @@ -1419,6 +1480,31 @@ margin-bottom: 16px; } +.myapps-backup-external-notice { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 16px 0; + color: var(--myapps-detail-accent); + font-size: 13px; + line-height: 1.5; +} + +.myapps-backup-external-notice-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 16px; + border: 1px solid currentColor; + border-radius: 50%; + font-family: Georgia, serif; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + .myapps-card-header-compose { justify-content: flex-start; } diff --git a/console/src/features/my-apps/my-app-detail-page.tsx b/console/src/features/my-apps/my-app-detail-page.tsx index 535e8e3be..1b23f0e83 100644 --- a/console/src/features/my-apps/my-app-detail-page.tsx +++ b/console/src/features/my-apps/my-app-detail-page.tsx @@ -46,10 +46,13 @@ type RedeployLogEntry = { data?: unknown } type DatabaseRow = { + source: string type: string host: string + databaseName: string account: string password: string + isExternal: boolean toolApps: Array<{ label: string; appKey: string }> } type BackupSnapshot = { @@ -248,7 +251,7 @@ function getDetailPortLabel(key: string, t: (key: string, options?: Record | undefined): Array<[string, string, string]> { if (!env) return [] return Object.entries(env) - .filter(([key]) => key.endsWith('PORT_SET')) + .filter(([key]) => key.endsWith('PORT_SET') && !(env.W9_DATABASE_MODE === 'external' && key === 'W9_DB_PORT_SET')) .map(([key, value]) => [key, knownDetailPortLabelKeys[key] ?? key, String(value || '-')]) } @@ -376,11 +379,27 @@ function getVolumeCreatedAt(v: Record, locale: string) { function getDatabaseRows(data: MyAppDetail): DatabaseRow[] { const expose = data.env?.W9_DB_EXPOSE if (!expose) return [] + if (data.env?.W9_DATABASE_MODE === 'external') { + const dbType = expose.split(',').map((value) => value.trim()).find(Boolean) ?? 'mysql' + return [{ + source: 'custom', + type: dbType, + host: [data.env.W9_DB_HOST_SET, data.env.W9_DB_PORT_SET].filter(Boolean).join(':') || data.env.WORDPRESS_DB_HOST || '-', + databaseName: data.env.W9_DB_NAME_SET ?? data.env.WORDPRESS_DB_NAME ?? '-', + account: data.env.W9_DB_USER_SET ?? data.env.WORDPRESS_DB_USER ?? '-', + password: data.env.W9_DB_PASSWORD_SET ?? data.env.WORDPRESS_DB_PASSWORD ?? '-', + isExternal: true, + toolApps: [], + }] + } return expose.split(',').map((s) => s.trim()).filter(Boolean).map((dbType) => ({ + source: '', type: dbType, host: `${data.app_id}-${dbType}`, + databaseName: '-', account: dbConfig[dbType]?.account || '-', password: data.env?.W9_POWER_PASSWORD || '-', + isExternal: false, toolApps: dbConfig[dbType]?.toolApps || [], })) } @@ -1066,6 +1085,7 @@ export function MyAppDetailPage() { const isComposeApp = data?.app_dist === 'compose' const isComposeUI = isComposeApp || data?.is_compose_app === true + const isExternalDatabase = data?.env?.W9_DATABASE_MODE === 'external' async function handleSimpleAction(actionKey: 'start' | 'stop' | 'restart') { if (!data) return @@ -1832,6 +1852,12 @@ export function MyAppDetailPage() { + {isExternalDatabase ? ( +
+ + {t('myAppsDetailPage.tabs.volumes.backups.externalDatabaseNotice')} +
+ ) : null} @@ -1847,33 +1873,37 @@ export function MyAppDetailPage() {
- +
+ {isExternalDatabase ? : null} - - + + {isExternalDatabase ? : null} + - + {!isExternalDatabase ? : null} {databaseRows.map((row) => ( + {row.isExternal ? : null} + {row.isExternal ? : null} - + : null} ))} @@ -2248,6 +2278,11 @@ export function MyAppDetailPage() { ? t('myAppsDetailPage.dialogs.removeBody', { appId: data?.app_id ?? appId ?? '-' }) : t('myAppsDetailPage.dialogs.uninstallBody', { appId: data?.app_id ?? appId ?? '-' })} + {isExternalDatabase ? ( + + {t('myAppsDetailPage.dialogs.externalDatabaseNotice')} + + ) : null} {!isComposeApp ? ( {t('myAppsDetailPage.dialogs.uninstallPurge')} diff --git a/console/src/main.tsx b/console/src/main.tsx index fccda7a05..716d7acb1 100644 --- a/console/src/main.tsx +++ b/console/src/main.tsx @@ -5,6 +5,9 @@ import { App } from './app/App' import './index.css' import './shared/i18n/i18n' +const CHUNK_RELOAD_KEY = 'websoft9:chunk-load-reload' +const CHUNK_RELOAD_WINDOW_MS = 30_000 + // When a new deployment replaces old chunk files, lazy-loaded routes // may fail because the browser still references stale chunk URLs. // Catch those errors and force a full reload to pick up the new assets. @@ -16,10 +19,20 @@ function isChunkLoadError(message: string) { ) } +function retryChunkLoadOnce() { + const lastReloadAt = Number(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) ?? '0') + if (Number.isFinite(lastReloadAt) && Date.now() - lastReloadAt <= CHUNK_RELOAD_WINDOW_MS) { + return + } + + window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now())) + window.location.reload() +} + window.addEventListener('unhandledrejection', (event) => { const message = event.reason?.message || String(event.reason || '') if (isChunkLoadError(message)) { - window.location.reload() + retryChunkLoadOnce() } }) @@ -27,7 +40,7 @@ window.addEventListener('unhandledrejection', (event) => { window.addEventListener('error', (event) => { const message = event.message || '' if (isChunkLoadError(message)) { - window.location.reload() + retryChunkLoadOnce() } }) diff --git a/console/src/shared/i18n/resources.ts b/console/src/shared/i18n/resources.ts index 4e51a586c..ecb2b3c90 100644 --- a/console/src/shared/i18n/resources.ts +++ b/console/src/shared/i18n/resources.ts @@ -127,6 +127,9 @@ const rawShellResources = { containers: { label: 'Containers', }, + databases: { + label: 'Databases', + }, gateway: { label: 'Gateway', }, @@ -746,6 +749,7 @@ const rawShellResources = { versionLabel: 'Application version', versionHelper: 'Choose the version to install before continuing.', versionFixedHelper: 'This application currently provides only one installable version.', + latestVersionWarning: 'Not verified for one‑click deployment.', httpPortLabel: 'Application HTTP port', httpsPortLabel: 'Application HTTPS port', databasePortLabel: 'Database port', @@ -759,6 +763,12 @@ const rawShellResources = { customDomainHelper: 'Users will open the app with this address. Leave it empty to use the default address generated by the platform. Do not include http://, https://, ports, or paths.', defaultDomainHelper: 'If enabled, the platform will also create the default access address {{domain}}.', proxyManagedPortHelper: 'When a custom access address is provided, external traffic goes through the proxy. Clear the custom address if you still need to change this host port.', + databaseConnection: { + test: 'Test connection', + testing: 'Testing...', + success: 'Database connection succeeded', + failed: 'Unable to connect to the specified MySQL database.', + }, disableDomain: 'Disable', enableDomain: 'Enable', validation: { @@ -1162,6 +1172,7 @@ const rawShellResources = { uninstallTitle: 'Uninstall application', uninstallBody: 'This will immediately uninstall the app, If the data is preserved, the app can be redeploy.', uninstallPurge: 'Do you want to purge the data:', + externalDatabaseNotice: 'The external database and its data are user-managed and will not be deleted. Purging application data removes the platform-stored connection configuration.', }, feedback: { genericError: 'The lifecycle action failed.', @@ -1317,6 +1328,7 @@ const rawShellResources = { refreshing: 'Refreshing...', create: 'Create Backup', createBody: 'The following volumes will be backed up:', + externalDatabaseNotice: 'This backup and restore only cover application volumes. The external database and its data are managed separately by you.', tipsTitle: 'Tips:', createTips: { allVolumes: 'All application volumes will be included in this backup.', @@ -1392,15 +1404,20 @@ const rawShellResources = { title: 'Database', description: 'Database connection information.', empty: 'The current application does not expose any database connection information.', + custom: 'Custom', copy: 'Copy', copied: 'Database password copied.', copyFailed: 'Failed to copy database password.', showPassword: 'Show password', hidePassword: 'Hide password', columns: { + source: 'Source', type: 'Type', host: 'Intranet Host', + address: 'Database Address', + name: 'Database Name', account: 'Initial Account', + username: 'Username', password: 'Password', tool: 'Recommended Tool', }, @@ -2233,6 +2250,35 @@ const rawShellResources = { logsFallbackTitle: 'Service logs', }, }, + databasesPage: { + hero: { + title: 'Custom Databases', + description: 'Overview of external database connections across installed applications.', + }, + searchPlaceholder: 'Search by app, type, address, database name, or username', + columns: { + appName: 'Application', + type: 'Type', + address: 'Database Address', + name: 'Database Name', + username: 'Username', + password: 'Password', + showPassword: 'Show password', + hidePassword: 'Hide password', + copy: 'Copy', + }, + states: { + loading: 'Loading databases...', + loadError: 'Database information is currently unavailable.', + empty: 'No external databases found. Install an app with an external database to see it here.', + noResults: 'No databases match the current search.', + refreshing: 'Refreshing...', + }, + actions: { + refresh: 'Refresh', + retry: 'Retry', + }, + }, integrations: { hero: { eyebrow: 'Embedded workspace baseline', @@ -2436,6 +2482,9 @@ const rawShellResources = { containers: { label: '容器', }, + databases: { + label: '数据库', + }, gateway: { label: '网关', }, @@ -3055,6 +3104,7 @@ const rawShellResources = { versionLabel: '应用版本', versionHelper: '安装前请选择要使用的版本。', versionFixedHelper: '当前应用只提供这一个可安装版本。', + latestVersionWarning: '该版本未经一键部署验证。', httpPortLabel: '应用 HTTP 端口', httpsPortLabel: '应用 HTTPS 端口', databasePortLabel: '数据库端口', @@ -3068,6 +3118,12 @@ const rawShellResources = { customDomainHelper: '用户将通过这个地址访问应用。留空时会使用默认生成的访问地址。不要输入 http://、https://、端口或路径。', defaultDomainHelper: '启用后,Websoft9 还会同时生成默认访问地址 {{domain}}。', proxyManagedPortHelper: '填写自定义访问地址后,外部访问会优先走代理。如需修改这个宿主端口,请先清空自定义访问地址。', + databaseConnection: { + test: '测试连接', + testing: '测试中...', + success: '数据库连接正常', + failed: '无法连接到指定的 MySQL 数据库。', + }, disableDomain: '禁用', enableDomain: '启用', validation: { @@ -3470,6 +3526,7 @@ const rawShellResources = { uninstallTitle: '卸载应用', uninstallBody: '该操作会立即卸载应用,如果保留数据,应用还可以重新部署。', uninstallPurge: '是否清理数据:', + externalDatabaseNotice: '外部数据库及其数据由用户自行管理,平台不会删除。清理应用数据会删除平台保存的连接配置。', }, feedback: { genericError: '生命周期动作执行失败。', @@ -3625,6 +3682,7 @@ const rawShellResources = { refreshing: '刷新中...', create: '创建备份', createBody: '以下数据卷将被备份:', + externalDatabaseNotice: '此备份和恢复仅覆盖应用数据卷,外部数据库及其数据由用户自行管理。', tipsTitle: '提示:', createTips: { allVolumes: '所有应用数据卷都会包含在本次备份中。', @@ -3700,15 +3758,20 @@ const rawShellResources = { title: '数据库', description: '数据库连接信息。', empty: '当前应用没有声明数据库暴露信息。', + custom: '自定义', copy: '复制', copied: '数据库密码已复制。', copyFailed: '复制数据库密码失败。', showPassword: '显示密码', hidePassword: '隐藏密码', columns: { + source: '来源', type: '类型', host: '内网主机', + address: '数据库地址', + name: '数据库名称', account: '初始账号', + username: '用户名', password: '密码', tool: '推荐工具', }, @@ -4509,6 +4572,35 @@ const rawShellResources = { logsFallbackTitle: '服务日志', }, }, + databasesPage: { + hero: { + title: '自定义数据库', + description: '已安装应用中所有外接数据库的连接信息概览。', + }, + searchPlaceholder: '按应用、类型、地址、数据库名或用户名搜索', + columns: { + appName: '关联应用', + type: '类型', + address: '数据库地址', + name: '数据库名称', + username: '用户名', + password: '密码', + showPassword: '显示密码', + hidePassword: '隐藏密码', + copy: '复制', + }, + states: { + loading: '加载中...', + loadError: '数据库信息暂时不可用。', + empty: '未发现外接数据库。安装带有外接数据库的应用后将在此显示。', + noResults: '没有匹配当前搜索的数据库。', + refreshing: '刷新中...', + }, + actions: { + refresh: '刷新', + retry: '重试', + }, + }, integrations: { hero: { eyebrow: '嵌入式工作区基线', diff --git a/docker/Dockerfile b/docker/Dockerfile index c7b54bfaf..8fe56e803 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -183,7 +183,7 @@ RUN WEBSOFT9_RUNTIME_ASSET_SYNC_MODE=build \ RUN printf '%s\n' '#!/bin/sh' 'exec python3 -m supervisor.supervisord "$@"' > /usr/local/bin/supervisord && \ printf '%s\n' '#!/bin/sh' 'exec python3 -m supervisor.supervisorctl "$@"' > /usr/local/bin/supervisorctl && \ - printf '%s\n' '#!/bin/sh' 'export PYTHONPATH="/opt/websoft9-pydeps:/websoft9/apphub:/websoft9/apphub/src"' 'export WEBSOFT9_CLI_NAME="websoft9"' 'exec python3 /websoft9/apphub/src/cli/apphub_cli.py "$@"' > /usr/local/bin/websoft9 && \ + printf '%s\n' '#!/bin/sh' 'export PYTHONPATH="/opt/websoft9-pydeps:/websoft9/apphub:/websoft9/apphub/src"' 'export WEBSOFT9_CLI_NAME="websoft9"' 'export WEBSOFT9_DATA_ROOT="${WEBSOFT9_DATA_ROOT:-/opt/websoft9/data}"' 'export WEBSOFT9_APPHUB_CONFIG_PATH="${WEBSOFT9_APPHUB_CONFIG_PATH:-$WEBSOFT9_DATA_ROOT/config/apphub/config.ini}"' 'export WEBSOFT9_APPHUB_SYSTEM_CONFIG_PATH="${WEBSOFT9_APPHUB_SYSTEM_CONFIG_PATH:-$WEBSOFT9_DATA_ROOT/config/apphub/system.ini}"' 'exec python3 /websoft9/apphub/src/cli/apphub_cli.py "$@"' > /usr/local/bin/websoft9 && \ chmod +x /usr/local/bin/supervisord /usr/local/bin/supervisorctl /usr/local/bin/websoft9 RUN chmod +x /app/init_nginx.sh \ diff --git a/docker/scripts/platform-sync-runtime-assets.py b/docker/scripts/platform-sync-runtime-assets.py index bde7274ee..fda7ac569 100644 --- a/docker/scripts/platform-sync-runtime-assets.py +++ b/docker/scripts/platform-sync-runtime-assets.py @@ -949,6 +949,37 @@ def resolve_value(key: str, stack: set[str]) -> str: return resolved_values +def get_install_settings(env_values: dict[str, str]) -> dict[str, str]: + return { + key: value + for key, value in env_values.items() + if key.startswith("W9_") and key.endswith("_SET") + } + + +def discover_install_profiles(app_dir: Path) -> dict[str, dict[str, object]]: + profiles: dict[str, dict[str, object]] = {} + + for compose_path in sorted(app_dir.glob("docker-compose.*.yml")): + match = re.match(r"^docker-compose\.([a-z0-9][a-z0-9-]*)\.yml$", compose_path.name) + if not match: + continue + + profile_name = match.group(1) + env_path = app_dir / f".env.{profile_name}" + if not env_path.is_file(): + continue + + try: + profiles[profile_name] = { + "settings": get_install_settings(load_env_values(env_path)), + } + except Exception as exc: + log(f"[platform-assets] failed to read {env_path}: {exc}") + + return profiles + + def build_app_store_install_metadata(library_root: Path, config_path: Path) -> dict[str, object]: manifest: dict[str, object] = { "initial_apps": load_initial_apps(config_path), @@ -974,15 +1005,15 @@ def build_app_store_install_metadata(library_root: Path, config_path: Path) -> d if env_path.exists(): try: env_values = load_env_values(env_path) - app_metadata["settings"] = { - key: value - for key, value in env_values.items() - if key.startswith("W9_") and key.endswith("_SET") - } + app_metadata["settings"] = get_install_settings(env_values) app_metadata["is_web_app"] = "W9_URL" in env_values except Exception as exc: log(f"[platform-assets] failed to read {env_path}: {exc}") + profiles = discover_install_profiles(app_dir) + if profiles: + app_metadata["profiles"] = profiles + apps_metadata[app_key] = app_metadata manifest["apps"] = apps_metadata diff --git a/scripts/generate_appstore_install_metadata.py b/scripts/generate_appstore_install_metadata.py index 5079d5665..b573c2c4d 100644 --- a/scripts/generate_appstore_install_metadata.py +++ b/scripts/generate_appstore_install_metadata.py @@ -10,6 +10,7 @@ ENV_REFERENCE_PATTERN = re.compile(r"\$\{?(\w+)\}?") +PROFILE_COMPOSE_PATTERN = re.compile(r"^docker-compose\.([a-z0-9][a-z0-9-]*)\.yml$") def load_initial_apps(config_path: Path) -> list[str]: @@ -63,6 +64,34 @@ def resolve_value(key: str, stack: set[str]) -> str: return resolved_values +def get_install_settings(env_values: dict[str, str]) -> dict[str, str]: + return { + key: value + for key, value in env_values.items() + if key.startswith("W9_") and key.endswith("_SET") + } + + +def discover_install_profiles(app_dir: Path) -> dict[str, dict[str, object]]: + profiles: dict[str, dict[str, object]] = {} + + for compose_path in sorted(app_dir.glob("docker-compose.*.yml")): + match = PROFILE_COMPOSE_PATTERN.match(compose_path.name) + if not match: + continue + + profile_name = match.group(1) + env_path = app_dir / f".env.{profile_name}" + if not env_path.is_file(): + continue + + profiles[profile_name] = { + "settings": get_install_settings(load_env_values(env_path)), + } + + return profiles + + def build_install_metadata(library_root: Path, config_path: Path) -> dict[str, object]: manifest: dict[str, object] = { "initial_apps": load_initial_apps(config_path), @@ -87,13 +116,13 @@ def build_install_metadata(library_root: Path, config_path: Path) -> dict[str, o if env_path.exists(): env_values = load_env_values(env_path) - app_metadata["settings"] = { - key: value - for key, value in env_values.items() - if key.startswith("W9_") and key.endswith("_SET") - } + app_metadata["settings"] = get_install_settings(env_values) app_metadata["is_web_app"] = "W9_URL" in env_values + profiles = discover_install_profiles(app_dir) + if profiles: + app_metadata["profiles"] = profiles + apps_metadata[app_key] = app_metadata manifest["apps"] = apps_metadata From c8f75b6ffaa5aa1d473d7c158589bfb727e5bffc Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 14 Aug 2026 16:59:33 +0800 Subject: [PATCH 02/11] feat: support external database install profiles --- apphub/requirements.txt | 3 +- apphub/src/api/v1/routers/app.py | 16 +- apphub/src/schemas/appAvailable.py | 1 + apphub/src/schemas/appInstall.py | 5 +- apphub/src/services/app_manager.py | 33 +- apphub/src/services/common_check.py | 31 +- apphub/src/services/install_profile.py | 120 ++++++- apphub/tests/test_install_profiles.py | 105 +++++- .../src/features/app-store/app-store-model.ts | 2 + .../src/features/app-store/app-store-page.tsx | 72 +++- .../features/app-store/use-app-store-apps.ts | 2 + console/src/shared/i18n/resources.ts | 4 +- .../scripts/platform-sync-runtime-assets.py | 17 +- docs/wordpress-external-mysql-pilot.md | 339 ++++++++++++++++++ scripts/generate_appstore_install_metadata.py | 17 +- 15 files changed, 699 insertions(+), 68 deletions(-) create mode 100644 docs/wordpress-external-mysql-pilot.md diff --git a/apphub/requirements.txt b/apphub/requirements.txt index de69ba977..6cdb13082 100755 --- a/apphub/requirements.txt +++ b/apphub/requirements.txt @@ -14,4 +14,5 @@ tenacity aiodocker paramiko python-multipart -PyMySQL \ No newline at end of file +PyMySQL +psycopg2-binary \ No newline at end of file diff --git a/apphub/src/api/v1/routers/app.py b/apphub/src/api/v1/routers/app.py index cd6c4c652..039f385c0 100755 --- a/apphub/src/api/v1/routers/app.py +++ b/apphub/src/api/v1/routers/app.py @@ -12,7 +12,7 @@ from src.schemas.appCatalog import AppCatalogResponse from src.schemas.appComposeInstall import ComposeInstallAcceptedResponse, ComposeInstallRequest, ComposeValidationRequest, ComposeValidationResponse from src.schemas.appInstallAcceptedResponse import AppInstallAcceptedResponse -from src.schemas.appInstall import ExternalMySQLConnectionTestRequest, appInstall +from src.schemas.appInstall import ExternalDatabaseConnectionTestRequest, appInstall from src.schemas.appPhpInfo import AppPhpInfoResponse from src.schemas.appPhpMigration import AppPhpMigrationRequest from src.schemas.appResponse import AppResponse @@ -364,25 +364,27 @@ async def apps_install( @router.post( - "/apps/install/external-mysql/test-connection", - summary="Test External MySQL Connection", + "/apps/install/external-db/test-connection", + summary="Test External Database Connection", responses={ 200: {"model": dict}, 400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}, }, ) -def test_external_mysql_install_connection(payload: ExternalMySQLConnectionTestRequest): - from src.services.install_profile import test_external_mysql_connection +def test_external_database_install_connection(payload: ExternalDatabaseConnectionTestRequest): + from src.services.common_check import validate_external_database_version + from src.services.install_profile import validate_external_database_connection - test_external_mysql_connection( + actual_type = validate_external_database_connection( payload.host, payload.port, payload.database_name, payload.username, payload.password, ) - return {"status": "success"} + validate_external_database_version(payload.app_name, payload.app_version, actual_type.database_type, actual_type.version) + return {"status": "success", "database_type": actual_type.database_type} @router.post( diff --git a/apphub/src/schemas/appAvailable.py b/apphub/src/schemas/appAvailable.py index d00054ca0..badc703d0 100755 --- a/apphub/src/schemas/appAvailable.py +++ b/apphub/src/schemas/appAvailable.py @@ -16,4 +16,5 @@ class AppAvailableResponse(BaseModel): storage: Optional[int] = Field(None, description="Storage(GB)",example=1) logo: Dict[str, str] = Field(None, description="Logo",example={"imageurl": "https://libs.websoft9.com/Websoft9/logo/product/gogs-websoft9.png"}) catalogCollection: Dict[str, Any] = Field(None, description="Catalog Collection", example={"items": [{"key": "repository", "title": "Code Repository","catalogCollection": {"items": [{"key": "itdeveloper", "title": "IT Developer"}]}}]}) + externalDB: Optional[Dict[str, Dict[str, List[str]]]] = Field(None, description="External database compatibility by application version") \ No newline at end of file diff --git a/apphub/src/schemas/appInstall.py b/apphub/src/schemas/appInstall.py index 3938c8bbf..b91ef4d86 100755 --- a/apphub/src/schemas/appInstall.py +++ b/apphub/src/schemas/appInstall.py @@ -77,7 +77,10 @@ def validate_domain_names(cls, v,values): return v -class ExternalMySQLConnectionTestRequest(BaseModel): +class ExternalDatabaseConnectionTestRequest(BaseModel): + app_name: str = Field(..., min_length=1) + app_version: str = Field(..., min_length=1) + profile: str = Field(..., min_length=1) host: str = Field(..., min_length=1) port: int = Field(..., ge=1, le=65535) database_name: str = Field(..., min_length=1) diff --git a/apphub/src/services/app_manager.py b/apphub/src/services/app_manager.py index 09addc78c..ae1a99384 100644 --- a/apphub/src/services/app_manager.py +++ b/apphub/src/services/app_manager.py @@ -35,7 +35,7 @@ from src.core.logger import logger from src.services.integration_credentials import IntegrationCredentialProvider from src.services.proxy_manager import ProxyManager -from src.services.install_profile import get_port_check_settings, materialize_profile_template +from src.services.install_profile import get_port_check_settings, is_external_database_profile, materialize_profile_template, validate_external_database_connection from src.utils.async_utils import AsyncWrapper from src.utils.file_manager import FileHelper from src.utils.password_generator import PasswordGenerator @@ -690,6 +690,19 @@ def get_available_apps(self, locale: str): item["settings"] = {} item["is_web_app"] = False + for item in data: + app_key = str(item.get("key") or "").strip() + if not app_key: + continue + variables_path = os.path.join(app_lib_path, app_key, "variables.json") + try: + with open(variables_path, encoding="utf-8") as handle: + external_database_metadata = json.load(handle).get("externalDB") + if isinstance(external_database_metadata, dict): + item["externalDB"] = external_database_metadata + except (OSError, json.JSONDecodeError): + continue + data = [self._normalize_available_app_media(item, normalized_locale) for item in data if isinstance(item, dict)] # 缓存结果 @@ -788,8 +801,9 @@ def create_installation_tracking(self, app_install: appInstall) -> Tuple[str, st # install requests see them before Docker containers are actually started. reserved_ports: set = set() try: - if app_install.profile != "external-mysql": - library_path = ConfigManager("system.ini").get_value("docker_library", "path") + library_path = ConfigManager("system.ini").get_value("docker_library", "path") + app_directory = os.path.join(library_path, app_install.app_name) + if not is_external_database_profile(app_directory, app_install.profile): env_path = os.path.join(library_path, app_install.app_name, ".env") else: env_path = None @@ -807,7 +821,7 @@ def create_installation_tracking(self, app_install: appInstall) -> Tuple[str, st pass except Exception as _e: logger.warning(f"Port reservation: could not read template .env: {_e}") - port_check_settings = get_port_check_settings(app_install.profile, app_install.settings) + port_check_settings = get_port_check_settings(app_install.profile, app_install.settings, app_directory) if port_check_settings: for _key, _val in port_check_settings.items(): if 'PORT_SET' in _key: @@ -1301,6 +1315,7 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app # The source directory. library_path = ConfigManager("system.ini").get_value("docker_library", "path") local_path = f"{library_path}/{app_name}" + uses_external_database = is_external_database_profile(local_path, profile) # Create a temporary directory. app_tmp_dir = "/tmp" @@ -1354,6 +1369,16 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app for key, value in settings.items(): envHelper.set_value(key, value) + if uses_external_database: + connection = validate_external_database_connection( + envHelper.get_value("W9_DB_HOST_SET"), + envHelper.get_value("W9_DB_PORT_SET"), + envHelper.get_value("W9_DB_NAME_SET"), + envHelper.get_value("W9_DB_USER_SET"), + envHelper.get_value("W9_DB_PASSWORD_SET"), + ) + envHelper.set_value("W9_DB_EXPOSE", connection.database_type) + # Verify the app is web app is_web_app = envHelper.get_value("W9_URL") # url_with_port = envHelper.get_value("W9_URL_WITH_PORT") diff --git a/apphub/src/services/common_check.py b/apphub/src/services/common_check.py index b4bccd6c4..351676c0a 100755 --- a/apphub/src/services/common_check.py +++ b/apphub/src/services/common_check.py @@ -10,7 +10,7 @@ from src.services.proxy_manager import ProxyManager from src.services.app_status import appInstalling,appInstallingError from src.services.product_metadata import read_product_edition -from src.services.install_profile import get_port_check_settings, test_external_mysql_connection, validate_profile_settings +from src.services.install_profile import get_port_check_settings, is_external_database_profile, matches_external_database_version, validate_external_database_connection, validate_profile_settings def _get_host_bound_ports() -> set: @@ -155,6 +155,25 @@ def check_appName_and_appVersion(app_name:str, app_version:str): logger.error(f"When install app:{app_name}, validate app_name and app_version error:{e}") raise CustomException() + +def validate_external_database_version(app_name: str, app_version: str, database_type: str, actual_version: tuple[int, ...]) -> None: + if app_version == "latest" or not actual_version: + return + + library_path = ConfigManager("system.ini").get_value("docker_library", "path") + try: + with open(os.path.join(library_path, app_name, "variables.json"), encoding="utf-8") as handle: + compatibility = json.load(handle).get("externalDB", {}).get(app_version, {}).get(database_type, []) + except (OSError, json.JSONDecodeError, AttributeError): + return + + if not isinstance(compatibility, list): + return + + matched = matches_external_database_version(actual_version, compatibility) + if matched is False: + raise CustomException(400, "External Database Version Mismatch", "The connected database version is not supported by this application version.") + def check_appId(app_id:str,endpointId:int,giteaManager:GiteaManager,portainerManager:PortainerManager): """ Check the app_id is exists in gitea and portainer @@ -297,15 +316,17 @@ def install_validate(appInstall:appInstall,endpointId:int): appInstall.profile, appInstall.settings, ) - if appInstall.profile == "external-mysql": + app_directory = os.path.join(library_path, app_name) + if is_external_database_profile(app_directory, appInstall.profile): settings = appInstall.settings or {} - test_external_mysql_connection( + connection = validate_external_database_connection( settings["W9_DB_HOST_SET"], settings["W9_DB_PORT_SET"], settings["W9_DB_NAME_SET"], settings["W9_DB_USER_SET"], settings["W9_DB_PASSWORD_SET"], ) + validate_external_database_version(app_name, app_version, connection.database_type, connection.version) # Check the app_id is exists in gitea and portainer check_appId(app_id, endpointId, giteaManager, portainerManager) @@ -319,8 +340,8 @@ def install_validate(appInstall:appInstall,endpointId:int): # Check the apps number is exceed the maximum number of apps check_apps_number(endpointId) - port_check_settings = get_port_check_settings(appInstall.profile, appInstall.settings) - check_port_conflicts(port_check_settings, None if appInstall.profile == "external-mysql" else app_name) + port_check_settings = get_port_check_settings(appInstall.profile, appInstall.settings, app_directory) + check_port_conflicts(port_check_settings, None if is_external_database_profile(app_directory, appInstall.profile) else app_name) except CustomException as e: raise e except Exception as e: diff --git a/apphub/src/services/install_profile.py b/apphub/src/services/install_profile.py index 68c8da941..624093309 100644 --- a/apphub/src/services/install_profile.py +++ b/apphub/src/services/install_profile.py @@ -2,6 +2,7 @@ import re import shutil +from dataclasses import dataclass from pathlib import Path from src.core.exception import CustomException @@ -9,7 +10,7 @@ _PROFILE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") _PROFILE_COMPOSE_PATTERN = re.compile(r"^docker-compose\.([a-z0-9][a-z0-9-]*)\.yml$") -EXTERNAL_MYSQL_CONNECTION_SETTING_KEYS = frozenset({ +EXTERNAL_DATABASE_CONNECTION_SETTING_KEYS = frozenset({ "W9_DB_HOST_SET", "W9_DB_PORT_SET", "W9_DB_NAME_SET", @@ -18,13 +19,19 @@ }) -def get_port_check_settings(profile: str | None, settings: dict | None) -> dict: - if profile != "external-mysql": +@dataclass(frozen=True) +class ExternalDatabaseConnection: + database_type: str + version: tuple[int, ...] + + +def get_port_check_settings(profile: str | None, settings: dict | None, app_directory: str | Path | None = None) -> dict: + if not is_external_database_profile(app_directory, profile): return settings or {} return { key: value for key, value in (settings or {}).items() - if key not in EXTERNAL_MYSQL_CONNECTION_SETTING_KEYS + if key not in EXTERNAL_DATABASE_CONNECTION_SETTING_KEYS } @@ -53,6 +60,21 @@ def get_profile_template(app_directory: str | Path, profile: str) -> tuple[Path, return compose_path, env_path +def is_external_database_profile(app_directory: str | Path | None, profile: str | None) -> bool: + if app_directory is None or profile is None: + return False + + _, env_path = get_profile_template(app_directory, profile) + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == "W9_DATABASE_MODE": + return value.strip().strip("\"'") == "external" + return False + + def validate_profile_settings(app_directory: str | Path, profile: str | None, settings: dict | None) -> None: if profile is None: return @@ -66,23 +88,23 @@ def validate_profile_settings(app_directory: str | Path, profile: str | None, se raise CustomException(400, "Invalid Request", "Profile settings must be strings.") -def test_external_mysql_connection(host: str, port: int | str, database_name: str, username: str, password: str) -> None: +def validate_external_database_connection(host: str, port: int | str, database_name: str, username: str, password: str) -> ExternalDatabaseConnection: if not all(isinstance(value, str) and value.strip() for value in (host, database_name, username, password)): - raise CustomException(400, "Invalid Request", "External MySQL connection information is required.") + raise CustomException(400, "Invalid Request", "External database connection information is required.") if "://" in host or any(character.isspace() for character in host): - raise CustomException(400, "Invalid Request", "External MySQL host is invalid.") + raise CustomException(400, "Invalid Request", "External database host is invalid.") try: normalized_port = int(port) except (TypeError, ValueError) as exc: - raise CustomException(400, "Invalid Request", "External MySQL port is invalid.") from exc + raise CustomException(400, "Invalid Request", "External database port is invalid.") from exc if normalized_port < 1 or normalized_port > 65535: - raise CustomException(400, "Invalid Request", "External MySQL port is invalid.") + raise CustomException(400, "Invalid Request", "External database port is invalid.") try: import pymysql except ImportError as exc: - raise CustomException(503, "External MySQL Connection Unavailable", "The MySQL connection test is not available.") from exc + raise CustomException(503, "External Database Connection Unavailable", "The database connection test is not available.") from exc try: connection = pymysql.connect( @@ -99,10 +121,84 @@ def test_external_mysql_connection(host: str, port: int | str, database_name: st try: with connection.cursor() as cursor: cursor.execute("SELECT 1") + cursor.execute("SELECT VERSION()") + version_row = cursor.fetchone() finally: connection.close() - except pymysql.MySQLError as exc: - raise CustomException(400, "External MySQL Connection Failed", "Unable to connect to the specified MySQL database.") from exc + except pymysql.MySQLError: + return _validate_postgresql_connection(host, normalized_port, database_name, username, password) + + version_text = str(version_row[0] if isinstance(version_row, (tuple, list)) else version_row or "").lower() + actual_type = "mariadb" if "mariadb" in version_text else "mysql" + version_numbers = re.findall(r"\d+(?:\.\d+)*", version_text) + version = version_numbers[-1] if actual_type == "mariadb" else version_numbers[0] if version_numbers else "" + return ExternalDatabaseConnection(actual_type, tuple(int(part) for part in version.split(".") if part)) + + +def _validate_postgresql_connection(host: str, port: int | str, database_name: str, username: str, password: str) -> ExternalDatabaseConnection: + if not all(isinstance(value, str) and value.strip() for value in (host, database_name, username, password)): + raise CustomException(400, "Invalid Request", "External database connection information is required.") + if "://" in host or any(character.isspace() for character in host): + raise CustomException(400, "Invalid Request", "External database host is invalid.") + try: + normalized_port = int(port) + except (TypeError, ValueError) as exc: + raise CustomException(400, "Invalid Request", "External database port is invalid.") from exc + if normalized_port < 1 or normalized_port > 65535: + raise CustomException(400, "Invalid Request", "External database port is invalid.") + + try: + import psycopg2 + except ImportError as exc: + raise CustomException(503, "External Database Connection Unavailable", "The database connection test is not available.") from exc + + try: + connection = psycopg2.connect( + host=host, + port=normalized_port, + user=username, + password=password, + dbname=database_name, + connect_timeout=8, + ) + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.execute("SHOW server_version_num") + version_row = cursor.fetchone() + finally: + connection.close() + except psycopg2.Error as exc: + raise CustomException(400, "External Database Connection Failed", "Unable to connect to the specified database.") from exc + version_number = int(version_row[0] if isinstance(version_row, (tuple, list)) else version_row) + return ExternalDatabaseConnection("postgresql", (version_number // 10000, (version_number // 100) % 100, version_number % 100)) + + +def matches_external_database_version(actual_version: tuple[int, ...], compatibility: list) -> bool | None: + parsed_rules = 0 + for rule in compatibility: + if not isinstance(rule, str): + continue + versions = [tuple(int(part) for part in value.split(".")) for value in re.findall(r"\d+(?:\.\d+)*", rule)] + if not versions: + continue + parsed_rules += 1 + if "+" in rule and _compare_versions(actual_version, versions[0]) >= 0: + return True + if "+" not in rule and any(_version_matches_prefix(actual_version, version) for version in versions): + return True + return False if parsed_rules else None + + +def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int: + length = max(len(left), len(right)) + normalized_left = left + (0,) * (length - len(left)) + normalized_right = right + (0,) * (length - len(right)) + return (normalized_left > normalized_right) - (normalized_left < normalized_right) + + +def _version_matches_prefix(actual: tuple[int, ...], expected: tuple[int, ...]) -> bool: + return len(actual) >= len(expected) and actual[:len(expected)] == expected def materialize_profile_template(workspace_directory: str | Path, profile: str | None) -> None: diff --git a/apphub/tests/test_install_profiles.py b/apphub/tests/test_install_profiles.py index f4b74e2dd..922cda56b 100644 --- a/apphub/tests/test_install_profiles.py +++ b/apphub/tests/test_install_profiles.py @@ -9,9 +9,13 @@ from src.core.exception import CustomException from src.services.install_profile import ( + _compare_versions, + _version_matches_prefix, get_port_check_settings, + matches_external_database_version, + is_external_database_profile, materialize_profile_template, - test_external_mysql_connection as check_external_mysql_connection, + validate_external_database_connection, validate_profile_settings, ) @@ -30,12 +34,15 @@ def _profile_template(app_dir: Path) -> None: app_dir.mkdir() (app_dir / "docker-compose.yml").write_text("services:\n mysql: {}\n", encoding="utf-8") (app_dir / ".env").write_text("W9_POWER_PASSWORD=\n", encoding="utf-8") - (app_dir / "docker-compose.external-mysql.yml").write_text( + (app_dir / "docker-compose.external-db.yml").write_text( "services:\n wordpress:\n ports:\n - $W9_HTTP_PORT_SET:80\n", encoding="utf-8", ) - (app_dir / ".env.external-mysql").write_text( - "\n".join(f"{key}={value if key != 'W9_DB_PASSWORD_SET' else ''}" for key, value in PROFILE_SETTINGS.items()), + (app_dir / ".env.external-db").write_text( + "\n".join([ + *(f"{key}={value if key != 'W9_DB_PASSWORD_SET' else ''}" for key, value in PROFILE_SETTINGS.items()), + "W9_DATABASE_MODE=external", + ]), encoding="utf-8", ) @@ -43,10 +50,10 @@ def _profile_template(app_dir: Path) -> None: def test_profile_validation_uses_the_local_template_whitelist(tmp_path): _profile_template(tmp_path / "wordpress") - validate_profile_settings(tmp_path / "wordpress", "external-mysql", PROFILE_SETTINGS) + validate_profile_settings(tmp_path / "wordpress", "external-db", PROFILE_SETTINGS) with pytest.raises(CustomException) as exc_info: - validate_profile_settings(tmp_path / "wordpress", "external-mysql", {**PROFILE_SETTINGS, "UNAPPROVED": "value"}) + validate_profile_settings(tmp_path / "wordpress", "external-db", {**PROFILE_SETTINGS, "UNAPPROVED": "value"}) assert exc_info.value.status_code == 400 assert "database-secret" not in exc_info.value.details @@ -58,19 +65,23 @@ def test_profile_validation_rejects_settings_from_a_different_template(tmp_path) with pytest.raises(CustomException) as exc_info: validate_profile_settings( tmp_path / "wordpress", - "external-mysql", + "external-db", {"W9_HTTP_PORT_SET": "9001"}, ) assert exc_info.value.status_code == 400 -def test_external_mysql_connection_settings_are_excluded_from_port_checks(): - assert get_port_check_settings("external-mysql", PROFILE_SETTINGS) == {"W9_HTTP_PORT_SET": "9001"} +def test_external_database_connection_settings_are_excluded_from_port_checks(tmp_path): + app_dir = tmp_path / "wordpress" + _profile_template(app_dir) + + assert is_external_database_profile(app_dir, "external-db") is True + assert get_port_check_settings("external-db", PROFILE_SETTINGS, app_dir) == {"W9_HTTP_PORT_SET": "9001"} assert get_port_check_settings(None, PROFILE_SETTINGS) == PROFILE_SETTINGS -def test_external_mysql_connection_test_is_read_only(monkeypatch): +def test_external_database_connection_test_is_read_only(monkeypatch): calls = [] class FakeCursor: @@ -83,6 +94,9 @@ def __exit__(self, exc_type, exc_value, traceback): def execute(self, statement): calls.append(("execute", statement)) + def fetchone(self): + return ("8.0.36",) + class FakeConnection: def cursor(self): return FakeCursor() @@ -100,13 +114,15 @@ def connect(**kwargs): monkeypatch.setitem(sys.modules, "pymysql", FakePyMySQL) - check_external_mysql_connection( + result = validate_external_database_connection( host="mysql.example.internal", port=3306, database_name="wordpress_demo", username="wordpress_user", password="database-secret", ) + assert result.database_type == "mysql" + assert result.version == (8, 0, 36) assert calls == [ ("connect", { @@ -121,22 +137,83 @@ def connect(**kwargs): "autocommit": True, }), ("execute", "SELECT 1"), + ("execute", "SELECT VERSION()"), ("close",), ] +def test_external_database_connection_falls_back_to_postgresql(monkeypatch): + class FakePyMySQL: + class MySQLError(Exception): + pass + + @staticmethod + def connect(**kwargs): + raise FakePyMySQL.MySQLError() + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return None + + def execute(self, statement): + return None + + def fetchone(self): + return (160003,) + + class FakeConnection: + def cursor(self): + return FakeCursor() + + def close(self): + return None + + class FakePsycopg2: + Error = Exception + + @staticmethod + def connect(**kwargs): + return FakeConnection() + + monkeypatch.setitem(sys.modules, "pymysql", FakePyMySQL) + monkeypatch.setitem(sys.modules, "psycopg2", FakePsycopg2) + + result = validate_external_database_connection( + host="postgres.example.internal", + port=5432, + database_name="wordpress_demo", + username="wordpress_user", + password="database-secret", + ) + + assert result.database_type == "postgresql" + assert result.version == (16, 0, 3) + + def test_profile_materialization_preserves_the_selected_template(tmp_path): workspace = tmp_path / "wordpress" _profile_template(workspace) (workspace / ".env.example").write_text("DOCUMENTATION_ONLY=true\n", encoding="utf-8") - materialize_profile_template(workspace, "external-mysql") + materialize_profile_template(workspace, "external-db") assert "wordpress" in (workspace / "docker-compose.yml").read_text(encoding="utf-8") assert not list(workspace.glob("docker-compose.*.yml")) - assert not (workspace / ".env.external-mysql").exists() + assert not (workspace / ".env.external-db").exists() assert (workspace / ".env.example").exists() env_content = (workspace / ".env").read_text(encoding="utf-8") assert "W9_DB_USER_SET=wordpress_user" in env_content assert "W9_DB_PASSWORD_SET=" in env_content - assert "W9_POWER_PASSWORD" not in env_content \ No newline at end of file + assert "W9_POWER_PASSWORD" not in env_content + + +def test_database_version_comparison_handles_minimum_and_fixed_major_versions(): + assert _compare_versions((10, 11, 2), (10, 11)) > 0 + assert _compare_versions((8, 0), (8, 0, 0)) == 0 + assert _version_matches_prefix((17, 0, 4), (17,)) is True + assert _version_matches_prefix((14, 9), (15,)) is False + assert matches_external_database_version((10, 11, 2), ["MariaDB 10.11+"]) is True + assert matches_external_database_version((14, 9), ["PostgreSQL 15, 16, 17"]) is False \ No newline at end of file diff --git a/console/src/features/app-store/app-store-model.ts b/console/src/features/app-store/app-store-model.ts index 72dfff102..51d070254 100644 --- a/console/src/features/app-store/app-store-model.ts +++ b/console/src/features/app-store/app-store-model.ts @@ -28,6 +28,7 @@ export type AppStoreDistribution = { export type AppStoreInstallProfile = { settings?: Record + is_external_database?: boolean } export type AppStoreApp = { @@ -50,6 +51,7 @@ export type AppStoreApp = { settings?: Record is_web_app?: boolean profiles?: Record + externalDB?: Record> production?: boolean relatedAppsCollection?: { items?: { key?: string; trademark?: string }[] diff --git a/console/src/features/app-store/app-store-page.tsx b/console/src/features/app-store/app-store-page.tsx index 76e534be4..de44433e3 100644 --- a/console/src/features/app-store/app-store-page.tsx +++ b/console/src/features/app-store/app-store-page.tsx @@ -134,7 +134,7 @@ function getInstallPortsValidationMessage( const usedPorts = new Set() for (const [key, rawValue] of Object.entries(settings)) { - if (!key.toLowerCase().includes('port') || (profile === 'external-mysql' && key === 'W9_DB_PORT_SET')) { + if (!key.toLowerCase().includes('port') || (profile === 'external-db' && key === 'W9_DB_PORT_SET')) { continue } @@ -350,10 +350,13 @@ async function installApp( return response.json().catch(() => null) as Promise } -async function testExternalMySQLConnection(settings: Record) { - return requestJson<{ status: string }>('/api/apps/install/external-mysql/test-connection', { +async function testExternalDatabaseConnection(app: AppStoreApp, version: string, profile: string, settings: Record) { + return requestJson<{ status: string; database_type: string }>('/api/apps/install/external-db/test-connection', { method: 'POST', body: JSON.stringify({ + app_name: app.key, + app_version: version, + profile, host: settings.W9_DB_HOST_SET, port: Number(settings.W9_DB_PORT_SET), database_name: settings.W9_DB_NAME_SET, @@ -1016,8 +1019,29 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = const selectedProfileTemplateSettings = selectedInstallProfile ? selectedAppProfiles[selectedInstallProfile]?.settings ?? {} : {} - const isExternalMySQLProfile = selectedInstallProfile === 'external-mysql' - const externalMySQLSettingKeys = [ + const selectedAppExternalDatabases = useMemo(() => { + const selectedAppKey = (selectedApp?.key ?? '').toLowerCase() + const canonicalSelectedApp = selectedAppKey + ? apps.find((app) => (app.key ?? '').toLowerCase() === selectedAppKey) + : undefined + return canonicalSelectedApp?.externalDB ?? selectedApp?.externalDB ?? {} + }, [apps, selectedApp]) + const externalDatabaseSupport = useMemo(() => { + const compatibility = selectedVersion === 'latest' + ? Object.values(selectedAppExternalDatabases).find((value) => typeof value === 'object' && !Array.isArray(value)) + : selectedAppExternalDatabases[selectedVersion] + if (!compatibility || Array.isArray(compatibility)) { + return [] + } + return Object.entries(compatibility).flatMap(([databaseType, versions]) => { + const displayName = ({ mariadb: 'MariaDB', mysql: 'MySQL', postgresql: 'PostgreSQL' } as Record)[databaseType] ?? databaseType + return Array.isArray(versions) && versions.length > 0 ? [`${displayName} ${versions.join(' / ')}`] : [displayName] + }) + }, [selectedAppExternalDatabases, selectedVersion]) + const isExternalDatabaseProfile = Boolean( + selectedInstallProfile && selectedAppProfiles[selectedInstallProfile]?.is_external_database, + ) + const externalDatabaseSettingKeys = [ 'W9_DB_HOST_SET', 'W9_DB_PORT_SET', 'W9_DB_NAME_SET', @@ -1035,9 +1059,9 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = [installSettings, profileInstallSettings, selectedInstallProfile, selectedProfileTemplateSettings], ) const sharedInstallSettings = Object.entries(effectiveInstallSettings).filter( - ([key]) => !isExternalMySQLProfile || !externalMySQLSettingKeys.includes(key), + ([key]) => !isExternalDatabaseProfile || !externalDatabaseSettingKeys.includes(key), ) - const displayedProfileInstallSettings = externalMySQLSettingKeys.map( + const displayedProfileInstallSettings = externalDatabaseSettingKeys.map( (key) => [key, effectiveInstallSettings[key] ?? ''] as [string, string], ) const isChineseLocale = resolvedLocale.toLowerCase().startsWith('zh') @@ -1532,7 +1556,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = return } - if (selectedInstallProfile === 'external-mysql') { + if (isExternalDatabaseProfile) { const databaseErrors = getExternalDatabaseValidationErrors(effectiveInstallSettings) if (Object.keys(databaseErrors).length > 0) { setInstallFieldErrors({ settings: databaseErrors }) @@ -1587,7 +1611,7 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = if (/Exceed the maximum number of apps/i.test(message)) { message = t('appStorePage.install.feedback.maxApps') } - if (selectedInstallProfile === 'external-mysql' && /Unable to connect to the specified MySQL database\.?/i.test(message)) { + if (isExternalDatabaseProfile && /Unable to connect to the specified database\.?/i.test(message)) { message = t('appStorePage.install.databaseConnection.failed') } // Detect port conflict error from backend and show i18n-friendly message @@ -3500,14 +3524,18 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = slotProps={{ select: { MenuProps: installDialogSelectMenuProps, displayEmpty: true } }} > {isChineseLocale ? '系统内置' : 'System built-in'} - {Object.keys(selectedAppProfiles).map((profile) => ( - - {profile === 'external-mysql' ? (isChineseLocale ? '自定义' : 'Custom') : profile.replace(/-/g, ' ')} - - ))} + {Object.entries(selectedAppProfiles) + .filter(([, metadata]) => !metadata.is_external_database || externalDatabaseSupport.length > 0) + .map(([profile, metadata]) => ( + + {metadata.is_external_database + ? `${isChineseLocale ? '自定义' : 'Custom'} (${externalDatabaseSupport.join(' / ')})` + : profile.replace(/-/g, ' ')} + + ))} - {isExternalMySQLProfile ? ( + {isExternalDatabaseProfile ? ( { + const profile = selectedInstallProfile + if (!profile) { + return + } setInstallFieldErrors((currentValue) => ({ ...currentValue, settings: currentValue.settings ? { ...currentValue.settings, [key]: undefined } : currentValue.settings, @@ -3552,8 +3584,8 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = setInstallError(null) setProfileInstallSettings((currentValue) => ({ ...currentValue, - [selectedInstallProfile]: { - ...(currentValue[selectedInstallProfile] ?? selectedProfileTemplateSettings), + [profile]: { + ...(currentValue[profile] ?? selectedProfileTemplateSettings), [key]: event.target.value, }, })) @@ -3600,9 +3632,13 @@ export function AppStorePage({ lockedInstallSource, hideInstallSourceSelector = setInstallError(null) setInstallFeedback(null) try { - await testExternalMySQLConnection(effectiveInstallSettings) + if (!selectedInstallProfile) { + return + } + await testExternalDatabaseConnection(selectedApp, selectedVersion, selectedInstallProfile, effectiveInstallSettings) setInstallFeedback({ severity: 'success', message: t('appStorePage.install.databaseConnection.success') }) } catch (error) { + setInstallToastRevision((currentValue) => currentValue + 1) setInstallError(t('appStorePage.install.databaseConnection.failed')) } finally { setIsTestingDatabase(false) diff --git a/console/src/features/app-store/use-app-store-apps.ts b/console/src/features/app-store/use-app-store-apps.ts index 2f25aa45d..bbf4c03b6 100644 --- a/console/src/features/app-store/use-app-store-apps.ts +++ b/console/src/features/app-store/use-app-store-apps.ts @@ -11,6 +11,7 @@ type AppStoreInstallMetadata = { settings?: Record is_web_app?: boolean profiles?: Record + externalDB?: Record> } type AppStoreInstallMetadataManifest = { @@ -56,6 +57,7 @@ function mergeInstallMetadata(apps: AppStoreApp[], metadataManifest: AppStoreIns settings: installMetadata.settings ?? app.settings ?? {}, is_web_app: installMetadata.is_web_app ?? app.is_web_app ?? false, profiles: installMetadata.profiles ?? app.profiles, + externalDB: installMetadata.externalDB ?? app.externalDB, } }) } diff --git a/console/src/shared/i18n/resources.ts b/console/src/shared/i18n/resources.ts index ecb2b3c90..5ba7d9fe4 100644 --- a/console/src/shared/i18n/resources.ts +++ b/console/src/shared/i18n/resources.ts @@ -767,7 +767,7 @@ const rawShellResources = { test: 'Test connection', testing: 'Testing...', success: 'Database connection succeeded', - failed: 'Unable to connect to the specified MySQL database.', + failed: 'Unable to connect to the specified database.', }, disableDomain: 'Disable', enableDomain: 'Enable', @@ -3122,7 +3122,7 @@ const rawShellResources = { test: '测试连接', testing: '测试中...', success: '数据库连接正常', - failed: '无法连接到指定的 MySQL 数据库。', + failed: '无法连接到指定数据库。', }, disableDomain: '禁用', enableDomain: '启用', diff --git a/docker/scripts/platform-sync-runtime-assets.py b/docker/scripts/platform-sync-runtime-assets.py index fda7ac569..496097b9a 100644 --- a/docker/scripts/platform-sync-runtime-assets.py +++ b/docker/scripts/platform-sync-runtime-assets.py @@ -971,9 +971,13 @@ def discover_install_profiles(app_dir: Path) -> dict[str, dict[str, object]]: continue try: - profiles[profile_name] = { - "settings": get_install_settings(load_env_values(env_path)), + env_values = load_env_values(env_path) + profile_metadata: dict[str, object] = { + "settings": get_install_settings(env_values), } + if env_values.get("W9_DATABASE_MODE") == "external": + profile_metadata["is_external_database"] = True + profiles[profile_name] = profile_metadata except Exception as exc: log(f"[platform-assets] failed to read {env_path}: {exc}") @@ -1010,6 +1014,15 @@ def build_app_store_install_metadata(library_root: Path, config_path: Path) -> d except Exception as exc: log(f"[platform-assets] failed to read {env_path}: {exc}") + variables_path = app_dir / "variables.json" + if variables_path.exists(): + try: + external_database_metadata = json.loads(variables_path.read_text(encoding="utf-8")).get("externalDB") + if isinstance(external_database_metadata, dict): + app_metadata["externalDB"] = external_database_metadata + except (json.JSONDecodeError, OSError) as exc: + log(f"[platform-assets] failed to read {variables_path}: {exc}") + profiles = discover_install_profiles(app_dir) if profiles: app_metadata["profiles"] = profiles diff --git a/docs/wordpress-external-mysql-pilot.md b/docs/wordpress-external-mysql-pilot.md new file mode 100644 index 000000000..f80cc50ee --- /dev/null +++ b/docs/wordpress-external-mysql-pilot.md @@ -0,0 +1,339 @@ +# WordPress 外部 MySQL 试点方案 + +## 目标 + +在应用商店为**新安装**的 WordPress 提供数据库来源选择: + +- **内置 MySQL**:保持当前 WordPress 与 MySQL 容器一起部署的行为不变。 +- **外部 MySQL**:部署不包含 MySQL 服务的 WordPress;使用用户提供的 MySQL-compatible 数据库、数据库名和可访问账号。 + +本试点用于验证完整安装模型。验证完成前,不扩展到 PostgreSQL、Odoo、已安装应用迁移、`/setup` 或云数据库自动创建。 + +## 明确不做的事项 + +- 不修改 `/setup`。 +- 不迁移已安装的 WordPress,也不自动识别或转换其数据库模式。 +- 不自动创建 RDS、VPC、安全组或其他云资源;用户自行准备可访问的数据库服务。 +- 不支持 PostgreSQL、Odoo、任意数据库 URL、复用已有数据库或导入已有应用数据。 +- 不修改现有内置 WordPress 安装行为,也不修改其他应用模板。 +- 卸载应用时不删除外部数据库、外部数据库账号或外部数据库服务。 + +## 支持范围与固定边界 + +首期只支持 WordPress + MySQL 协议:MySQL、MariaDB、Aurora MySQL 均可接入。数据库类型由 WordPress 外部模板固定,用户不需要在表单中选择其他引擎。 + +首期按以下边界实现: + +1. **密码存储**:首期沿用现有应用模板模型。用户提供的数据库用户名和密码保存到该应用私有 Gitea 仓库的 `.env`,Portainer 从该仓库拉取并部署,后续重部署直接复用连接配置。数据库密码不得写入安装日志、安装状态或错误响应;拥有“我的应用”详情访问权限的用户可在详情页掩码查看和复制密码。 +2. **TLS 范围**:首期使用用户名密码认证,不额外配置客户端证书或 WordPress MySQL SSL 参数。数据库地址不区分公网、私网或 Docker 服务名:安装前实际 MySQL 连通性测试成功即可继续;连接失败由安装流程直接返回错误。 + +## 安装界面 + +现有 WordPress 安装表单保留应用名称、访问方式、端口或域名设置。新增如下数据库区块: + +```text +应用数据库 +[ 系统内置 | 自定义 ] + +选择“使用外部 MySQL”后: + +数据库连接信息 +主机地址 [ db.example.internal ] +端口 [ 3306 ] +数据库名称 [ wordpress_myblog ] +数据库用户 [ wordpress_user ] +数据库密码 [ ******** ] [显示/隐藏] [测试连接] +``` + +规则: + +- 用户负责预先创建数据库并提供仅需访问目标数据库的可用账号。 +- 平台不生成 WordPress 数据库账号或密码,也不检查或修改数据库对象。 +- 主机地址可填写 Docker 服务名、宿主机地址、私网 IP、私有 DNS、RDS/Aurora endpoint 或公网域名;平台只以实际 MySQL 连通性测试结果判断能否继续。 +- 不在安装日志、安装状态或错误响应中显示数据库密码;应用详情页仅向拥有现有详情访问权限的用户提供掩码查看和复制。 + +## 外部数据库生命周期 + +1. 校验当前 profile 模板声明的 `W9_*_SET`。 +2. AppHub 使用用户提供的主机、端口、数据库名、用户名和密码进行 MySQL 协议认证,并执行只读 `SELECT 1`。 +3. 成功后复制并规范化 WordPress 外部模板,创建 Gitea 仓库并由 Portainer 部署。WordPress 首次启动时自行创建表。 +4. 将用户提供的连接参数写入最终应用私有 `.env`;不得写入日志、状态或错误响应。 + +平台不执行 `CREATE DATABASE`、`CREATE USER`、`GRANT`、删除数据库、删除账号或其他数据库生命周期操作。失败处理沿用现有应用安装流程:Gitea、Portainer Stack 和本地卷按既有回滚逻辑删除;平台不对外部数据库、外部账号或其数据执行额外删除操作。 + +## 模板与部署契约 + +仅为 WordPress 模板增加: + +```text +apps/wordpress/docker-compose.external-mysql.yml +apps/wordpress/.env.external-mysql +``` + +默认模式固定使用 `docker-compose.yml` 与 `.env`。额外安装模式使用配对命名约定: + +```text +docker-compose.<模式>.yml +.env.<模式> +``` + +因此本试点的模式键为 `external-mysql`。发布工具仅将同时存在的一对文件识别为额外安装模式;未来可以按相同规则增加 `external-postgresql`、`with-redis` 或 `high-availability` 等模式,而不增加每个应用专用的描述文件。 + +`docker-compose.external-mysql.yml` 保留 WordPress 服务、既有支持服务、Websoft9 网络和应用卷,但不包含: + +- `mysql` 服务; +- `mysql_data` 卷; +- `depends_on: mysql`; +- 本地 MySQL 初始化变量。 + +`.env.external-mysql` 保留必需的 `W9_*` 应用元数据。用户在安装页填写的安装阶段字段由该文件中的 `W9_*_SET` 声明: + +```text +W9_DB_HOST_SET= +W9_DB_PORT_SET=3306 +W9_DB_NAME_SET= +W9_DB_USER_SET= +W9_DB_PASSWORD_SET= +``` + +其中 `W9_DB_PASSWORD_SET` 是秘密字段,安装页必须使用密码输入框且不回显。用户负责预先创建数据库并提供可访问该数据库的账号;Websoft9 仅在安装前使用只读 `SELECT 1` 验证 MySQL-compatible 连接,不建库、不建用户或授权。 + +最终 `.env` 使用已有的数据库类型字段与新增的模式标识: + +```text +W9_DATABASE_MODE=external +W9_DB_EXPOSE=mysql +WORDPRESS_DB_HOST +WORDPRESS_DB_NAME +WORDPRESS_DB_USER +WORDPRESS_DB_PASSWORD +``` + +安装时,通用安装模式解析逻辑在“复制模板并生成安装目录”的边界选择 `external-mysql` 源文件,并在镜像拉取、Gitea 推送和 Portainer Stack 创建之前,将结果规范化为应用私有 Gitea 仓库中的: + +```text +docker-compose.yml +.env +``` + +不修改 Portainer、重部署、启停、卸载、镜像拉取和仓库文件发现逻辑以识别第二个 compose 文件名;这些既有链路始终只消费上述规范化文件名。 + +外接模式不使用内置 MySQL 的 `W9_POWER_PASSWORD`。最终 `.env` 保存用户提供的 WordPress 运行凭据;`W9_DATABASE_MODE=external` 标记外接模式,缺少该变量即为现有内置模式;数据库类型复用现有 `W9_DB_EXPOSE=mysql`。此文件属于明文敏感配置,必须保持仓库私有;密码不得出现在日志、安装状态或错误响应,且只在“我的应用”详情中向已授权用户掩码显示。 + +## API 与后端契约 + +扩展安装元数据与安装请求,使其携带受约束的安装模式;当前选中模板中的 `W9_*_SET` 仍通过既有 `settings` 字典提交。 + +```json +{ + "profile": "external-mysql", + "settings": { + "W9_HTTP_PORT_SET": "9001", + "W9_DB_HOST_SET": "db.example.internal", + "W9_DB_PORT_SET": "3306", + "W9_DB_NAME_SET": "wordpress_myblog", + "W9_DB_USER_SET": "wordpress_user", + "W9_DB_PASSWORD_SET": "数据库密码" + } +} +``` + +规则: + +- 默认模式不传 `profile`,继续使用 `docker-compose.yml` 与 `.env`;额外模式必须来自已发布的配对文件,未知模式返回 `400`。 +- WordPress 的 `external-mysql` 模式只支持 MySQL、MariaDB 和 Aurora MySQL;用户必须预先创建目标数据库并提供可访问它的账号。 +- `W9_DB_PASSWORD_SET` 不得进入 Pydantic 校验错误、安装状态、日志、遥测或 API 响应;模板物化后作为 WordPress 运行密码写入私有 Gitea `.env`。 +- 后端仅接受当前选中模板声明的 `W9_*_SET`,并使用既有通用设置写入流程写入最终 `.env`。 +- 后端在创建安装资源前验证外部 MySQL 的账号、密码、数据库可访问性和 MySQL 协议兼容性;不创建数据库、账号或授权。 +- 最终 `.env` 必须写入 `W9_DATABASE_MODE=external` 与 `W9_DB_EXPOSE=mysql`;不得通过主机名推断外部模式。 + +### 安装元数据与通用表单 + +`install metadata JSON` 是安装页面的参数来源。发布和运行时本地回退生成逻辑都必须扫描每个应用目录中的模式文件对,并把模式与其 `W9_*_SET` 字段写入元数据。例如: + +```json +{ + "apps": { + "wordpress": { + "settings": { "W9_HTTP_PORT_SET": "9001" }, + "is_web_app": true, + "profiles": { + "external-mysql": { + "settings": { + "W9_DB_HOST_SET": "", + "W9_DB_PORT_SET": "3306", + "W9_DB_NAME_SET": "", + "W9_DB_USER_SET": "", + "W9_DB_PASSWORD_SET": "" + } + } + } + } + } +} +``` + +前端通用地读取 `profiles`:没有额外模式的应用维持现有表单;存在额外模式时显示模式选择器,并以所选模式的 `settings` 完整替换默认模式字段。`external-mysql` 作为平台 MySQL-compatible 协议 profile,将其标准五项连接字段显示为数据库连接卡片并用于连接测试;其他 `W9_*_SET` 仍按通用表单显示。所有匹配 `W9_*_PASSWORD_SET` 的字段按全局命名约定使用密码输入框,并在日志、状态和响应中脱敏;其他秘密类型若未来需要支持,必须先定义同样通用的命名约定。 + +`apps index JSON` 和 `manifest` 不承载模式或字段定义:它们继续发布整个应用 bundle 并校验 checksum。模式文件随 bundle 下载后,由安装服务按用户选择的 `profile` 物化。 + +## 密码存储、详情与重部署 + +外接模式不使用 `W9_POWER_PASSWORD`,应用私有 Gitea 仓库 `.env` 保存用户提供的 `W9_DB_*_SET` 与对应的 `WORDPRESS_DB_*` 运行连接信息,能够支持 Portainer Git Stack 的创建与重部署。 + +明确边界: + +1. 用户负责预先创建数据库并提供账号;Websoft9 仅做只读连接验证,不创建数据库、账号或授权。WordPress 运行时使用 `WORDPRESS_DB_USER` 与 `WORDPRESS_DB_PASSWORD`。 +2. 私有 `.env` 是明文配置:拥有 Gitea 仓库读取权限、Portainer Stack 配置权限或宿主机 Docker 管理权限的人员可能读取它;这些管理权限必须严格控制。 +3. 数据库密码不得出现在日志、安装进度或错误响应;数据库详情按现有权限模型向已授权用户提供掩码显示和复制。 +4. 用户需要修改外部数据库地址、账号或密码时,更新该应用私有仓库中的 `.env` 后执行既有“重部署”即可;密码变更应先在数据库侧完成。 +5. 彻底卸载会删除应用 Gitea 仓库,因此也会删除 `.env`;外部数据库、外部账号及其数据由用户自行保留和管理。 + +后续可增加加密秘密存储与 `WORDPRESS_DB_PASSWORD_FILE` 注入,作为安全增强;该增强不应改变首期 API 字段、模板选择或重部署流程。 + +停机与重部署语义: + +- 运行中时,应用详情可从主容器环境读取模式标识和 WordPress 运行连接信息。 +- 停机后容器环境不可用,详情服务必须从私有 Gitea `.env` 回读 `W9_DATABASE_MODE`、`W9_DB_EXPOSE` 与 `WORDPRESS_DB_*`。不能因停机而退回为假设存在 `-mysql` 的内置数据库。 +- 重部署不连接或修改外部数据库;Portainer 继续从私有仓库中的规范化 `.env` 读取 WordPress 运行配置。 + +卸载语义: + +- 现有 `purge_data=true` 会删除应用 Gitea 仓库和本地卷;外部数据库、外部账号和数据不由平台额外处理。 +- `.env` 随 Gitea 仓库删除,平台不保证能再次取得原外部数据库凭据;用户必须自行保留外部数据库连接信息。 +- `purge_data=false` 仅停止 Stack 并保留私有仓库和连接配置,可使用既有重部署恢复。 +- `purge_data=true` 以及对非活动应用执行“移除”都会删除私有仓库和 `.env`;UI 必须准确表述“外部数据库资源由用户管理;彻底删除会删除平台保存的连接配置”。 + +## 网络要求 + +安装服务直接以用户输入的主机、端口、数据库名和账号执行 MySQL 连接测试。地址可以是 Docker 服务名、宿主机地址、私网地址或公网地址;测试成功则继续,失败则返回错误。网络路由、防火墙、安全组、数据库监听和部署 Endpoint 的网络可达性均由用户环境负责,Websoft9 不管理或限制这些网络拓扑。 + +## 备份、卸载与升级 + +- 当前 AppHub 备份仅备份 Docker 卷,不包含外部数据库数据。外部模式的创建和恢复备份入口都必须明确提示“数据库备份与恢复由外部数据库服务负责”。 +- 卸载仅按现有行为处理应用 Stack、本地卷、代理和 Gitea 仓库;不得对外部数据库发出删除命令。 +- 平台升级保留应用私有 Gitea 仓库,不应修改外部数据库数据。已安装应用维持内置模式,不自动转换。 +- 外部 WordPress 必须在平台升级后,使用规范化 Gitea `docker-compose.yml` 与 `.env` 成功重部署。 + +## 具体实施设计 + +### 改动范围 + +| 位置 | 改动 | 不改动的行为 | +|---|---|---| +| docker-library `apps/wordpress/` | 增加 `docker-compose.external-mysql.yml`、`.env.external-mysql` | 现有 `docker-compose.yml` 与 `.env` 原样保留 | +| 安装元数据生成与同步 | 自动发现配对的 `docker-compose.<模式>.yml` 与 `.env.<模式>`,写入 `profiles` | 无额外模式的应用维持当前元数据 | +| `apphub/src/schemas/appInstall.py`、校验与安装服务 | 增加受约束的 `profile`,通过既有 `settings` 写入当前模板字段 | 默认模式的请求、端口保留与模板行为不变 | +| `console/src/features/app-store/` | 渲染元数据 `profiles` 的选择器与字段;`external-mysql` 使用标准数据库连接卡片 | 其他 profile 字段仍按通用表单渲染 | +| 我的应用详情、备份和卸载提示 | 识别外接模式;增加准确提示 | 列表、启停与既有重部署接口保持不变 | + +### 请求与数据模型 + +```json +{ + "app_name": "wordpress", + "edition": { "dist": "community", "version": "6.9" }, + "app_id": "myblog", + "proxy_enabled": true, + "domain_names": ["blog.example.com"], + "settings": { + "W9_HTTP_PORT_SET": "9001", + "W9_DB_HOST_SET": "db.internal", + "W9_DB_PORT_SET": "3306", + "W9_DB_NAME_SET": "wordpress_myblog", + "W9_DB_USER_SET": "wordpress_user", + "W9_DB_PASSWORD_SET": "数据库密码" + }, + "profile": "external-mysql", +} +``` + +- `profile` 必须由当前应用已发布的模式文件对导出,并拒绝未知模式、额外字段或缺少字段。 +- `W9_DB_PASSWORD_SET` 不得写入 `appInstalling`、安装日志或持久化状态;模板物化后作为 `WORDPRESS_DB_PASSWORD` 的来源写入私有 Gitea `.env`。 +- 外接模式写入 `W9_DATABASE_MODE=external` 和 `W9_DB_EXPOSE=mysql`,用于我的应用、备份和卸载提示;没有 `W9_DATABASE_MODE` 的应用沿用内置模式。 + +### 安装时序与补偿 + +```text +1. 前端依据 install metadata 的 `profiles` 选择模板,并提交完整的当前模板 `settings` 与 `profile=external-mysql` +2. 安装路由校验模式及字段;使用 MySQL 协议认证并对指定数据库执行只读 `SELECT 1` +3. 用户负责确保目标数据库和账号已经存在且可访问;验证成功后安装服务创建安装任务 +4. 复制 WordPress 模板;选择 `external-mysql` 配对文件,并覆盖为标准 `docker-compose.yml` 与 `.env` +5. 使用既有通用设置写入流程写入 `W9_*_SET`;模板派生 `WORDPRESS_DB_*`、`W9_DATABASE_MODE=external`、`W9_DB_EXPOSE=mysql` +6. 安装服务复用既有 `GiteaManager`、`PortainerManager`、`ProxyManager` 和安装状态服务:推送 Gitea、预拉镜像、创建 Portainer Git Stack、创建代理 +7. 安装完成 +``` + +失败时沿用既有安装回滚:删除本次创建的 Gitea 仓库、Portainer Stack 和本地卷。外部数据库、账号和数据从未由 Websoft9 创建或清理。 + +### 我的应用最小适配 + +- **列表与启停**:不改变服务逻辑。外部模板同样规范化为 `docker-compose.yml` 和 `.env`,现有 Portainer Stack 操作继续可用。 +- **详情**:运行时从容器环境读取,停机时从 Gitea `.env` 回读模式标识和 WordPress 运行连接信息;数据库密码按现有详情权限模型以掩码形式显示并支持复制。 +- **重部署**:不增加新接口。Portainer 从 Gitea 重新读取 `.env`,因此可取得应用低权限密码;不得要求用户再次输入安装账号密码。 +- **卸载**:不改变删除 Stack/卷/Gitea 仓库的后端基础语义。仅增加外部模式提示,说明不会删除外部数据库,且彻底卸载会删除本地保存的连接配置。 +- **备份**:不改变 Restic 卷备份实现。仅在外部模式提示其不包含数据库数据。 + +### 我的应用数据页 + +当前数据库连接信息由 `W9_DB_EXPOSE` 推断,内置模式默认假定主机为 `-`、账号为内置初始账号、密码为 `W9_POWER_PASSWORD`。外接模式必须先将数据库标签纳入详情页标签集合,再按 `W9_DATABASE_MODE` 分支: + +```text +无 W9_DATABASE_MODE(内置) + type = W9_DB_EXPOSE + host = - + account = 内置数据库初始账号 + password = W9_POWER_PASSWORD + +W9_DATABASE_MODE=external + type = W9_DB_EXPOSE + host = WORDPRESS_DB_HOST + account = WORDPRESS_DB_USER + password = WORDPRESS_DB_PASSWORD +``` + +外接数据页只展示 WordPress 实际运行连接信息。即使外接模式没有 MySQL 数据卷,也必须保留数据库信息表;详情标签与数据库信息区块的显示条件必须使用“存在数据库信息”,不能仅依赖卷列表。 + +## 实施顺序 + +1. 在 docker-library 源仓库添加 WordPress 外部模板,分别验证内置与外部变体的 `docker compose config`。 +2. 扩展安装元数据生成与同步,使其自动发现模式文件对;前端通用渲染模式选择与字段,并完成密码字段脱敏。 +3. 增加受类型约束的 `profile` 模型;当前选中模板的 `W9_*_SET` 继续通过 `settings` 提交,并在安装前验证外部 MySQL 连接。 +4. 在通用安装服务中按 `profile` 复制/物化模板,并在既有 Gitea、镜像拉取、Portainer 步骤之前规范化为固定文件名和 `.env`;默认模式必须保持兼容。 +5. 在我的应用详情中加入数据库标签和外接连接信息;在备份与卸载界面增加外接数据库生命周期提示。 +7. 将构建物部署到运行中的产品容器,从实际应用商店入口完成验证。 +8. 后续独立评估加密秘密存储、TLS 和公网数据库支持,不与本试点混合实施。 + +## 必需测试 + +后端测试: + +- 内置 WordPress 安装请求与现有行为完全一致。 +- 外部请求拒绝未知 profile、错误模板字段、空连接参数、无效端口和非 MySQL-compatible 端点。 +- 数据库密码不出现在校验错误、日志、状态或遥测中;仅向已授权的“我的应用”详情用户提供掩码显示和复制。 +- 使用具备目标数据库访问权限的非 root 账号可通过只读认证和 `SELECT 1`。 +- 权限不足、网络不可达、凭据错误和数据库不存在均返回可操作错误。 +- 安装失败沿用既有 Gitea、Stack 和本地卷回滚,不额外删除外部数据库资源。 +- 内置和外部模式都仅向 Gitea 写入 `docker-compose.yml` 与 `.env`;外部 `.env` 保存用户提供的 WordPress 运行连接参数。 + +集成与运行时测试: + +- WordPress 内置安装、启停、重部署、卸载和卷备份回归通过。 +- 外部 WordPress 可对用户管理的 MySQL-compatible 数据库成功完成只读验证并安装。 +- 外部 WordPress 使用私有 `.env` 中的运行凭据即可重部署。 +- 外部 WordPress 卸载后,外部数据库及其数据不由平台额外处理;用户界面提示符合实际凭据清理语义。 +- 我的应用数据库页在运行和停机状态都正确显示 `WORDPRESS_DB_HOST`、`WORDPRESS_DB_USER` 与外接模式,不回显 `WORDPRESS_DB_PASSWORD`,也不显示不存在的 `-mysql` 主机。 +- 外接 WordPress 部署失败时,安装错误不会对外部数据库发出删除命令。 +- 平台升级后外部 WordPress 可以重部署。 +- 真实产品入口验证表单渲染、安装账号密码脱敏、成功安装、网络失败和权限失败。 +- 已提交的 Gitea/Portainer 镜像升级需补充运行时验证:Gitea 登录桥接、Portainer 登录、Git Stack 创建、Git Stack 重部署,以及升级后平台数据兼容性。 + +## 发布条件 + +仅在以下条件全部满足后发布试点: + +- 内置 WordPress 行为无回归; +- 外部 WordPress 使用仅授权目标数据库的运行账号; +- 数据库密码仅存在私有 Gitea `.env` 且未被 API、日志或状态回显; +- 连接验证、部署、重部署、卸载、备份提示和平台升级回归测试全部通过; +- 若启用 TLS,已通过对应端到端测试。 \ No newline at end of file diff --git a/scripts/generate_appstore_install_metadata.py b/scripts/generate_appstore_install_metadata.py index b573c2c4d..2d9b104f4 100644 --- a/scripts/generate_appstore_install_metadata.py +++ b/scripts/generate_appstore_install_metadata.py @@ -85,9 +85,13 @@ def discover_install_profiles(app_dir: Path) -> dict[str, dict[str, object]]: if not env_path.is_file(): continue - profiles[profile_name] = { - "settings": get_install_settings(load_env_values(env_path)), + env_values = load_env_values(env_path) + profile_metadata: dict[str, object] = { + "settings": get_install_settings(env_values), } + if env_values.get("W9_DATABASE_MODE") == "external": + profile_metadata["is_external_database"] = True + profiles[profile_name] = profile_metadata return profiles @@ -119,6 +123,15 @@ def build_install_metadata(library_root: Path, config_path: Path) -> dict[str, o app_metadata["settings"] = get_install_settings(env_values) app_metadata["is_web_app"] = "W9_URL" in env_values + variables_path = app_dir / "variables.json" + if variables_path.exists(): + try: + external_database_metadata = json.loads(variables_path.read_text(encoding="utf-8")).get("externalDB") + if isinstance(external_database_metadata, dict): + app_metadata["externalDB"] = external_database_metadata + except json.JSONDecodeError: + pass + profiles = discover_install_profiles(app_dir) if profiles: app_metadata["profiles"] = profiles From 9c099cd2c673fe902ab1fa9965788471f4cb9928 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Tue, 18 Aug 2026 08:23:02 +0800 Subject: [PATCH 03/11] fix(gateway): remove default 1m body limit on /api/ for terminal file uploads --- docker/gateway/platform-gateway-routes.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/gateway/platform-gateway-routes.conf b/docker/gateway/platform-gateway-routes.conf index 558e98e64..f63f16952 100644 --- a/docker/gateway/platform-gateway-routes.conf +++ b/docker/gateway/platform-gateway-routes.conf @@ -243,6 +243,7 @@ location /api/ { proxy_set_header X-Forwarded-Proto $scheme; include /etc/websoft9/platform-gateway/apphub-auth.conf; proxy_read_timeout 7200s; + client_max_body_size 0; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_proxied any; From adeeb99fd03558ae8b57af1e900b5c14cb4dadb1 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Tue, 18 Aug 2026 10:39:39 +0800 Subject: [PATCH 04/11] refactor(install): drop daemon.json restart fallback, add mirror pulls for utility images - Remove temporary daemon.json write + Docker restart fallback in pull_image_with_mirrors; failed pulls now abort with clear guidance to configure daemon-level registry mirrors manually - Support utility image mode (alpine:3.20) in pull_image_with_mirrors: local cache check -> direct pull -> mirrors.json prefixed pull (library/ prefix for official images) - Ensure utility image before volume backup/restore and legacy data transform; abort pre-migration backup on failure instead of silently continuing - Export W9_INSTALL_PATH in install.sh backup subcommand --- install/install.sh | 1 + install/lib/backup.sh | 9 +- install/lib/common.sh | 175 ++++++++++------------------------ install/lib/upgrade-legacy.sh | 5 + 4 files changed, 64 insertions(+), 126 deletions(-) diff --git a/install/install.sh b/install/install.sh index def413cb3..5cbe1c393 100755 --- a/install/install.sh +++ b/install/install.sh @@ -257,6 +257,7 @@ if [ -n "$_SUBCMD" ]; then ;; backup) require_root + export W9_INSTALL_PATH="$OPT_PATH" env_kind="$(detect_environment)" case "$env_kind" in modern) diff --git a/install/lib/backup.sh b/install/lib/backup.sh index 1137b4646..4faa48631 100755 --- a/install/lib/backup.sh +++ b/install/lib/backup.sh @@ -65,6 +65,7 @@ backup_volume() { log_warn "Volume not found, skipping backup: $volume_name" return 0 fi + pull_image_with_mirrors "${W9_INSTALL_PATH:-$DEFAULT_INSTALL_PATH}" "alpine:3.20" || return 1 run_cmd mkdir -p "$backup_dir" log_step "Backing up volume: $volume_name" run_cmd docker run --rm \ @@ -104,6 +105,7 @@ restore_volume() { log_warn "Volume backup not found, skipping restore: $archive" return 1 fi + pull_image_with_mirrors "${W9_INSTALL_PATH:-$DEFAULT_INSTALL_PATH}" "alpine:3.20" || return 1 run_cmd docker volume create "$volume_name" >/dev/null log_step "Restoring volume: $volume_name" run_cmd docker run --rm \ @@ -173,10 +175,15 @@ backup_modern_pre_upgrade() { backup_legacy_pre_migration() { local backup_dir="$1" run_cmd mkdir -p "$backup_dir" + # 强制备份点:循环外统一确保工具镜像可用,拉取失败立即中止, + # 避免逐卷重复拉取以及"备份未完成却继续迁移"的风险。 + pull_image_with_mirrors "${W9_INSTALL_PATH:-$DEFAULT_INSTALL_PATH}" "alpine:3.20" \ + || die "$EXIT_RUNTIME" "Failed to pull utility image alpine:3.20 for pre-migration backup" local v host_compose service_root download_root while IFS= read -r v; do [ -n "$v" ] || continue - backup_volume "$v" "$backup_dir" + backup_volume "$v" "$backup_dir" \ + || die "$EXIT_RUNTIME" "Pre-migration backup failed for volume: $v (backup point: $backup_dir)" done < <(legacy_list_resolved_volumes) host_compose="$(legacy_host_compose_dir 2>/dev/null || true)" if [ -n "$host_compose" ]; then diff --git a/install/lib/common.sh b/install/lib/common.sh index 43a18f04f..0114e713d 100755 --- a/install/lib/common.sh +++ b/install/lib/common.sh @@ -676,29 +676,6 @@ modern_compose() { "$@" } -restart_docker_service() { - if [ "${W9_DRY_RUN:-0}" = "1" ]; then - log_info "(dry-run) would restart Docker service" - return 0 - fi - - if command_exists systemctl; then - run_cmd systemctl restart docker || return 1 - elif command_exists service; then - run_cmd service docker restart || return 1 - else - log_error "Unable to restart Docker: neither systemctl nor service is available" - return 1 - fi - - if ! docker_available; then - log_error "Docker did not become available after restart" - return 1 - fi - - return 0 -} - # ──────────────────────────────────────────────────────────── # Mirror config bootstrap — write mirrors.json to config.ini # once during install / upgrade when the value is empty. @@ -815,110 +792,54 @@ pull_image_via_prefixed_mirrors() { return "$success" } -write_temp_daemon_json_from_mirrors() { - local daemon_file="$1" - local mirrors="$2" - - { - printf '{\n "registry-mirrors": [\n' - local first=1 - local mirror mirror_url - while IFS= read -r mirror; do - [ -z "$mirror" ] && continue - mirror_url="$mirror" - case "$mirror_url" in - http://*|https://*) ;; - *) mirror_url="https://${mirror_url}" ;; - esac - if [ "$first" -eq 1 ]; then - first=0 - else - printf ',\n' - fi - printf ' "%s"' "$mirror_url" - done <<< "$mirrors" - printf '\n ]\n}\n' - } > "$daemon_file" -} - -pull_image_via_temporary_daemon_mirrors() { +# 镜像拉取(带镜像加速回退) +# 模式一(默认,websoft9 主镜像):docker compose pull 直拉,失败后使用 +# mirrors.json 中的地址显式拉取。 +# 模式二(工具镜像,第二参数传入镜像引用如 "alpine:3.20"): +# 本地缓存检查 -> docker pull 直拉 -> mirrors.json 显式前缀拉取。 +# 注意:不再改写 /etc/docker/daemon.json 或重启 Docker 服务,避免影响宿主机上 +# 其他运行中的容器;如环境无法直连 Docker Hub,请自行配置 daemon 级镜像加速。 +pull_image_with_mirrors() { local install_path="$1" - local mirrors="$2" - local daemon_file="/etc/docker/daemon.json" - local backup_file="/tmp/websoft9-daemon-backup-$$.json" - local had_original=0 - local pull_status=1 - local restore_status=0 - - if [ -f "$daemon_file" ]; then - cp -a "$daemon_file" "$backup_file" - had_original=1 + local image_ref_override="${2:-}" + + local image_repo image_tag image_ref + if [ -n "$image_ref_override" ]; then + image_ref="$image_ref_override" + image_repo="${image_ref%%:*}" + image_tag="${image_ref##*:}" + [ "$image_tag" = "$image_ref" ] && image_tag="latest" else - rm -f "$backup_file" + # 从 .env 读取镜像名(已由 install_prepare_material 写入) + local env_file="${install_path}/.env" + if [ -f "$env_file" ]; then + image_repo="$(read_env_value "$env_file" IMAGE_REPO 2>/dev/null || true)" + image_tag="$(read_env_value "$env_file" IMAGE_TAG 2>/dev/null || true)" + fi + image_repo="${image_repo:-$DEFAULT_IMAGE_REPO}" + image_tag="${image_tag:-$DEFAULT_IMAGE_TAG}" + image_ref="${image_repo}:${image_tag}" fi - # Restore the original daemon.json even if the script exits mid-flight. - export W9_TRAP_DAEMON_FILE="$daemon_file" - export W9_TRAP_BACKUP_FILE="$backup_file" - export W9_TRAP_HAD_ORIGINAL="$had_original" - trap '_restore_daemon_json "$W9_TRAP_DAEMON_FILE" "$W9_TRAP_BACKUP_FILE" "$W9_TRAP_HAD_ORIGINAL"' EXIT - - write_temp_daemon_json_from_mirrors "$daemon_file" "$mirrors" - - log_warn "Direct and explicit mirror pulls failed, retrying with a temporary Docker daemon mirror configuration" - if ! restart_docker_service; then - log_error "Failed to restart Docker after writing temporary daemon.json" - pull_status=1 - elif modern_compose "$install_path" pull; then - log_info "Pull succeeded via temporary Docker daemon mirror configuration" - pull_status=0 + # 1. 直拉:主镜像走 compose pull,工具镜像走 docker pull(含本地缓存短路) + if [ -n "$image_ref_override" ]; then + if [ "${W9_DRY_RUN:-0}" = "1" ]; then + log_info "(dry-run) would ensure utility image: $image_ref" + return 0 + fi + if docker image inspect "$image_ref" >/dev/null 2>&1; then + log_info "Utility image already present: $image_ref" + return 0 + fi + log_info "Pulling utility image: $image_ref" + if docker pull "$image_ref"; then + return 0 + fi else - log_warn "Pull failed even after applying temporary Docker daemon mirror configuration" - pull_status=1 - fi - - # Restore now (trap also covers this, but explicit restore gives better error handling). - _restore_daemon_json "$daemon_file" "$backup_file" "$had_original" - trap - EXIT - unset W9_TRAP_DAEMON_FILE W9_TRAP_BACKUP_FILE W9_TRAP_HAD_ORIGINAL - - return "$pull_status" -} - -# Restore original daemon.json (called via trap and inline in pull path). -_restore_daemon_json() { - local daemon_file="$1" backup_file="$2" had_original="$3" - if [ "$had_original" = "1" ] && [ -f "$backup_file" ]; then - cp -a "$backup_file" "$daemon_file" 2>/dev/null || true - elif [ "$had_original" != "1" ]; then - rm -f "$daemon_file" 2>/dev/null || true - fi - rm -f "$backup_file" 2>/dev/null || true - restart_docker_service 2>/dev/null || true -} - -# 镜像拉取(带镜像加速回退) -# 先尝试 docker compose pull 直拉;失败后使用 mirrors.json 中的地址显式拉取; -# 若仍失败,则临时写入全新的 /etc/docker/daemon.json 并重启 Docker 后再试一次; -# 结束后恢复原 daemon.json。全部失败返回 1。 -pull_image_with_mirrors() { - local install_path="$1" - - # 从 .env 读取镜像名(已由 install_prepare_material 写入) - local env_file="${install_path}/.env" - local image_repo image_tag - if [ -f "$env_file" ]; then - image_repo="$(read_env_value "$env_file" IMAGE_REPO 2>/dev/null || true)" - image_tag="$(read_env_value "$env_file" IMAGE_TAG 2>/dev/null || true)" - fi - image_repo="${image_repo:-$DEFAULT_IMAGE_REPO}" - image_tag="${image_tag:-$DEFAULT_IMAGE_TAG}" - local image_ref="${image_repo}:${image_tag}" - - # 1. 先尝试直拉(利用 Docker daemon 已配置的镜像加速) - log_info "Pulling image: $image_ref" - if modern_compose "$install_path" pull; then - return 0 + log_info "Pulling image: $image_ref" + if modern_compose "$install_path" pull; then + return 0 + fi fi log_warn "Direct pull failed, trying mirror accelerators..." @@ -933,10 +854,14 @@ pull_image_with_mirrors() { return 0 fi - if pull_image_via_temporary_daemon_mirrors "$install_path" "$mirrors"; then - return 0 + # Docker 官方 library 镜像(如 alpine)在部分镜像站需要 library/ 前缀 + if [ -n "$image_ref_override" ]; then + case "$image_repo" in + */*) ;; + *) pull_image_via_prefixed_mirrors "library/$image_repo" "$image_tag" "$image_ref" "$mirrors" && return 0 ;; + esac fi - log_error "Direct pull, explicit mirrors, and temporary Docker daemon mirror configuration all failed" + log_error "Direct pull and explicit mirror pulls all failed" return 1 } diff --git a/install/lib/upgrade-legacy.sh b/install/lib/upgrade-legacy.sh index e079c3146..a8b1445b4 100755 --- a/install/lib/upgrade-legacy.sh +++ b/install/lib/upgrade-legacy.sh @@ -482,6 +482,11 @@ _legacy_transform_volumes() { mounts+=(-v "/etc/docker/daemon.json:/legacy/docker-daemon.json:ro") fi + if ! pull_image_with_mirrors "$install_path" "alpine:3.20"; then + rm -rf "$tmpdir" + die "$EXIT_RUNTIME" "Failed to pull utility image alpine:3.20 for legacy data transform" + fi + if ! docker run --rm "${mounts[@]}" alpine:3.20 sh /w9script/transform.sh; then rm -rf "$tmpdir" die "$EXIT_RUNTIME" "Legacy data transform failed" From f94753adbebf816a6c5116a78e60a79d3fb19a97 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Tue, 18 Aug 2026 13:57:47 +0800 Subject: [PATCH 05/11] feat: add platform readiness endpoint and gate setup wizard on it - Add GET /api/healthz/ready returning 200 when fully ready or 503 with pending components - Probe own SQLite stores read-only, embedded service health, and bootstrap markers - Initialize lazily-created host-access storage at apphub startup so fresh boots can become ready - Setup wizard polls readiness every second and keeps its loading state until the platform is ready - Add unit tests for the readiness service --- apphub/src/core/api_key_auth.py | 2 +- apphub/src/main.py | 15 +++ apphub/src/services/platform_readiness.py | 105 +++++++++++++++++ apphub/tests/test_platform_readiness.py | 106 ++++++++++++++++++ .../setup-wizard/setup-wizard-page.tsx | 44 +++++++- 5 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 apphub/src/services/platform_readiness.py create mode 100644 apphub/tests/test_platform_readiness.py diff --git a/apphub/src/core/api_key_auth.py b/apphub/src/core/api_key_auth.py index c5e9cb681..0fedf8e27 100644 --- a/apphub/src/core/api_key_auth.py +++ b/apphub/src/core/api_key_auth.py @@ -3,7 +3,7 @@ def should_skip_api_key_auth(path: str) -> bool: if normalized_path.startswith("/api/"): normalized_path = normalized_path[4:] - if normalized_path in {"/docs", "/openapi.json", "/redoc", "/healthz"}: + if normalized_path in {"/docs", "/openapi.json", "/redoc", "/healthz", "/healthz/ready"}: return True if normalized_path.startswith("/static/"): diff --git a/apphub/src/main.py b/apphub/src/main.py index 7caad755a..85e06e309 100755 --- a/apphub/src/main.py +++ b/apphub/src/main.py @@ -25,6 +25,7 @@ from src.core.logger import clear_logging_context, logger, set_request_id from src.core.request_auth import has_valid_internal_gateway_auth from src.schemas.errorResponse import ErrorResponse +from src.services.platform_readiness import PlatformReadinessService uvicorn_logger = logging.getLogger("uvicorn") uvicorn_logger.setLevel(logging.INFO) @@ -102,10 +103,24 @@ async def request_logging_context(request: Request, call_next): finally: clear_logging_context() +@app.on_event("startup") +async def ensure_platform_storage(): + # host-access.sqlite is lazily created on first use; initialize it eagerly + # so a fresh cloud boot can reach the fully-ready state. + api_host_access._get_host_access_service()._ensure_storage() + @app.get("/healthz", include_in_schema=False) async def healthz(): return {"status": "ok"} +@app.get("/healthz/ready", include_in_schema=False) +async def healthz_ready(): + ready, pending = PlatformReadinessService().check() + return JSONResponse( + status_code=200 if ready else 503, + content={"ready": ready, "pending": pending}, + ) + @app.get("/docs", include_in_schema=False) async def custom_swagger_ui_html(): return get_swagger_ui_html( diff --git a/apphub/src/services/platform_readiness.py b/apphub/src/services/platform_readiness.py new file mode 100644 index 000000000..a750f3197 --- /dev/null +++ b/apphub/src/services/platform_readiness.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path +from typing import Callable, Optional + +import requests + +READINESS_PROBE_TIMEOUT_SECONDS = 2.0 + +# Keys reported in the `pending` list when a component is not ready yet. +SQLITE_PRODUCT_AUTH = "product-auth" +SQLITE_INSTALL_TRACKING = "install-tracking" +SQLITE_HOST_ACCESS = "host-access" +HTTP_GITEA = "gitea" +HTTP_PORTAINER = "portainer" +HTTP_NPM = "nginx-proxy-manager" +MARKER_GITEA_CREDENTIAL = "gitea-credential" +MARKER_PORTAINER_CREDENTIAL = "portainer-credential" +MARKER_NPM_CREDENTIAL = "npm-credential" +MARKER_NPM_CERTIFICATE = "npm-certificate" + + +def _data_root() -> str: + return os.getenv("WEBSOFT9_DATA_ROOT", "/opt/websoft9/data") + + +def _sqlite_database_path(kind: str) -> Path: + data_root = _data_root() + if kind == SQLITE_PRODUCT_AUTH: + directory = os.getenv("WEBSOFT9_PRODUCT_AUTH_DATA_DIR", f"{data_root}/config/product-auth") + return Path(directory) / "product-auth.sqlite" + if kind == SQLITE_INSTALL_TRACKING: + directory = os.getenv("WEBSOFT9_INSTALL_TRACKING_DIR", f"{data_root}/config/apphub") + return Path(directory) / "install-tracking.sqlite" + directory = os.getenv("WEBSOFT9_HOST_ACCESS_DATA_DIR", f"{data_root}/config/host-access") + return Path(directory) / "host-access.sqlite" + + +class PlatformReadinessService: + """Aggregates the platform's full-readiness state. + + Every sub-check is side-effect free: SQLite databases are opened for a + read-only probe and HTTP services are hit with short timeouts. Any + failure is reported as a `pending` component rather than raised, so the + readiness endpoint itself never returns a bare 500. + """ + + def __init__(self, http_probe: Optional[Callable[[str], bool]] = None): + self._http_probe = http_probe or self._probe_http + + def check(self) -> tuple[bool, list[str]]: + pending: list[str] = [] + + if not self._check_sqlite(_sqlite_database_path(SQLITE_PRODUCT_AUTH), "operators"): + pending.append(SQLITE_PRODUCT_AUTH) + if not self._check_sqlite(_sqlite_database_path(SQLITE_INSTALL_TRACKING), "install_tasks"): + pending.append(SQLITE_INSTALL_TRACKING) + if not self._check_sqlite(_sqlite_database_path(SQLITE_HOST_ACCESS), "host_profiles"): + pending.append(SQLITE_HOST_ACCESS) + + if not self._http_probe(os.getenv("WEBSOFT9_GITEA_HEALTH_URL", "http://127.0.0.1:3001/")): + pending.append(HTTP_GITEA) + if not self._http_probe(os.getenv("WEBSOFT9_PORTAINER_HEALTH_URL", "http://127.0.0.1:9004/api/system/status")): + pending.append(HTTP_PORTAINER) + if not self._http_probe(os.getenv("WEBSOFT9_NPM_HEALTH_URL", "http://127.0.0.1:81/")): + pending.append(HTTP_NPM) + + data_root = _data_root() + if not Path(os.getenv("WEBSOFT9_GITEA_CREDENTIAL_PATH", f"{data_root}/gitea/credential")).is_file(): + pending.append(MARKER_GITEA_CREDENTIAL) + if not Path(os.getenv("WEBSOFT9_PORTAINER_CREDENTIAL_PATH", f"{data_root}/portainer/credential")).is_file(): + pending.append(MARKER_PORTAINER_CREDENTIAL) + if not Path(os.getenv("WEBSOFT9_NPM_CREDENTIAL_PATH", f"{data_root}/credential.json")).is_file(): + pending.append(MARKER_NPM_CREDENTIAL) + if not Path(os.getenv("WEBSOFT9_NPM_CERT_MARKER", f"{data_root}/custom_ssl/websoft9-self-signed.cert")).is_file(): + pending.append(MARKER_NPM_CERTIFICATE) + + return (not pending, pending) + + @staticmethod + def _probe_http(url: str) -> bool: + try: + response = requests.get(url, timeout=READINESS_PROBE_TIMEOUT_SECONDS) + return 200 <= response.status_code < 500 + except Exception: + return False + + @staticmethod + def _check_sqlite(database_file: Path, required_table: str) -> bool: + try: + if not database_file.is_file(): + return False + connection = sqlite3.connect(str(database_file), timeout=1) + try: + row = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (required_table,), + ).fetchone() + return row is not None + finally: + connection.close() + except Exception: + return False diff --git a/apphub/tests/test_platform_readiness.py b/apphub/tests/test_platform_readiness.py new file mode 100644 index 000000000..41563fdb7 --- /dev/null +++ b/apphub/tests/test_platform_readiness.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path +import sqlite3 + +from src.services.platform_readiness import PlatformReadinessService + + +def _write_sqlite(database_file: Path, table_name: str) -> None: + database_file.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(str(database_file)) + try: + connection.execute(f"CREATE TABLE IF NOT EXISTS {table_name} (id INTEGER PRIMARY KEY)") + connection.commit() + finally: + connection.close() + + +def test_ready_when_all_checks_pass(monkeypatch, tmp_path): + data_root = tmp_path / "data" + monkeypatch.setenv("WEBSOFT9_DATA_ROOT", str(data_root)) + monkeypatch.setenv("WEBSOFT9_PRODUCT_AUTH_DATA_DIR", str(data_root / "config" / "product-auth")) + monkeypatch.setenv("WEBSOFT9_INSTALL_TRACKING_DIR", str(data_root / "config" / "apphub")) + monkeypatch.setenv("WEBSOFT9_HOST_ACCESS_DATA_DIR", str(data_root / "config" / "host-access")) + + _write_sqlite(data_root / "config" / "product-auth" / "product-auth.sqlite", "operators") + _write_sqlite(data_root / "config" / "apphub" / "install-tracking.sqlite", "install_tasks") + _write_sqlite(data_root / "config" / "host-access" / "host-access.sqlite", "host_profiles") + + for relative_path in ( + "gitea/credential", + "portainer/credential", + "credential.json", + "custom_ssl/websoft9-self-signed.cert", + ): + marker = data_root / relative_path + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("ok", encoding="utf-8") + + service = PlatformReadinessService(http_probe=lambda url: True) + + ready, pending = service.check() + + assert ready is True + assert pending == [] + + +def test_pending_lists_sqlite_and_service_failures(monkeypatch, tmp_path): + data_root = tmp_path / "data" + monkeypatch.setenv("WEBSOFT9_DATA_ROOT", str(data_root)) + + service = PlatformReadinessService(http_probe=lambda url: False) + + ready, pending = service.check() + + assert ready is False + assert "product-auth" in pending + assert "install-tracking" in pending + assert "host-access" in pending + assert "gitea" in pending + assert "portainer" in pending + assert "nginx-proxy-manager" in pending + + +def test_missing_markers_are_reported(monkeypatch, tmp_path): + data_root = tmp_path / "data" + monkeypatch.setenv("WEBSOFT9_DATA_ROOT", str(data_root)) + monkeypatch.setenv("WEBSOFT9_PRODUCT_AUTH_DATA_DIR", str(data_root / "config" / "product-auth")) + monkeypatch.setenv("WEBSOFT9_INSTALL_TRACKING_DIR", str(data_root / "config" / "apphub")) + monkeypatch.setenv("WEBSOFT9_HOST_ACCESS_DATA_DIR", str(data_root / "config" / "host-access")) + + _write_sqlite(data_root / "config" / "product-auth" / "product-auth.sqlite", "operators") + _write_sqlite(data_root / "config" / "apphub" / "install-tracking.sqlite", "install_tasks") + _write_sqlite(data_root / "config" / "host-access" / "host-access.sqlite", "host_profiles") + + service = PlatformReadinessService(http_probe=lambda url: True) + + ready, pending = service.check() + + assert ready is False + assert "gitea-credential" in pending + assert "portainer-credential" in pending + assert "npm-credential" in pending + assert "npm-certificate" in pending + + +def test_sqlite_missing_table_reports_pending(monkeypatch, tmp_path): + data_root = tmp_path / "data" + monkeypatch.setenv("WEBSOFT9_DATA_ROOT", str(data_root)) + monkeypatch.setenv("WEBSOFT9_PRODUCT_AUTH_DATA_DIR", str(data_root / "config" / "product-auth")) + monkeypatch.setenv("WEBSOFT9_INSTALL_TRACKING_DIR", str(data_root / "config" / "apphub")) + monkeypatch.setenv("WEBSOFT9_HOST_ACCESS_DATA_DIR", str(data_root / "config" / "host-access")) + + database_file = data_root / "config" / "product-auth" / "product-auth.sqlite" + database_file.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(str(database_file)) + connection.execute("CREATE TABLE unrelated (id INTEGER PRIMARY KEY)") + connection.commit() + connection.close() + + service = PlatformReadinessService(http_probe=lambda url: True) + + ready, pending = service.check() + + assert ready is False + assert "product-auth" in pending diff --git a/console/src/features/setup-wizard/setup-wizard-page.tsx b/console/src/features/setup-wizard/setup-wizard-page.tsx index 52686fbef..682710292 100644 --- a/console/src/features/setup-wizard/setup-wizard-page.tsx +++ b/console/src/features/setup-wizard/setup-wizard-page.tsx @@ -142,6 +142,7 @@ export function SetupWizardPage() { const [appInfo, setAppInfo] = useState(null) const [currentStep, setCurrentStep] = useState('welcome') const [pageLoading, setPageLoading] = useState(true) + const [platformReady, setPlatformReady] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [username, setUsername] = useState('') @@ -166,6 +167,43 @@ export function SetupWizardPage() { return () => document.removeEventListener('click', handler) }, [langMenuOpen]) + useEffect(() => { + if (platformReady) { + return + } + + let active = true + let timerId: number | undefined + + async function checkPlatformReady() { + try { + const response = await fetch('/api/healthz/ready', { credentials: 'include' }) + // A 404 means the runtime predates the readiness endpoint; + // treat it as pass-through so the setup flow is not blocked. + if (active && (response.ok || response.status === 404)) { + setPlatformReady(true) + return + } + } catch { + // The platform may still be bootstrapping; keep polling below. + } + if (active) { + timerId = window.setTimeout(() => { + void checkPlatformReady() + }, 1000) + } + } + + void checkPlatformReady() + + return () => { + active = false + if (timerId !== undefined) { + window.clearTimeout(timerId) + } + } + }, [platformReady]) + const resolvedWizardLocale = normalizeSupportedLocale(i18n.resolvedLanguage ?? i18n.language ?? 'en') const apiLocale = resolveApiLocale(resolvedWizardLocale) const platformInitializationRequired = Boolean(status?.initialization_required) @@ -294,7 +332,7 @@ export function SetupWizardPage() { } useEffect(() => { - if (isLoading || !status) { + if (isLoading || !status || !platformReady) { return } @@ -359,7 +397,7 @@ export function SetupWizardPage() { return () => { active = false } - }, [apiLocale, isLoading]) + }, [apiLocale, isLoading, platformReady]) useEffect(() => { if (isLoading || !status || pageLoading || !wizardState) { @@ -632,7 +670,7 @@ export function SetupWizardPage() { }} > - {(pageLoading || isLoading) ? ( + {(pageLoading || isLoading || !platformReady) ? ( {apiLocale === 'zh' ? '正在加载…' : 'Loading…'} From 15cc75b7e2448b2c6e8d4d4cc45f4816778c5f32 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 11:55:41 +0800 Subject: [PATCH 06/11] feat: add managed scheduled tasks --- .../sprint-status.yaml | 1 + apphub/requirements.txt | 3 +- apphub/src/api/v1/routers/scheduled_tasks.py | 100 + apphub/src/main.py | 2 + apphub/src/schemas/scheduledTasks.py | 22 + apphub/src/services/scheduled_tasks.py | 999 ++++++++ apphub/tests/test_scheduled_tasks.py | 642 +++++ console/src/app/router/index.tsx | 14 + console/src/app/shell/app-shell.tsx | 5 +- console/src/app/shell/shell-navigation.ts | 4 + .../scheduled-tasks/scheduled-tasks-page.css | 526 ++++ .../scheduled-tasks/scheduled-tasks-page.tsx | 2254 +++++++++++++++++ console/src/shared/i18n/resources.ts | 76 + docker/Dockerfile | 3 +- 14 files changed, 4648 insertions(+), 3 deletions(-) create mode 100644 apphub/src/api/v1/routers/scheduled_tasks.py create mode 100644 apphub/src/schemas/scheduledTasks.py create mode 100644 apphub/src/services/scheduled_tasks.py create mode 100644 apphub/tests/test_scheduled_tasks.py create mode 100644 console/src/features/scheduled-tasks/scheduled-tasks-page.css create mode 100644 console/src/features/scheduled-tasks/scheduled-tasks-page.tsx diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 190d784ee..ad2492566 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -79,6 +79,7 @@ development_status: 4-6-build-the-controlled-terminal-bridge-and-session-audit: ready-for-dev 4-7-build-the-core-services-view-and-log-drilldown: review 4-8-build-the-websoft9-runtime-logs-page: review + 4-9-build-lightweight-scheduled-tasks: done epic-4-retrospective: optional epic-5: in-progress diff --git a/apphub/requirements.txt b/apphub/requirements.txt index 6cdb13082..082050c44 100755 --- a/apphub/requirements.txt +++ b/apphub/requirements.txt @@ -15,4 +15,5 @@ aiodocker paramiko python-multipart PyMySQL -psycopg2-binary \ No newline at end of file +psycopg2-binary +croniter \ No newline at end of file diff --git a/apphub/src/api/v1/routers/scheduled_tasks.py b/apphub/src/api/v1/routers/scheduled_tasks.py new file mode 100644 index 000000000..18f33a8ce --- /dev/null +++ b/apphub/src/api/v1/routers/scheduled_tasks.py @@ -0,0 +1,100 @@ +import asyncio +import hashlib +import json +from typing import Optional + +from fastapi import APIRouter, Cookie, Query, Request, Response +from fastapi.responses import StreamingResponse + +from src.schemas.errorResponse import ErrorResponse +from src.schemas.scheduledTasks import ScheduledTaskToggleRequest, ScheduledTaskWriteRequest +from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME +from src.services.scheduled_tasks import ScheduledTaskService + + +router = APIRouter() +_scheduled_task_service = ScheduledTaskService() + + +def _get_scheduled_task_service() -> ScheduledTaskService: + return _scheduled_task_service + + +@router.get("/scheduled-tasks", responses={200: {"model": dict}, 401: {"model": ErrorResponse}}) +def list_scheduled_tasks(session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().list_tasks(session_token) + + +@router.post("/scheduled-tasks/sync", status_code=202, responses={401: {"model": ErrorResponse}}) +def sync_scheduled_tasks(session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().start_sync(session_token) + + +@router.get("/scheduled-tasks/stream", responses={200: {"description": "Scheduled task stream established"}, 401: {"model": ErrorResponse}}) +async def stream_scheduled_tasks(request: Request, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + async def event_generator(): + last_digest: Optional[str] = None + while not await request.is_disconnected(): + snapshot = _get_scheduled_task_service().list_cached_tasks(session_token) + payload = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")) + digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() + if digest != last_digest: + last_digest = digest + yield f"event: snapshot\ndata: {payload}\n\n" + else: + yield ": keep-alive\n\n" + await asyncio.sleep(2) + return StreamingResponse(event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + + +@router.post("/scheduled-tasks/hosts/{profile_id}/capability", responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 503: {"model": ErrorResponse}}) +def check_scheduled_task_host_capability(profile_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().check_host_capability(session_token, profile_id) + + +@router.post("/scheduled-tasks", status_code=201, responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 409: {"model": ErrorResponse}}) +def create_scheduled_task(payload: ScheduledTaskWriteRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().create_task(session_token, payload.model_dump()) + + +@router.put("/scheduled-tasks/{task_id}", responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def update_scheduled_task(task_id: str, payload: ScheduledTaskWriteRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().update_task(session_token, task_id, payload.model_dump()) + + +@router.post("/scheduled-tasks/{task_id}/toggle", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def toggle_scheduled_task(task_id: str, payload: ScheduledTaskToggleRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().toggle_task(session_token, task_id, payload.enabled) + + +@router.delete("/scheduled-tasks/{task_id}", status_code=204, responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def delete_scheduled_task(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + _get_scheduled_task_service().delete_task(session_token, task_id) + return Response(status_code=204) + + +@router.post("/scheduled-tasks/{task_id}/run", status_code=202, responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}, 503: {"model": ErrorResponse}}) +def run_scheduled_task(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().run_task(session_token, task_id) + + +@router.post("/scheduled-tasks/{task_id}/refresh-status", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def refresh_scheduled_task_status(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().refresh_status(session_token, task_id) + + +@router.get("/scheduled-tasks/{task_id}/runs", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def list_scheduled_task_runs(task_id: str, offset: int = Query(default=0, ge=0), limit: int = Query(default=20, ge=1, le=100), session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().list_runs(session_token, task_id, offset, limit) + + +@router.get("/scheduled-tasks/{task_id}/runs/{run_id}/log", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def get_scheduled_task_run_log(task_id: str, run_id: str, before: Optional[int] = Query(default=None, ge=0), session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + return _get_scheduled_task_service().get_run_log(session_token, task_id, run_id, before) + + +@router.get("/scheduled-tasks/{task_id}/runs/{run_id}/log/download", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) +def download_scheduled_task_run_log(task_id: str, run_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)): + content = _get_scheduled_task_service().download_run_log(session_token, task_id, run_id) + filename = f"scheduled-task-{task_id}-{run_id}.log" + return Response(content=content, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) \ No newline at end of file diff --git a/apphub/src/main.py b/apphub/src/main.py index 85e06e309..01b6a54b9 100755 --- a/apphub/src/main.py +++ b/apphub/src/main.py @@ -19,6 +19,7 @@ from src.api.v1.routers import compose_app as api_compose_app from src.api.v1.routers import appstore_sync as api_appstore_sync from src.api.v1.routers import setup_wizard as api_setup_wizard +from src.api.v1.routers import scheduled_tasks as api_scheduled_tasks from src.core.config import ConfigManager from src.core.exception import CustomException from src.core.api_key_auth import should_skip_api_key_auth @@ -177,5 +178,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE app.include_router(api_compose_app.router,tags=["compose-apps"]) app.include_router(api_appstore_sync.router,tags=["appstore-sync"]) app.include_router(api_setup_wizard.router,tags=["setup-wizard"]) +app.include_router(api_scheduled_tasks.router, tags=["scheduled-tasks"]) remove_422_responses() \ No newline at end of file diff --git a/apphub/src/schemas/scheduledTasks.py b/apphub/src/schemas/scheduledTasks.py new file mode 100644 index 000000000..ac6c5f957 --- /dev/null +++ b/apphub/src/schemas/scheduledTasks.py @@ -0,0 +1,22 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class ScheduledTaskWriteRequest(BaseModel): + name: str = Field(min_length=1, max_length=64) + target: Literal["container", "host"] = "container" + profile_id: Optional[str] = None + schedule: str = Field(min_length=1, max_length=128) + execution_mode: Literal["command", "path", "upload"] = "command" + command: str = Field(default="", max_length=4096) + script_path: Optional[str] = Field(default=None, max_length=4096) + script_name: Optional[str] = Field(default=None, max_length=255) + script_content: Optional[str] = Field(default=None, max_length=524288) + timeout_seconds: int = Field(default=0, ge=0, le=86400) + retry_count: int = Field(default=0, ge=0, le=10) + enabled: bool = True + + +class ScheduledTaskToggleRequest(BaseModel): + enabled: bool diff --git a/apphub/src/services/scheduled_tasks.py b/apphub/src/services/scheduled_tasks.py new file mode 100644 index 000000000..0b9fe2026 --- /dev/null +++ b/apphub/src/services/scheduled_tasks.py @@ -0,0 +1,999 @@ +from __future__ import annotations + +import os +import base64 +import json +import shlex +import sqlite3 +import subprocess +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from croniter import croniter + +from src.core.exception import CustomException +from src.services.host_access import HostAccessService +from src.services.product_auth import ProductAuthService + + +class ScheduledTaskService: + """Persist and run simple platform-container cron tasks.""" + + _lock = threading.RLock() + _host_capability_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {} + _background_syncing: set[str] = set() + _background_syncing_task_ids: dict[str, set[str]] = {} + _runner_version_marker = "# websoft9-task-runner-version: 4" + _run_retention_count = 50 + _run_retention_days = 7 + _log_read_line_limit = 200 + _log_read_byte_limit = 1024 * 1024 + + def __init__( + self, + data_dir: Optional[str] = None, + cron_file: Optional[str] = None, + auth_service: Optional[ProductAuthService] = None, + cron_reloader: Optional[Callable[[], None]] = None, + host_access_service: Optional[HostAccessService] = None, + ): + data_root = os.getenv("WEBSOFT9_DATA_ROOT", "/opt/websoft9/data") + self.data_dir = Path(data_dir or os.getenv("WEBSOFT9_SCHEDULED_TASKS_DATA_DIR") or f"{data_root}/config/scheduled-tasks") + self.database_file = self.data_dir / "scheduled-tasks.sqlite" + self.cron_file = Path(cron_file or os.getenv("WEBSOFT9_SCHEDULED_TASKS_CRON_FILE", "/etc/cron.d/websoft9-tasks")) + self.auth_service = auth_service or ProductAuthService() + self._cron_reloader = cron_reloader or self._reload_cron + self.host_access_service = host_access_service or HostAccessService(auth_service=self.auth_service) + + def check_host_capability(self, session_token: Optional[str], profile_id: str) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + normalized_profile_id = str(profile_id or "").strip() + if not normalized_profile_id: + raise CustomException(400, "Host Access Profile Required", "A saved SSH host profile is required") + cache_key = (str(operator["id"]), normalized_profile_id) + cached = self._host_capability_cache.get(cache_key) + if cached and time.monotonic() - cached[0] < 300: + return cached[1] + + profile = self.host_access_service.get_connection_profile(session_token, profile_id=normalized_profile_id) + with self.host_access_service._open_file_client(profile) as client: + try: + _, stdout, stderr = client.exec_command( + "command -v bash >/dev/null && command -v crontab >/dev/null && command -v flock >/dev/null && command -v timeout >/dev/null " + "&& mkdir -p \"$HOME/.local/state/websoft9/scheduled-tasks\" " + "&& timezone_name=$(test -f /etc/timezone -a -r /etc/timezone && cat /etc/timezone || readlink -f /etc/localtime 2>/dev/null | sed 's#^.*/zoneinfo/##') " + "&& printf '%s' \"${timezone_name:-UTC}\"", + timeout=15, + ) + deadline = time.monotonic() + 15 + while not getattr(stdout.channel, "exit_status_ready", lambda: True)(): + if time.monotonic() >= deadline: + close_channel = getattr(stdout.channel, "close", None) + if callable(close_channel): + close_channel() + raise CustomException(503, "Scheduled Task Host Unavailable", "Timed out while inspecting the SSH host") + time.sleep(0.1) + exit_code = stdout.channel.recv_exit_status() + timezone_name = stdout.read().decode("utf-8", errors="replace").strip() or "UTC" + error_text = stderr.read().decode("utf-8", errors="replace").strip() + except Exception as exc: + raise CustomException(503, "Scheduled Task Host Unavailable", f"Unable to inspect the SSH host: {exc}") from exc + + try: + ZoneInfo(timezone_name) + except (ZoneInfoNotFoundError, ValueError): + timezone_name = "UTC" + + checks = [ + {"name": "bash", "ok": exit_code == 0}, + {"name": "crontab", "ok": exit_code == 0}, + {"name": "flock", "ok": exit_code == 0}, + {"name": "timeout", "ok": exit_code == 0}, + {"name": "task_directory", "ok": exit_code == 0}, + ] + result = { + "capability_status": "ready" if exit_code == 0 else "unavailable", + "timezone": timezone_name, + "checks": checks, + "message": error_text or ("Host is ready for scheduled tasks" if exit_code == 0 else "The SSH host is missing a required command or directory permission"), + } + self._host_capability_cache[cache_key] = (time.monotonic(), result) + return result + + def list_tasks(self, session_token: Optional[str]) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + self._ensure_storage() + self._start_background_sync(session_token, str(operator["id"])) + return self.list_cached_tasks(session_token) + + def list_cached_tasks(self, session_token: Optional[str]) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + self._ensure_storage() + operator_id = str(operator["id"]) + with self._lock: + syncing_task_ids = self._background_syncing_task_ids.get(operator_id, set()).copy() + tasks = [] + for task in self._list_tasks(operator_id): + public_task = self._public_task(task) + public_task["syncing"] = task["task_id"] in syncing_task_ids + tasks.append(public_task) + return { + "tasks": tasks, + } + + def start_sync(self, session_token: Optional[str]) -> dict[str, str]: + operator = self.auth_service._require_authenticated_operator(session_token) + self._ensure_storage() + self._start_background_sync(session_token, str(operator["id"])) + return {"status": "started"} + + def _start_background_sync(self, session_token: Optional[str], operator_id: str) -> None: + with self._lock: + if operator_id in self._background_syncing: + return + self._background_syncing.add(operator_id) + threading.Thread(target=self._sync_operator_tasks_in_background, args=(session_token, operator_id), daemon=True).start() + + def _sync_operator_tasks_in_background(self, session_token: Optional[str], operator_id: str) -> None: + try: + tasks = self._list_tasks(operator_id) + for task in tasks: + if task["target"] == "container": + self._upgrade_local_runner_if_needed(task) + self._sync_task_runs(session_token, task) + host_tasks: dict[str, list[sqlite3.Row]] = {} + for task in tasks: + if task["target"] == "host" and task["profile_id"]: + host_tasks.setdefault(str(task["profile_id"]), []).append(task) + host_threads = [] + for profile_id, grouped_tasks in host_tasks.items(): + host_thread = threading.Thread( + target=self._sync_host_task_group_in_background, + args=(session_token, operator_id, profile_id, grouped_tasks), + daemon=True, + ) + host_thread.start() + host_threads.append(host_thread) + for host_thread in host_threads: + host_thread.join() + finally: + with self._lock: + self._background_syncing.discard(operator_id) + self._background_syncing_task_ids.pop(operator_id, None) + + def _sync_host_task_group_in_background( + self, + session_token: Optional[str], + operator_id: str, + profile_id: str, + grouped_tasks: list[sqlite3.Row], + ) -> None: + task_ids = {str(task["task_id"]) for task in grouped_tasks} + with self._lock: + self._background_syncing_task_ids.setdefault(operator_id, set()).update(task_ids) + try: + self._sync_host_task_runs_batch(session_token, profile_id, grouped_tasks) + except CustomException: + for task in grouped_tasks: + self._write_task(task["task_id"], sync_status="unreachable", updated_at=self._now_iso()) + else: + for task in grouped_tasks: + if task["sync_status"] == "unreachable": + self._write_task(task["task_id"], sync_status="synced", updated_at=self._now_iso()) + finally: + with self._lock: + syncing_task_ids = self._background_syncing_task_ids.get(operator_id) + if syncing_task_ids is not None: + syncing_task_ids.difference_update(task_ids) + + def create_task(self, session_token: Optional[str], payload: dict[str, Any]) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + normalized = self._normalize_payload(payload, require_upload_content=True) + capability = self._host_capability_or_none(session_token, normalized) + now = self._now_iso() + task = { + "task_id": str(uuid.uuid4()), + "operator_id": operator["id"], + "name": normalized["name"], + "target": normalized["target"], + "profile_id": normalized["profile_id"], + "schedule": normalized["schedule"], + "timezone": capability.get("timezone") if capability else self._platform_timezone(), + "command": normalized["command"], + "execution_mode": normalized["execution_mode"], + "script_path": normalized["script_path"], + "script_name": normalized["script_name"], + "timeout_seconds": normalized["timeout_seconds"], + "retry_count": normalized["retry_count"], + "enabled": int(normalized["enabled"]), + "last_run_at": None, + "last_status": "never", + "sync_status": "synced", + "next_run_at": self._next_run(normalized["schedule"]), + "created_at": now, + "updated_at": now, + } + with self._lock: + self._ensure_storage() + if self._task_name_exists(operator["id"], task["name"]): + raise CustomException(409, "Scheduled Task Already Exists", "A task with this name already exists") + self._insert_task(task) + if normalized["execution_mode"] == "upload": + self._store_uploaded_script(session_token, self._get_task(operator["id"], task["task_id"]), normalized["script_content"]) + self._sync_or_mark_failed(session_token, task["task_id"]) + return self._public_task(self._get_task(operator["id"], task["task_id"])) + + def update_task(self, session_token: Optional[str], task_id: str, payload: dict[str, Any]) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + normalized = self._normalize_payload(payload) + capability = self._host_capability_or_none(session_token, normalized) + with self._lock: + task = self._get_task(operator["id"], task_id) + if task["name"] != normalized["name"] and self._task_name_exists(operator["id"], normalized["name"]): + raise CustomException(409, "Scheduled Task Already Exists", "A task with this name already exists") + target_changed = task["target"] != normalized["target"] or task["profile_id"] != normalized["profile_id"] + if normalized["execution_mode"] == "upload" and not normalized["script_content"] and task["execution_mode"] != "upload": + raise CustomException(400, "Scheduled Task Script Required", "Upload a script before selecting uploaded script execution") + if normalized["execution_mode"] == "upload" and target_changed and not normalized["script_content"]: + raise CustomException(400, "Scheduled Task Script Required", "Upload the script again after changing the execution target") + script_name = normalized["script_name"] or (task["script_name"] if normalized["execution_mode"] == "upload" else None) + old_host_available = True + if task["target"] == "host": + try: + self._sync_host_tasks(session_token, task["profile_id"], exclude_task_id=task_id) + except CustomException: + old_host_available = False + if target_changed and old_host_available: + self._remove_host_task_files(session_token, task) + elif task["target"] == "container": + self._sync_without_task(task_id) + if target_changed and task["target"] == "container": + for path in (self._runner_path(task_id), self._log_path(task_id), self._state_path(task_id), self._lock_path(task_id), self._uploaded_script_path(task)): + path.unlink(missing_ok=True) + self._write_task( + task_id, + name=normalized["name"], + target=normalized["target"], + profile_id=normalized["profile_id"], + schedule=normalized["schedule"], + timezone=capability.get("timezone") if capability else self._platform_timezone(), + command=normalized["command"], + execution_mode=normalized["execution_mode"], + script_path=normalized["script_path"], + script_name=script_name, + timeout_seconds=normalized["timeout_seconds"], + retry_count=normalized["retry_count"], + enabled=int(normalized["enabled"]), + next_run_at=self._next_run(normalized["schedule"]), + updated_at=self._now_iso(), + ) + updated_task = self._get_task(operator["id"], task_id) + if normalized["execution_mode"] == "upload" and normalized["script_content"]: + self._store_uploaded_script(session_token, updated_task, normalized["script_content"]) + elif task["execution_mode"] == "upload" and normalized["execution_mode"] != "upload": + self._remove_uploaded_script(session_token, task) + self._sync_or_mark_failed(session_token, task_id) + return self._public_task(self._get_task(operator["id"], task_id)) + + def toggle_task(self, session_token: Optional[str], task_id: str, enabled: bool) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + with self._lock: + task = self._get_task(operator["id"], task_id) + self._write_task(task_id, enabled=int(enabled), updated_at=self._now_iso()) + self._sync_or_mark_failed(session_token, task_id) + return self._public_task(self._get_task(operator["id"], task_id)) + + def delete_task(self, session_token: Optional[str], task_id: str) -> None: + operator = self.auth_service._require_authenticated_operator(session_token) + with self._lock: + task = self._get_task(operator["id"], task_id) + if task["target"] == "host": + self._sync_host_tasks(session_token, task["profile_id"], exclude_task_id=task_id) + self._remove_host_task_files(session_token, task) + else: + self._sync_without_task(task_id) + self._delete_task(task_id) + for path in (self._runner_path(task_id), self._log_path(task_id), self._state_path(task_id), self._lock_path(task_id), self._uploaded_script_path(task)): + path.unlink(missing_ok=True) + self._task_logs_dir(task_id).unlink(missing_ok=True) if self._task_logs_dir(task_id).is_file() else None + if self._task_logs_dir(task_id).is_dir(): + subprocess.run(["rm", "-rf", str(self._task_logs_dir(task_id)), str(self._runs_dir(task_id))], check=False) + + def run_task(self, session_token: Optional[str], task_id: str) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + with self._lock: + task = self._get_task(operator["id"], task_id) + if task["sync_status"] != "synced": + self._sync_or_mark_failed(session_token, task_id) + task = self._get_task(operator["id"], task_id) + if task["sync_status"] != "synced": + raise CustomException(503, "Scheduled Task Sync Failed", "The task could not be synchronized before running") + if task["target"] == "host": + self._run_host_task(session_token, task) + else: + runner = self._runner_path(task_id) + if not runner.is_file(): + raise CustomException(503, "Scheduled Task Runner Missing", "The task runner is unavailable") + subprocess.Popen([str(runner), "manual"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + self._write_task(task_id, last_status="running", last_run_at=self._now_iso(), updated_at=self._now_iso()) + return {"task_id": task_id, "status": "started"} + + def refresh_status(self, session_token: Optional[str], task_id: str) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + with self._lock: + task = self._get_task(operator["id"], task_id) + if task["target"] == "host": + self._sync_or_mark_failed(session_token, task_id) + task = self._get_task(operator["id"], task_id) + if task["sync_status"] == "synced": + self._sync_task_runs(session_token, task) + return self._public_task(self._get_task(operator["id"], task_id)) + + def list_runs(self, session_token: Optional[str], task_id: str, offset: int = 0, limit: int = 20) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + self._get_task(operator["id"], task_id) + bounded_offset = max(0, offset) + bounded_limit = max(1, min(100, limit)) + with self._db_connect() as connection: + total = connection.execute("SELECT COUNT(*) FROM scheduled_task_runs WHERE task_id = ?", (task_id,)).fetchone()[0] + rows = connection.execute( + "SELECT run_id, task_id, started_at, finished_at, status, exit_code, trigger, log_path FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?", + (task_id, bounded_limit, bounded_offset), + ).fetchall() + return {"runs": [dict(row) for row in rows], "total": total, "offset": bounded_offset, "limit": bounded_limit} + + def get_run_log(self, session_token: Optional[str], task_id: str, run_id: str, before: Optional[int] = None) -> dict[str, Any]: + operator = self.auth_service._require_authenticated_operator(session_token) + task = self._get_task(operator["id"], task_id) + with self._db_connect() as connection: + run = connection.execute("SELECT log_path FROM scheduled_task_runs WHERE task_id = ? AND run_id = ?", (task_id, run_id)).fetchone() + if run is None: + raise CustomException(404, "Scheduled Task Run Not Found", "The requested task execution does not exist") + content = self._read_host_run_log(session_token, task, run["log_path"], before) if task["target"] == "host" else self._read_log_window(Path(run["log_path"]), before) + return content + + def download_run_log(self, session_token: Optional[str], task_id: str, run_id: str) -> bytes: + operator = self.auth_service._require_authenticated_operator(session_token) + task = self._get_task(operator["id"], task_id) + with self._db_connect() as connection: + run = connection.execute("SELECT log_path FROM scheduled_task_runs WHERE task_id = ? AND run_id = ?", (task_id, run_id)).fetchone() + if run is None: + raise CustomException(404, "Scheduled Task Run Not Found", "The requested task execution does not exist") + if task["target"] == "host": + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + content = self._remote_output(client, f"cat -- {shlex.quote(run['log_path'])} 2>/dev/null || true") + return content.encode("utf-8") + try: + return Path(run["log_path"]).read_bytes() + except OSError: + return b"" + + def _normalize_payload(self, payload: dict[str, Any], require_upload_content: bool = False) -> dict[str, Any]: + target = str(payload.get("target") or "container") + profile_id = str(payload.get("profile_id") or "").strip() or None + if target not in {"container", "host"}: + raise CustomException(400, "Invalid Scheduled Task Target", "Task target must be platform container or SSH host") + if target == "container" and profile_id: + raise CustomException(400, "Invalid Scheduled Task Target", "Platform tasks cannot use an SSH host profile") + if target == "host" and not profile_id: + raise CustomException(400, "Host Access Profile Required", "SSH host tasks require a saved host profile") + name = str(payload.get("name") or "").strip() + execution_mode = str(payload.get("execution_mode") or "command") + command = str(payload.get("command") or "").strip() + script_path = str(payload.get("script_path") or "").strip() or None + script_name = Path(str(payload.get("script_name") or "").strip()).name or None + script_content = payload.get("script_content") + timeout_seconds = int(payload.get("timeout_seconds") or 0) + retry_count = int(payload.get("retry_count") or 0) + schedule = str(payload.get("schedule") or "").strip() + if not name: + raise CustomException(400, "Invalid Scheduled Task", "A task name is required") + if execution_mode not in {"command", "path", "upload"}: + raise CustomException(400, "Invalid Scheduled Task", "Execution mode is invalid") + if execution_mode == "command" and (not command or "\x00" in command): + raise CustomException(400, "Invalid Scheduled Task", "A command is required") + if execution_mode == "path" and (not script_path or not script_path.startswith("/") or "\n" in script_path or "\x00" in script_path): + raise CustomException(400, "Invalid Scheduled Task", "Script path must be an absolute path") + if execution_mode == "upload" and ((require_upload_content and not script_content) or (script_content is not None and (not isinstance(script_content, str) or not script_content.strip()))): + raise CustomException(400, "Invalid Scheduled Task", "Uploaded script content is invalid") + if len(name) > 64 or len(command) > 4096 or (script_content is not None and len(script_content) > 524288): + raise CustomException(400, "Invalid Scheduled Task", "Task input exceeds its maximum length") + if timeout_seconds < 0 or timeout_seconds > 86400: + raise CustomException(400, "Invalid Scheduled Task", "Timeout must be between 0 and 86400 seconds") + if retry_count < 0 or retry_count > 10: + raise CustomException(400, "Invalid Scheduled Task", "Retry count must be between 0 and 10") + if len(schedule.split()) != 5 or "\n" in schedule: + raise CustomException(400, "Invalid Schedule", "Schedule must be a five-field cron expression") + try: + croniter(schedule, datetime.now()) + except (TypeError, ValueError) as exc: + raise CustomException(400, "Invalid Schedule", "Schedule must be a valid five-field cron expression") from exc + return {"name": name, "target": target, "profile_id": profile_id, "command": command if execution_mode == "command" else "", "execution_mode": execution_mode, "script_path": script_path if execution_mode == "path" else None, "script_name": script_name if execution_mode == "upload" else None, "script_content": script_content if execution_mode == "upload" else None, "timeout_seconds": timeout_seconds, "retry_count": retry_count, "schedule": schedule, "enabled": bool(payload.get("enabled", True))} + + def _host_capability_or_none(self, session_token: Optional[str], payload: dict[str, Any]) -> Optional[dict[str, Any]]: + if payload["target"] != "host": + return None + try: + capability = self.check_host_capability(session_token, str(payload["profile_id"])) + except CustomException: + return None + return capability if capability["capability_status"] == "ready" else None + + def _sync_or_mark_failed(self, session_token: Optional[str], task_id: str) -> None: + task = self._get_task_by_id(task_id) + if task["target"] == "host": + try: + capability = self.check_host_capability(session_token, str(task["profile_id"])) + except CustomException: + self._write_task(task_id, sync_status="unreachable", updated_at=self._now_iso()) + return + if capability["capability_status"] != "ready": + self._write_task(task_id, sync_status="failed", updated_at=self._now_iso()) + return + self._write_task(task_id, timezone=capability["timezone"], updated_at=self._now_iso()) + try: + task = self._get_task_by_id(task_id) + if task["target"] == "host": + self._sync_host_tasks(session_token, task["profile_id"]) + else: + self._sync() + except CustomException: + self._write_task(task_id, sync_status="unreachable", updated_at=self._now_iso()) + except Exception: + self._write_task(task_id, sync_status="failed", updated_at=self._now_iso()) + else: + self._write_task(task_id, sync_status="synced", updated_at=self._now_iso()) + + def _sync(self) -> None: + self._sync_tasks([task for task in self._list_enabled_tasks() if task["target"] == "container"]) + + def _sync_without_task(self, task_id: str) -> None: + tasks = [task for task in self._list_enabled_tasks() if task["target"] == "container" and task["task_id"] != task_id] + self._sync_tasks(tasks) + + def _sync_host_tasks(self, session_token: Optional[str], profile_id: Optional[str], exclude_task_id: Optional[str] = None) -> None: + if not profile_id: + raise CustomException(503, "Scheduled Task Host Unavailable", "The SSH host profile is unavailable") + profile = self.host_access_service.get_connection_profile(session_token, profile_id=profile_id) + tasks = self._list_enabled_host_tasks(profile_id, exclude_task_id) + with self.host_access_service._open_file_client(profile) as client: + home = self._remote_home(client) + for task in tasks: + self._write_host_runner(client, task, home) + existing_crontab = self._remote_output(client, "crontab -l 2>/dev/null || true") + block = self._host_cron_block(profile_id, tasks, home) + updated_crontab = self._replace_host_cron_block(existing_crontab, profile_id, block) + self._write_host_crontab(client, home, profile_id, updated_crontab) + + def _list_enabled_host_tasks(self, profile_id: str, exclude_task_id: Optional[str]) -> list[sqlite3.Row]: + with self._db_connect() as connection: + rows = connection.execute( + "SELECT * FROM scheduled_tasks WHERE target = 'host' AND profile_id = ? AND enabled = 1 ORDER BY created_at ASC", (profile_id,) + ).fetchall() + return [task for task in rows if task["task_id"] != exclude_task_id] + + def _remote_home(self, client: Any) -> str: + home = self._remote_output(client, "printf '%s' \"$HOME\"").strip() + if not home.startswith("/"): + raise CustomException(503, "Scheduled Task Host Unavailable", "The SSH host did not provide a usable home directory") + return home + + def _write_host_runner(self, client: Any, task: sqlite3.Row, home: str) -> None: + paths = self._host_paths(home, task["task_id"]) + command = ( + f"mkdir -p {shlex.quote(paths['scripts_dir'])} {shlex.quote(paths['logs_task_dir'])} {shlex.quote(paths['runs_dir'])} {shlex.quote(paths['states_dir'])} {shlex.quote(paths['uploads_dir'])}\n" + f"cat > {shlex.quote(paths['runner'])} <<'WEBSOFT9_TASK_RUNNER'\n" + f"{self._runner_content(paths['state'], paths['lock'], paths['logs_task_dir'], paths['runs_dir'], task['task_id'], self._task_command(task, paths['upload']), task['timeout_seconds'], task['retry_count'])}" + "WEBSOFT9_TASK_RUNNER\n" + f"chmod 700 {shlex.quote(paths['runner'])}" + ) + self._run_remote(client, command, "Scheduled Task Sync Failed", "Unable to write the remote task runner") + + def _host_cron_block(self, profile_id: str, tasks: list[sqlite3.Row], home: str) -> str: + start = f"# >>> websoft9-tasks:{profile_id}" + end = f"# <<< websoft9-tasks:{profile_id}" + lines = [start] + for task in tasks: + lines.append(f"{task['schedule']} {self._host_paths(home, task['task_id'])['runner']}") + lines.append(end) + return "\n".join(lines) + + @staticmethod + def _replace_host_cron_block(existing: str, profile_id: str, block: str) -> str: + start = f"# >>> websoft9-tasks:{profile_id}" + end = f"# <<< websoft9-tasks:{profile_id}" + lines = existing.splitlines() + kept: list[str] = [] + inside_block = False + for line in lines: + if line == start: + if inside_block: + raise CustomException(503, "Scheduled Task Sync Failed", "The remote crontab contains nested Websoft9 task blocks") + inside_block = True + continue + if line == end: + if not inside_block: + raise CustomException(503, "Scheduled Task Sync Failed", "The remote crontab contains an unmatched Websoft9 task block marker") + inside_block = False + continue + if not inside_block: + kept.append(line) + if inside_block: + raise CustomException(503, "Scheduled Task Sync Failed", "The remote crontab contains an incomplete Websoft9 task block") + while kept and not kept[-1].strip(): + kept.pop() + return "\n".join([*kept, block, ""]) + + def _write_host_crontab(self, client: Any, home: str, profile_id: str, content: str) -> None: + temporary = f"{home}/.local/state/websoft9/scheduled-tasks/.crontab-{profile_id}" + command = ( + f"cat > {shlex.quote(temporary)} <<'WEBSOFT9_CRONTAB'\n{content}WEBSOFT9_CRONTAB\n" + f"crontab {shlex.quote(temporary)} && rm -f {shlex.quote(temporary)}" + ) + self._run_remote(client, command, "Scheduled Task Sync Failed", "Unable to update the remote crontab") + + def _run_host_task(self, session_token: Optional[str], task: sqlite3.Row) -> None: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + runner = self._host_paths(self._remote_home(client), task["task_id"])["runner"] + self._run_remote(client, f"nohup {shlex.quote(runner)} manual >/dev/null 2>&1 &", "Scheduled Task Run Failed", "Unable to start the remote task") + + def _remove_host_task_files(self, session_token: Optional[str], task: sqlite3.Row) -> None: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + paths = self._host_paths(self._remote_home(client), task["task_id"]) + self._run_remote(client, f"rm -rf {shlex.quote(paths['runner'])} {shlex.quote(paths['logs_task_dir'])} {shlex.quote(paths['runs_dir'])} {shlex.quote(paths['state'])} {shlex.quote(paths['lock'])} {shlex.quote(paths['upload'])}", "Scheduled Task Delete Failed", "Unable to remove remote task files") + + def _store_uploaded_script(self, session_token: Optional[str], task: sqlite3.Row, content: Optional[str]) -> None: + if content is None: + return + if task["target"] == "container": + script_path = self._uploaded_script_path(task) + script_path.parent.mkdir(parents=True, exist_ok=True) + script_path.write_text(content, encoding="utf-8") + script_path.chmod(0o700) + return + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + paths = self._host_paths(self._remote_home(client), task["task_id"]) + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + self._run_remote(client, f"mkdir -p {shlex.quote(paths['uploads_dir'])} && printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(paths['upload'])} && chmod 700 {shlex.quote(paths['upload'])}", "Scheduled Task Upload Failed", "Unable to write the remote task script") + + def _remove_uploaded_script(self, session_token: Optional[str], task: sqlite3.Row) -> None: + if task["target"] == "container": + self._uploaded_script_path(task).unlink(missing_ok=True) + return + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + upload_path = self._host_paths(self._remote_home(client), task["task_id"])["upload"] + self._run_remote(client, f"rm -f {shlex.quote(upload_path)}", "Scheduled Task Delete Failed", "Unable to remove remote task script") + + def _read_host_state(self, session_token: Optional[str], task: sqlite3.Row) -> dict[str, str]: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + home = self._remote_home(client) + paths = self._host_paths(home, task["task_id"]) + marker = shlex.quote(self._runner_version_marker) + runner = shlex.quote(paths["runner"]) + version_matches = self._remote_output(client, f"grep -Fxq {marker} {runner} 2>/dev/null; printf '%s' $?") + if version_matches.strip() != "0": + self._write_host_runner(client, task, home) + path = paths["state"] + content = self._remote_output(client, f"cat {shlex.quote(path)} 2>/dev/null || true") + return dict(line.split("=", 1) for line in content.splitlines() if "=" in line) + + def _read_host_log(self, session_token: Optional[str], task: sqlite3.Row) -> str: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + path = self._host_paths(self._remote_home(client), task["task_id"])["log"] + return self._remote_output(client, f"tail -n 200 -- {shlex.quote(path)} 2>/dev/null || true") + + def _read_host_run_log(self, session_token: Optional[str], task: sqlite3.Row, log_path: str, before: Optional[int]) -> dict[str, Any]: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + command = f"wc -l < {shlex.quote(log_path)} 2>/dev/null || printf '0'" + total_lines = int(self._remote_output(client, command).strip() or "0") + end_line = min(total_lines, before) if before is not None else total_lines + start_line = max(1, end_line - self._log_read_line_limit + 1) + if end_line < 1: + content = "" + else: + content = self._remote_output(client, f"sed -n '{start_line},{end_line}p' {shlex.quote(log_path)} 2>/dev/null | head -c {self._log_read_byte_limit}") + return {"content": content, "next_before": start_line - 1 if start_line > 1 else None} + + def _read_log_window(self, path: Path, before: Optional[int]) -> dict[str, Any]: + if not path.is_file(): + return {"content": "", "next_before": None} + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + end_line = min(len(lines), before) if before is not None else len(lines) + start_line = max(0, end_line - self._log_read_line_limit) + content = "\n".join(lines[start_line:end_line]).encode("utf-8")[: self._log_read_byte_limit].decode("utf-8", errors="ignore") + return {"content": content, "next_before": start_line if start_line else None} + + def _sync_task_runs(self, session_token: Optional[str], task: sqlite3.Row) -> None: + records = self._read_host_runs(session_token, task) if task["target"] == "host" else self._read_local_runs(task["task_id"]) + self._sync_task_run_records(task, records) + + def _sync_task_run_records(self, task: sqlite3.Row, records: list[dict[str, Any]]) -> None: + if not records: + return + with self._db_connect() as connection: + for record in records: + if record.get("task_id") != task["task_id"] or not record.get("run_id"): + continue + connection.execute( + "INSERT INTO scheduled_task_runs (run_id, task_id, started_at, finished_at, status, exit_code, trigger, log_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id) DO UPDATE SET finished_at = excluded.finished_at, status = excluded.status, exit_code = excluded.exit_code, log_path = excluded.log_path", + (record["run_id"], task["task_id"], record.get("started_at"), record.get("finished_at"), record.get("status", "running"), record.get("exit_code"), record.get("trigger", "cron"), record.get("log_path", "")), + ) + latest = connection.execute("SELECT status, COALESCE(finished_at, started_at) AS run_at FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT 1", (task["task_id"],)).fetchone() + connection.commit() + if latest: + self._write_task(task["task_id"], last_status=latest["status"], last_run_at=latest["run_at"], updated_at=self._now_iso()) + self._prune_run_index(task["task_id"]) + + def _sync_host_task_runs_batch(self, session_token: Optional[str], profile_id: str, tasks: list[sqlite3.Row]) -> None: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=profile_id) + with self.host_access_service._open_file_client(profile) as client: + home = self._remote_home(client) + run_dirs = " ".join(shlex.quote(self._host_paths(home, task["task_id"])["runs_dir"]) for task in tasks) + content = self._remote_output(client, f"for run_dir in {run_dirs}; do for record in \"$run_dir\"/*.json; do [ -f \"$record\" ] && cat \"$record\"; done; done; true") + records_by_task: dict[str, list[dict[str, Any]]] = {str(task["task_id"]): [] for task in tasks} + for line in content.splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + task_id = str(record.get("task_id") or "") + if task_id in records_by_task: + records_by_task[task_id].append(record) + for task in tasks: + self._sync_task_run_records(task, records_by_task[str(task["task_id"])]) + + def _read_local_runs(self, task_id: str) -> list[dict[str, Any]]: + records = [] + for path in self._runs_dir(task_id).glob("*.json"): + try: + records.append(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + continue + return records + + def _read_host_runs(self, session_token: Optional[str], task: sqlite3.Row) -> list[dict[str, Any]]: + profile = self.host_access_service.get_connection_profile(session_token, profile_id=task["profile_id"]) + with self.host_access_service._open_file_client(profile) as client: + home = self._remote_home(client) + paths = self._host_paths(home, task["task_id"]) + marker = shlex.quote(self._runner_version_marker) + runner = shlex.quote(paths["runner"]) + if self._remote_output(client, f"grep -Fxq {marker} {runner} 2>/dev/null; printf '%s' $?").strip() != "0": + self._write_host_runner(client, task, home) + content = self._remote_output(client, f"for record in {shlex.quote(paths['runs_dir'])}/*.json; do [ -f \"$record\" ] && cat \"$record\"; done; true") + records = [] + for line in content.splitlines(): + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + return records + + def _prune_run_index(self, task_id: str) -> None: + cutoff = datetime.now(timezone.utc).timestamp() - self._run_retention_days * 86400 + with self._db_connect() as connection: + rows = connection.execute("SELECT run_id, started_at FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC", (task_id,)).fetchall() + stale = [row["run_id"] for index, row in enumerate(rows) if index >= self._run_retention_count or self._parse_timestamp(row["started_at"]) < cutoff] + if stale: + connection.executemany("DELETE FROM scheduled_task_runs WHERE run_id = ?", [(run_id,) for run_id in stale]) + connection.commit() + + @staticmethod + def _parse_timestamp(value: Optional[str]) -> float: + try: + return datetime.fromisoformat(value or "").timestamp() + except ValueError: + return 0 + + @staticmethod + def _host_paths(home: str, task_id: str) -> dict[str, str]: + root = f"{home}/.local/state/websoft9/scheduled-tasks" + return {"scripts_dir": f"{root}/scripts", "logs_dir": f"{root}/logs", "runs_root": f"{root}/runs", "states_dir": f"{root}/state", "uploads_dir": f"{root}/uploads", "runner": f"{root}/scripts/{task_id}.sh", "upload": f"{root}/uploads/{task_id}.sh", "logs_task_dir": f"{root}/logs/{task_id}", "runs_dir": f"{root}/runs/{task_id}", "state": f"{root}/state/{task_id}.state", "lock": f"{root}/state/{task_id}.lock"} + + def _runner_content(self, state_path: str, lock_path: str, logs_dir: str, runs_dir: str, task_id: str, command: str, timeout_seconds: int = 0, retry_count: int = 0) -> str: + state = shlex.quote(state_path) + lock = shlex.quote(lock_path) + logs = shlex.quote(logs_dir) + runs = shlex.quote(runs_dir) + quoted_task_id = shlex.quote(task_id) + user_command = shlex.quote(command) + execution = f"timeout {int(timeout_seconds)} bash -c {user_command}" if timeout_seconds else f"bash -c {user_command}" + return ( + f"#!/bin/bash\n{self._runner_version_marker}\nset -u\n" + f"STATE={state}\nLOCK={lock}\nLOGS={logs}\nRUNS={runs}\nTASK_ID={quoted_task_id}\n" + "write_state() { printf 'run_id=%s\\nstatus=%s\\nstarted_at=%s\\nfinished_at=%s\\nexit_code=%s\\n' \"$1\" \"$2\" \"$3\" \"$4\" \"$5\" > \"${STATE}.tmp\" && mv \"${STATE}.tmp\" \"$STATE\"; }\n" + "write_log() { printf '[%s] %s\\n' \"$(date -Iseconds)\" \"$1\" >> \"$LOG\"; }\n" + "write_run() { printf '{\"run_id\":\"%s\",\"task_id\":\"%s\",\"started_at\":\"%s\",\"finished_at\":\"%s\",\"status\":\"%s\",\"exit_code\":%s,\"trigger\":\"%s\",\"log_path\":\"%s\"}\\n' \"$run_id\" \"$TASK_ID\" \"$started_at\" \"$1\" \"$2\" \"$3\" \"$trigger\" \"$LOG\" > \"${RUN}.tmp\" && mv \"${RUN}.tmp\" \"$RUN\"; }\n" + "trigger=\"${1:-cron}\"\n" + "run_id=\"$(date +%s%N)-$$\"\nLOG=\"$LOGS/$run_id.log\"\nRUN=\"$RUNS/$run_id.json\"\nstarted_at=$(date -Iseconds)\nmkdir -p \"$LOGS\" \"$RUNS\"\nexec 9>\"$LOCK\"\nif ! flock -n 9; then\n write_log \"SKIPPED trigger=$trigger reason=previous_execution_running\"\n write_run \"$started_at\" skipped 0\n exit 0\nfi\n" + "started_epoch=$(date +%s)\nwrite_state \"$run_id\" running \"$started_at\" \"\" \"\"\nwrite_run \"\" running null\nwrite_log \"START trigger=$trigger\"\n" + "attempt=0\n" + "while true; do\n" + " attempt=$((attempt + 1))\n" + f" {execution} >> \"$LOG\" 2>&1\n" + " exit_code=$?\n" + f" if [ \"$exit_code\" -eq 0 ] || [ \"$attempt\" -gt {int(retry_count)} ]; then break; fi\n" + f" write_log \"RETRY trigger=$trigger attempt=$((attempt + 1))/{int(retry_count) + 1} exit_code=$exit_code\"\n" + "done\n" + "if [ \"$exit_code\" -eq 0 ]; then status=success; else status=failed; fi\nfinished_at=$(date -Iseconds)\nduration=$(( $(date +%s) - started_epoch ))\nwrite_state \"$run_id\" \"$status\" \"$started_at\" \"$finished_at\" \"$exit_code\"\nwrite_log \"END trigger=$trigger status=$status exit_code=$exit_code duration=${duration}s\"\nwrite_run \"$finished_at\" \"$status\" \"$exit_code\"\nfind \"$RUNS\" -type f -name '*.json' -mtime +7 -delete\nfind \"$LOGS\" -type f -name '*.log' -mtime +7 -delete\nls -1t \"$RUNS\"/*.json 2>/dev/null | tail -n +51 | while read -r stale; do rm -f \"$stale\" \"$LOGS/$(basename \"$stale\" .json).log\"; done\nexit \"$exit_code\"\n" + ) + + def _remote_output(self, client: Any, command: str) -> str: + try: + _, stdout, stderr = client.exec_command(command, timeout=15) + exit_code = stdout.channel.recv_exit_status() + output = stdout.read().decode("utf-8", errors="replace") + error_text = stderr.read().decode("utf-8", errors="replace").strip() + except Exception as exc: + raise CustomException(503, "Scheduled Task Host Unavailable", f"Unable to communicate with the SSH host: {exc}") from exc + if exit_code != 0: + raise CustomException(503, "Scheduled Task Host Unavailable", error_text or "The SSH host command failed") + return output + + def _run_remote(self, client: Any, command: str, title: str, prefix: str) -> None: + try: + _, stdout, stderr = client.exec_command(command, timeout=15) + exit_code = stdout.channel.recv_exit_status() + error_text = stderr.read().decode("utf-8", errors="replace").strip() + except Exception as exc: + raise CustomException(503, title, f"{prefix}: {exc}") from exc + if exit_code != 0: + raise CustomException(503, title, f"{prefix}: {error_text or 'remote command failed'}") + + def _sync_tasks(self, tasks: list[sqlite3.Row]) -> None: + self._ensure_storage() + for task in tasks: + self._write_runner(task) + self.cron_file.parent.mkdir(parents=True, exist_ok=True) + previous_contents = self.cron_file.read_bytes() if self.cron_file.exists() else None + previous_mode = self.cron_file.stat().st_mode if self.cron_file.exists() else None + lines = ["SHELL=/bin/bash", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", ""] + for task in tasks: + lines.append(f"{task['schedule']} root {self._runner_path(task['task_id'])}") + temporary = self.cron_file.with_suffix(".tmp") + temporary.write_text("\n".join(lines) + "\n", encoding="utf-8") + temporary.chmod(0o644) + temporary.replace(self.cron_file) + try: + self._cron_reloader() + except Exception: + if previous_contents is None: + self.cron_file.unlink(missing_ok=True) + else: + rollback = self.cron_file.with_suffix(".rollback") + rollback.write_bytes(previous_contents) + rollback.chmod(previous_mode or 0o644) + rollback.replace(self.cron_file) + try: + self._cron_reloader() + except Exception: + pass + raise + + def _write_runner(self, task: sqlite3.Row) -> None: + self._scripts_dir().mkdir(parents=True, exist_ok=True) + self._task_logs_dir(task["task_id"]).mkdir(parents=True, exist_ok=True) + self._runs_dir(task["task_id"]).mkdir(parents=True, exist_ok=True) + self._states_dir().mkdir(parents=True, exist_ok=True) + runner = self._runner_path(task["task_id"]) + state = shlex.quote(str(self._state_path(task["task_id"]))) + lock = shlex.quote(str(self._lock_path(task["task_id"]))) + log = shlex.quote(str(self._log_path(task["task_id"]))) + command = self._task_command(task, str(self._uploaded_script_path(task))) + runner.write_text( + self._runner_content(str(self._state_path(task["task_id"])), str(self._lock_path(task["task_id"])), str(self._task_logs_dir(task["task_id"])), str(self._runs_dir(task["task_id"])), task["task_id"], command, task["timeout_seconds"], task["retry_count"]), + encoding="utf-8", + ) + runner.chmod(0o700) + + def _upgrade_local_runner_if_needed(self, task: sqlite3.Row) -> None: + runner = self._runner_path(task["task_id"]) + if not runner.is_file() or self._runner_version_marker not in runner.read_text(encoding="utf-8", errors="replace"): + self._write_runner(task) + + def _ensure_storage(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) + with self._db_connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS scheduled_tasks ( + task_id TEXT PRIMARY KEY, + operator_id TEXT NOT NULL, + name TEXT NOT NULL, + target TEXT NOT NULL, + profile_id TEXT, + schedule TEXT NOT NULL, + timezone TEXT NOT NULL, + command TEXT NOT NULL, + execution_mode TEXT NOT NULL DEFAULT 'command', + script_path TEXT, + script_name TEXT, + timeout_seconds INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL, + last_run_at TEXT, + last_status TEXT NOT NULL, + sync_status TEXT NOT NULL, + next_run_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(operator_id, name) + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS scheduled_task_runs ( + run_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + exit_code INTEGER, + trigger TEXT NOT NULL, + log_path TEXT NOT NULL + ) + """ + ) + connection.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task_started ON scheduled_task_runs (task_id, started_at DESC)") + columns = {row[1] for row in connection.execute("PRAGMA table_info(scheduled_tasks)")} + for name, definition in (("execution_mode", "TEXT NOT NULL DEFAULT 'command'"), ("script_path", "TEXT"), ("script_name", "TEXT"), ("timeout_seconds", "INTEGER NOT NULL DEFAULT 0"), ("retry_count", "INTEGER NOT NULL DEFAULT 0")): + if name not in columns: + connection.execute(f"ALTER TABLE scheduled_tasks ADD COLUMN {name} {definition}") + connection.commit() + + def _db_connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(str(self.database_file)) + connection.row_factory = sqlite3.Row + return connection + + def _insert_task(self, task: dict[str, Any]) -> None: + with self._db_connect() as connection: + connection.execute( + """ + INSERT INTO scheduled_tasks ( + task_id, operator_id, name, target, profile_id, schedule, timezone, command, execution_mode, script_path, script_name, timeout_seconds, retry_count, enabled, + last_run_at, last_status, sync_status, next_run_at, created_at, updated_at + ) VALUES ( + :task_id, :operator_id, :name, :target, :profile_id, :schedule, :timezone, :command, :execution_mode, :script_path, :script_name, :timeout_seconds, :retry_count, :enabled, + :last_run_at, :last_status, :sync_status, :next_run_at, :created_at, :updated_at + ) + """, + task, + ) + connection.commit() + + def _write_task(self, task_id: str, **fields: Any) -> None: + if not fields: + return + assignments = ", ".join(f"{name} = ?" for name in fields) + with self._db_connect() as connection: + connection.execute(f"UPDATE scheduled_tasks SET {assignments} WHERE task_id = ?", [*fields.values(), task_id]) + connection.commit() + + def _delete_task(self, task_id: str) -> None: + with self._db_connect() as connection: + connection.execute("DELETE FROM scheduled_tasks WHERE task_id = ?", (task_id,)) + connection.commit() + + def _get_task(self, operator_id: str, task_id: str) -> sqlite3.Row: + self._ensure_storage() + with self._db_connect() as connection: + task = connection.execute( + "SELECT * FROM scheduled_tasks WHERE operator_id = ? AND task_id = ?", (operator_id, task_id) + ).fetchone() + if task is None: + raise CustomException(404, "Scheduled Task Not Found", "The requested task does not exist") + return task + + def _get_task_by_id(self, task_id: str) -> sqlite3.Row: + self._ensure_storage() + with self._db_connect() as connection: + task = connection.execute("SELECT * FROM scheduled_tasks WHERE task_id = ?", (task_id,)).fetchone() + if task is None: + raise CustomException(404, "Scheduled Task Not Found", "The requested task does not exist") + return task + + def _list_tasks(self, operator_id: str) -> list[sqlite3.Row]: + with self._db_connect() as connection: + return connection.execute( + "SELECT * FROM scheduled_tasks WHERE operator_id = ? ORDER BY created_at DESC", (operator_id,) + ).fetchall() + + def _list_enabled_tasks(self) -> list[sqlite3.Row]: + with self._db_connect() as connection: + return connection.execute("SELECT * FROM scheduled_tasks WHERE enabled = 1 ORDER BY created_at ASC").fetchall() + + def _task_name_exists(self, operator_id: str, name: str) -> bool: + with self._db_connect() as connection: + return connection.execute( + "SELECT 1 FROM scheduled_tasks WHERE operator_id = ? AND name = ?", (operator_id, name) + ).fetchone() is not None + + def _next_run(self, schedule: str, timezone_name: Optional[str] = None) -> str: + try: + current_time = datetime.now(ZoneInfo(timezone_name or "UTC")) + except (ZoneInfoNotFoundError, ValueError): + current_time = datetime.now(timezone.utc) + return croniter(schedule, current_time).get_next(datetime).astimezone(timezone.utc).isoformat() + + @staticmethod + def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + @staticmethod + def _platform_timezone() -> str: + return os.getenv("TZ") or "UTC" + + def _scripts_dir(self) -> Path: + return self.data_dir / "scripts" + + def _uploads_dir(self) -> Path: + return self.data_dir / "uploads" + + def _logs_dir(self) -> Path: + return self.data_dir / "logs" + + def _task_logs_dir(self, task_id: str) -> Path: + return self._logs_dir() / task_id + + def _runs_dir(self, task_id: str) -> Path: + return self.data_dir / "runs" / task_id + + def _states_dir(self) -> Path: + return self.data_dir / "state" + + def _runner_path(self, task_id: str) -> Path: + return self._scripts_dir() / f"{task_id}.sh" + + def _log_path(self, task_id: str) -> Path: + return self._logs_dir() / f"{task_id}.log" + + def _state_path(self, task_id: str) -> Path: + return self._states_dir() / f"{task_id}.state" + + def _lock_path(self, task_id: str) -> Path: + return self._states_dir() / f"{task_id}.lock" + + def _uploaded_script_path(self, task: sqlite3.Row) -> Path: + return self._uploads_dir() / f"{task['task_id']}.sh" + + def _task_command(self, task: sqlite3.Row, uploaded_script_path: Optional[str] = None) -> str: + if task["execution_mode"] == "path": + return f"bash -- {shlex.quote(task['script_path'])}" + if task["execution_mode"] == "upload": + return f"bash -- {shlex.quote(uploaded_script_path or str(self._uploaded_script_path(task)))}" + return task["command"] + + def _read_state(self, task_id: str) -> dict[str, str]: + state_path = self._state_path(task_id) + if not state_path.is_file(): + return {} + return dict(line.split("=", 1) for line in state_path.read_text(encoding="utf-8").splitlines() if "=" in line) + + def _public_task(self, task: sqlite3.Row) -> dict[str, Any]: + return { + "task_id": task["task_id"], "name": task["name"], "target": task["target"], + "profile_id": task["profile_id"], "schedule": task["schedule"], "timezone": task["timezone"], + "command": task["command"], "execution_mode": task["execution_mode"], "script_path": task["script_path"], "script_name": task["script_name"], "timeout_seconds": task["timeout_seconds"], "retry_count": task["retry_count"], "enabled": bool(task["enabled"]), "last_run_at": task["last_run_at"], + "last_status": task["last_status"], "sync_status": task["sync_status"], "next_run_at": self._next_run(task["schedule"], task["timezone"]), + "created_at": task["created_at"], "updated_at": task["updated_at"], + } + + @staticmethod + def _reload_cron() -> None: + config_path = os.getenv("WEBSOFT9_SUPERVISOR_CONFIG", "/etc/supervisor/conf.d/websoft9-platform.conf") + subprocess.run(["supervisorctl", "-c", config_path, "restart", "cron"], check=True, capture_output=True, text=True) \ No newline at end of file diff --git a/apphub/tests/test_scheduled_tasks.py b/apphub/tests/test_scheduled_tasks.py new file mode 100644 index 000000000..9ab4440c7 --- /dev/null +++ b/apphub/tests/test_scheduled_tasks.py @@ -0,0 +1,642 @@ +import subprocess +import sys +import threading +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.api.v1.routers import scheduled_tasks as scheduled_tasks_router +from src.core.exception import CustomException +from src.schemas.errorResponse import ErrorResponse +from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME +from src.services.scheduled_tasks import ScheduledTaskService +from fastapi.responses import JSONResponse + + +class FakeAuthService: + def _require_authenticated_operator(self, session_token): + if session_token != "valid-session": + raise CustomException(401, "Authentication Required", "Login required") + return {"id": "operator-1"} + + +class FakeHostClient: + def __init__(self, output="Asia/Shanghai"): + self.output = output + + def exec_command(self, _command, timeout): + output = self.output + + class Channel: + @staticmethod + def recv_exit_status(): + return 0 + + class Output: + channel = Channel() + + @staticmethod + def read(): + return output.encode() + + class Error: + @staticmethod + def read(): + return b"" + + return None, Output(), Error() + + +class FakeHostAccessService: + def __init__(self, timezone_name="Asia/Shanghai"): + self.timezone_name = timezone_name + + def get_connection_profile(self, session_token, profile_id): + assert session_token == "valid-session" + assert profile_id == "profile-1" + return {"profile_id": profile_id} + + class _ClientContext: + def __init__(self, timezone_name): + self.timezone_name = timezone_name + + def __enter__(self): + return FakeHostClient(self.timezone_name) + + def __exit__(self, *_args): + return False + + def _open_file_client(self, _profile): + return self._ClientContext(self.timezone_name) + + +class FakeHostTaskClient: + def __init__(self): + self.commands = [] + + def exec_command(self, command, timeout): + self.commands.append(command) + + class Channel: + @staticmethod + def recv_exit_status(): + return 0 + + class Output: + channel = Channel() + + @staticmethod + def read(): + return b"/home/operator" + + class Error: + @staticmethod + def read(): + return b"" + + return None, Output(), Error() + + +class FakeHostTaskAccessService(FakeHostAccessService): + def __init__(self): + self.client = FakeHostTaskClient() + + class _ClientContext: + def __init__(self, client): + self.client = client + + def __enter__(self): + return self.client + + def __exit__(self, *_args): + return False + + def _open_file_client(self, _profile): + return self._ClientContext(self.client) + + +class RecoveringHostTaskAccessService(FakeHostTaskAccessService): + def __init__(self): + super().__init__() + self.available = False + + def _open_file_client(self, profile): + if not self.available: + raise CustomException(400, "SSH Authentication Failed", "Authentication failed") + return super()._open_file_client(profile) + + +def create_test_app() -> FastAPI: + app = FastAPI() + + @app.exception_handler(CustomException) + async def custom_exception_handler(_request, exc: CustomException): + return JSONResponse(status_code=exc.status_code, content=ErrorResponse(message=exc.message, details=exc.details).model_dump()) + + app.include_router(scheduled_tasks_router.router) + return app + + +@pytest.fixture(autouse=True) +def clear_host_capability_cache(): + ScheduledTaskService._host_capability_cache.clear() + yield + ScheduledTaskService._host_capability_cache.clear() + + +def test_platform_task_crud_renders_cron_and_preserves_operator_isolation(monkeypatch, tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + ) + monkeypatch.setattr(scheduled_tasks_router, "_scheduled_task_service", service) + + with TestClient(create_test_app()) as client: + created = client.post( + "/scheduled-tasks", + headers={"Cookie": f"{PRODUCT_AUTH_COOKIE_NAME}=valid-session"}, + json={"name": "Date", "schedule": "* * * * *", "command": "date", "enabled": True}, + ) + assert created.status_code == 201 + task = created.json() + assert task["sync_status"] == "synced" + assert "* * * * * root" in (tmp_path / "websoft9-tasks").read_text(encoding="utf-8") + assert (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").is_file() + + listed = client.get("/scheduled-tasks", headers={"Cookie": f"{PRODUCT_AUTH_COOKIE_NAME}=valid-session"}) + assert listed.status_code == 200 + assert [item["name"] for item in listed.json()["tasks"]] == ["Date"] + + toggled = client.post( + f"/scheduled-tasks/{task['task_id']}/toggle", + headers={"Cookie": f"{PRODUCT_AUTH_COOKIE_NAME}=valid-session"}, + json={"enabled": False}, + ) + assert toggled.status_code == 200 + assert " root " not in (tmp_path / "websoft9-tasks").read_text(encoding="utf-8") + + deleted = client.delete(f"/scheduled-tasks/{task['task_id']}", headers={"Cookie": f"{PRODUCT_AUTH_COOKIE_NAME}=valid-session"}) + assert deleted.status_code == 204 + assert not (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").exists() + + +def test_platform_task_rejects_profile_on_container_target(monkeypatch, tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None + ) + monkeypatch.setattr(scheduled_tasks_router, "_scheduled_task_service", service) + + with TestClient(create_test_app()) as client: + response = client.post( + "/scheduled-tasks", + headers={"Cookie": f"{PRODUCT_AUTH_COOKIE_NAME}=valid-session"}, + json={"name": "Remote", "target": "container", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}, + ) + + assert response.status_code == 400 + + +def test_platform_task_accepts_multiline_shell_command(tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None + ) + + task = service.create_task("valid-session", {"name": "Multiline", "schedule": "* * * * *", "command": "echo first\necho second"}) + + runner = (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").read_text(encoding="utf-8") + assert "echo first\necho second" in runner + + +def test_delete_restores_cron_file_when_reload_fails(tmp_path): + reload_attempts = [] + + def reload_cron(): + reload_attempts.append(True) + if len(reload_attempts) == 2: + raise RuntimeError("cron reload failed") + + cron_file = tmp_path / "websoft9-tasks" + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), cron_file=str(cron_file), auth_service=FakeAuthService(), cron_reloader=reload_cron + ) + task = service.create_task("valid-session", {"name": "Date", "schedule": "* * * * *", "command": "date"}) + + try: + service.delete_task("valid-session", task["task_id"]) + except RuntimeError as exc: + assert str(exc) == "cron reload failed" + else: + raise AssertionError("Expected cron reload failure") + + assert task["task_id"] in cron_file.read_text(encoding="utf-8") + assert service.list_tasks("valid-session")["tasks"][0]["task_id"] == task["task_id"] + + +def test_public_task_always_returns_a_future_next_run(tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None + ) + task = service.create_task("valid-session", {"name": "Date", "schedule": "* * * * *", "command": "date"}) + + assert task["next_run_at"] > service._now_iso() + + +def test_task_list_orders_by_creation_time_descending(tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None + ) + first = service.create_task("valid-session", {"name": "First", "schedule": "* * * * *", "command": "date"}) + second = service.create_task("valid-session", {"name": "Second", "schedule": "* * * * *", "command": "date"}) + service._write_task(first["task_id"], updated_at="2099-01-01T00:00:00+00:00") + + tasks = service.list_tasks("valid-session")["tasks"] + + assert [task["task_id"] for task in tasks] == [second["task_id"], first["task_id"]] + + +def test_host_capability_reuses_saved_host_access_profile(tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=FakeHostAccessService(), + ) + + result = service.check_host_capability("valid-session", "profile-1") + + assert result["capability_status"] == "ready" + assert result["timezone"] == "Asia/Shanghai" + assert all(check["ok"] for check in result["checks"]) + + +def test_host_task_saves_while_unreachable_and_refresh_resynchronizes(tmp_path): + host_access_service = RecoveringHostTaskAccessService() + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=host_access_service, + ) + + task = service.create_task("valid-session", {"name": "Remote", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}) + + assert task["sync_status"] == "unreachable" + host_access_service.available = True + + refreshed = service.refresh_status("valid-session", task["task_id"]) + + assert refreshed["sync_status"] == "synced" + + +def test_background_sync_runs_different_host_profiles_concurrently(monkeypatch, tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + tasks = [ + {"task_id": "task-1", "target": "host", "profile_id": "profile-1", "sync_status": "synced"}, + {"task_id": "task-2", "target": "host", "profile_id": "profile-2", "sync_status": "synced"}, + ] + both_started = threading.Event() + release_syncs = threading.Event() + started_profiles = set() + started_lock = threading.Lock() + + monkeypatch.setattr(service, "_list_tasks", lambda _operator_id: tasks) + + def sync_host_group(_session_token, profile_id, _grouped_tasks): + with started_lock: + started_profiles.add(profile_id) + if len(started_profiles) == 2: + both_started.set() + release_syncs.wait(timeout=1) + + monkeypatch.setattr(service, "_sync_host_task_runs_batch", sync_host_group) + worker = threading.Thread(target=service._sync_operator_tasks_in_background, args=("valid-session", "operator-1")) + worker.start() + + assert both_started.wait(timeout=0.5) + release_syncs.set() + worker.join(timeout=1) + assert not worker.is_alive() +def test_host_capability_uses_utc_for_non_iana_timezone(tmp_path): + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=FakeHostAccessService("EDT"), + ) + + result = service.check_host_capability("valid-session", "profile-1") + + assert result["timezone"] == "UTC" + + +def test_host_capability_closes_a_timed_out_remote_command(monkeypatch, tmp_path): + class HangingChannel: + def __init__(self): + self.closed = False + + def exit_status_ready(self): + return False + + def close(self): + self.closed = True + + class HangingClient: + def __init__(self): + self.channel = HangingChannel() + + def exec_command(self, _command, timeout): + class Output: + def __init__(self, channel): + self.channel = channel + + @staticmethod + def read(): + return b"" + + class Error: + @staticmethod + def read(): + return b"" + + return None, Output(self.channel), Error() + + class ClientContext: + def __init__(self, client): + self.client = client + + def __enter__(self): + return self.client + + def __exit__(self, *_args): + return False + + host_access = FakeHostAccessService() + client = HangingClient() + monkeypatch.setattr(host_access, "_open_file_client", lambda _profile: ClientContext(client)) + monotonic_values = iter([0.0, 0.0, 16.0]) + monkeypatch.setattr("src.services.scheduled_tasks.time.monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr("src.services.scheduled_tasks.time.sleep", lambda _seconds: None) + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None, host_access_service=host_access) + + try: + service.check_host_capability("valid-session", "profile-1") + except CustomException as exc: + assert exc.status_code == 503 + else: + raise AssertionError("Expected the hanging capability command to time out") + + assert client.channel.closed + + +def test_host_crontab_rejects_unmatched_or_nested_profile_markers(): + block = "# >>> websoft9-tasks:profile-1\n# <<< websoft9-tasks:profile-1" + malformed_inputs = [ + "# <<< websoft9-tasks:profile-1", + "# >>> websoft9-tasks:profile-1\n# >>> websoft9-tasks:profile-1\n# <<< websoft9-tasks:profile-1", + ] + + for existing in malformed_inputs: + try: + ScheduledTaskService._replace_host_cron_block(existing, "profile-1", block) + except CustomException as exc: + assert exc.status_code == 503 + else: + raise AssertionError("Expected malformed crontab markers to be rejected") + + +def test_host_runner_overlap_does_not_overwrite_active_state(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + + runner = service._runner_content("/tmp/task.state", "/tmp/task.lock", "/tmp/task.logs", "/tmp/task.runs", "task-1", "sleep 1") + + assert "# websoft9-task-runner-version: 4" in runner + assert "write_state skipped" not in runner + assert "SKIPPED trigger=$trigger reason=previous_execution_running" in runner + + +def test_container_runner_overlap_does_not_overwrite_active_state(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "Overlap", "schedule": "* * * * *", "command": "sleep 1"}) + + runner = (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").read_text(encoding="utf-8") + + assert "write_state skipped" not in runner + assert "SKIPPED trigger=$trigger reason=previous_execution_running" in runner + + +def test_container_task_supports_script_path_timeout_and_retry(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + + task = service.create_task( + "valid-session", + {"name": "Path", "schedule": "* * * * *", "execution_mode": "path", "script_path": "/opt/jobs/backup.sh", "timeout_seconds": 60, "retry_count": 2}, + ) + + runner = (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").read_text(encoding="utf-8") + assert task["execution_mode"] == "path" + assert task["timeout_seconds"] == 60 + assert task["retry_count"] == 2 + assert "timeout 60 bash -c" in runner + assert "RETRY trigger=$trigger attempt=$((attempt + 1))/3 exit_code=$exit_code" in runner + assert "bash -- /opt/jobs/backup.sh" in runner + + +def test_container_runner_writes_execution_boundaries(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "Boundaries", "schedule": "* * * * *", "command": "printf 'task output\\n'"}) + + result = subprocess.run([str(tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh"), "manual"], capture_output=True, text=True) + log = next((tmp_path / "tasks" / "logs" / task["task_id"]).glob("*.log")).read_text(encoding="utf-8") + + assert result.returncode == 0 + assert "START trigger=manual" in log + assert "task output" in log + assert "END trigger=manual status=success exit_code=0 duration=" in log + + +def test_container_runner_logs_retry_and_failure(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "Retry", "schedule": "* * * * *", "command": "exit 7", "retry_count": 1}) + + result = subprocess.run([str(tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh"), "manual"], capture_output=True, text=True) + log = next((tmp_path / "tasks" / "logs" / task["task_id"]).glob("*.log")).read_text(encoding="utf-8") + + assert result.returncode == 7 + assert "RETRY trigger=manual attempt=2/2 exit_code=7" in log + assert "END trigger=manual status=failed exit_code=7 duration=" in log + + +def test_container_run_history_indexes_individual_log(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "History", "schedule": "* * * * *", "command": "printf 'history output\\n'"}) + + subprocess.run([str(tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh"), "manual"], check=True) + service.refresh_status("valid-session", task["task_id"]) + runs = service.list_runs("valid-session", task["task_id"])["runs"] + log = service.get_run_log("valid-session", task["task_id"], runs[0]["run_id"]) + + assert len(runs) == 1 + assert runs[0]["status"] == "success" + assert runs[0]["trigger"] == "manual" + assert "history output" in log["content"] + + +def test_download_run_log_returns_an_attachment(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "Download", "schedule": "* * * * *", "command": "printf 'download output\\n'"}) + subprocess.run([str(tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh"), "manual"], check=True) + service.refresh_status("valid-session", task["task_id"]) + run = service.list_runs("valid-session", task["task_id"])["runs"][0] + + content = service.download_run_log("valid-session", task["task_id"], run["run_id"]) + + assert b"download output" in content + + +def test_list_runs_reads_sqlite_without_synchronizing_source_files(monkeypatch, tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + task = service.create_task("valid-session", {"name": "Cached history", "schedule": "* * * * *", "command": "date"}) + + monkeypatch.setattr(service, "_sync_task_runs", lambda *_args: (_ for _ in ()).throw(AssertionError("list_runs must not synchronize source files"))) + + result = service.list_runs("valid-session", task["task_id"]) + + assert result == {"runs": [], "total": 0, "offset": 0, "limit": 20} + + +def test_container_task_stores_uploaded_script(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + + task = service.create_task( + "valid-session", + {"name": "Upload", "schedule": "* * * * *", "execution_mode": "upload", "script_name": "backup.sh", "script_content": "#!/bin/bash\necho backup"}, + ) + + uploaded_script = tmp_path / "tasks" / "uploads" / f"{task['task_id']}.sh" + assert task["execution_mode"] == "upload" + assert task["script_name"] == "backup.sh" + assert uploaded_script.read_text(encoding="utf-8") == "#!/bin/bash\necho backup" + + +def test_new_uploaded_task_requires_script_content(tmp_path): + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None) + + try: + service.create_task("valid-session", {"name": "Missing upload", "schedule": "* * * * *", "execution_mode": "upload"}) + except CustomException as exc: + assert exc.status_code == 400 + else: + raise AssertionError("Expected an uploaded task without content to be rejected") + + +def test_host_task_writes_profile_scoped_runner_and_crontab(tmp_path): + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=host_access, + ) + + task = service.create_task( + "valid-session", + {"name": "Remote", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}, + ) + + commands = "\n".join(host_access.client.commands) + assert task["target"] == "host" + assert task["profile_id"] == "profile-1" + assert f"# >>> websoft9-tasks:profile-1" in commands + assert f"{task['task_id']}.sh" in commands + + +def test_list_tasks_refreshes_ssh_task_execution_status(monkeypatch, tmp_path): + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=host_access, + ) + task = service.create_task( + "valid-session", + {"name": "Remote status", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}, + ) + monkeypatch.setattr( + service, + "_read_host_runs", + lambda *_args: [{"run_id": "remote-run-1", "task_id": task["task_id"], "status": "success", "started_at": "2026-08-20T08:00:00+00:00", "finished_at": "2026-08-20T08:00:01+00:00", "exit_code": 0, "trigger": "cron", "log_path": "/remote/run.log"}], + ) + + listed_task = service.list_tasks("valid-session")["tasks"][0] + + assert listed_task["task_id"] == task["task_id"] + assert listed_task["last_status"] == "success" + assert listed_task["last_run_at"] == "2026-08-20T08:00:01+00:00" + + +def test_host_run_sync_accepts_an_empty_remote_runs_directory(tmp_path): + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(tmp_path / "websoft9-tasks"), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=host_access, + ) + task = service.create_task( + "valid-session", + {"name": "Empty remote history", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}, + ) + + service.list_tasks("valid-session") + + assert service._get_task("operator-1", task["task_id"])["sync_status"] == "synced" + + +def test_switching_away_from_host_removes_remote_task_files(tmp_path): + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None, host_access_service=host_access) + task = service.create_task("valid-session", {"name": "Move", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}) + + service.update_task("valid-session", task["task_id"], {"name": "Move", "target": "container", "schedule": "* * * * *", "command": "date"}) + + assert f"rm -rf /home/operator/.local/state/websoft9/scheduled-tasks/scripts/{task['task_id']}.sh" in "\n".join(host_access.client.commands) + + +def test_container_cron_excludes_ssh_tasks(tmp_path): + host_access = FakeHostTaskAccessService() + cron_file = tmp_path / "websoft9-tasks" + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), cron_file=str(cron_file), auth_service=FakeAuthService(), cron_reloader=lambda: None, host_access_service=host_access) + host_task = service.create_task("valid-session", {"name": "Host", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}) + service.create_task("valid-session", {"name": "Container", "schedule": "* * * * *", "command": "date"}) + + assert host_task["task_id"] not in cron_file.read_text(encoding="utf-8") + + +def test_switching_away_from_unreachable_host_is_rejected(monkeypatch, tmp_path): + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService(data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None, host_access_service=host_access) + task = service.create_task("valid-session", {"name": "Unavailable", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"}) + monkeypatch.setattr(service, "_sync_host_tasks", lambda *_args, **_kwargs: (_ for _ in ()).throw(CustomException(503, "Scheduled Task Host Unavailable", "Host is unavailable"))) + + try: + service.update_task("valid-session", task["task_id"], {"name": "Unavailable", "target": "container", "schedule": "* * * * *", "command": "date"}) + except CustomException as exc: + assert exc.status_code == 503 + else: + raise AssertionError("Expected an unreachable old host to block task migration") \ No newline at end of file diff --git a/console/src/app/router/index.tsx b/console/src/app/router/index.tsx index 81b6bdfe6..1c50dac01 100644 --- a/console/src/app/router/index.tsx +++ b/console/src/app/router/index.tsx @@ -36,6 +36,7 @@ const OverviewPage = lazyPage(() => import('../../features/overview/overview-pag const DatabasesPage = lazyPage(() => import('../../features/databases/databases-page'), 'DatabasesPage') const ServicesPage = lazyPage(() => import('../../features/services/services-page'), 'ServicesPage') const TerminalPage = lazyPage(() => import('../../features/terminal/terminal-page'), 'TerminalPage') +const ScheduledTasksPage = lazyPage(() => import('../../features/scheduled-tasks/scheduled-tasks-page'), 'ScheduledTasksPage') const UsersPage = lazyPage(() => import('../../features/users/users-page'), 'UsersPage') const ApplicationsDeployPage = lazyPage(() => import('../../features/applications/applications-deploy-page'), 'ApplicationsDeployPage') const ApplicationsCustomInstallPage = lazyPage(() => import('../../features/applications/applications-custom-install-page'), 'ApplicationsCustomInstallPage') @@ -83,6 +84,8 @@ function preloadInitialRoute(pathname: string) { preloaders.push(FilesPage.preload) } else if (pathname === '/terminal') { preloaders.push(TerminalPage.preload) + } else if (pathname === '/cronjob') { + preloaders.push(ScheduledTasksPage.preload) } else if (pathname === '/logs') { preloaders.push(LogsPage.preload) } else if (pathname === '/services') { @@ -181,6 +184,13 @@ export function createAppRouter() { } } + if (item.segment === 'cronjob') { + return { + path: item.segment, + element: , + } + } + if (item.segment === 'logs') { return { path: item.segment, @@ -270,6 +280,10 @@ export function createAppRouter() { path: 'applications/custom-install', element: , }, + { + path: 'scheduled-tasks', + element: , + }, ...shellRoutes, ], }, diff --git a/console/src/app/shell/app-shell.tsx b/console/src/app/shell/app-shell.tsx index 4eaf937ac..235a01cfb 100644 --- a/console/src/app/shell/app-shell.tsx +++ b/console/src/app/shell/app-shell.tsx @@ -47,7 +47,7 @@ const navigationSections = [ }, { key: 'tools', - segments: ['terminal', 'services', 'logs', 'users', 'settings'], + segments: ['terminal', 'cronjob', 'services', 'logs', 'users', 'settings'], }, ] as const @@ -88,6 +88,8 @@ function ShellNavIcon({ segment }: { segment: AppNavIconSegment }) { return case 'terminal': return + case 'cronjob': + return case 'services': return case 'logs': @@ -212,6 +214,7 @@ export function AppShell() { location.pathname.startsWith('/myapps/') || location.pathname === '/dashboard' || location.pathname === '/terminal' || + location.pathname === '/cronjob' || location.pathname === '/services' || location.pathname === '/databases' || location.pathname === '/logs' || diff --git a/console/src/app/shell/shell-navigation.ts b/console/src/app/shell/shell-navigation.ts index 7bd8f1ef3..15b7a14bc 100644 --- a/console/src/app/shell/shell-navigation.ts +++ b/console/src/app/shell/shell-navigation.ts @@ -31,6 +31,10 @@ export const shellNavigationItems = [ segment: 'terminal', pageKey: 'terminal', }, + { + segment: 'cronjob', + pageKey: 'scheduledTasks', + }, { segment: 'services', pageKey: 'services', diff --git a/console/src/features/scheduled-tasks/scheduled-tasks-page.css b/console/src/features/scheduled-tasks/scheduled-tasks-page.css new file mode 100644 index 000000000..3cfcac6eb --- /dev/null +++ b/console/src/features/scheduled-tasks/scheduled-tasks-page.css @@ -0,0 +1,526 @@ +.scheduled-tasks-page-shell { + overflow-x: hidden; +} + +.scheduled-tasks-toolbar { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; + min-width: 0; +} + +.scheduled-tasks-toolbar::after { + content: ''; + position: absolute; + inset: -12px -12px -12px auto; +} + +.scheduled-tasks-toolbar-search { + flex: 1 1 420px; + min-width: 0; + width: 460px; +} + +.scheduled-tasks-toolbar-search .MuiOutlinedInput-root { + min-height: 42px; + border-radius: 4px; + background: #fff; + box-shadow: none; +} + +.scheduled-tasks-toolbar-filters, +.scheduled-tasks-toolbar-actions { + align-items: center; + min-width: 0; +} + +.scheduled-tasks-toolbar-filter { + min-width: 160px; +} + +.scheduled-tasks-toolbar-filter--enabled { + min-width: 150px; +} + +.scheduled-tasks-toolbar-filter .MuiOutlinedInput-root { + min-height: 42px; + border-radius: 4px; + background: #fff; + box-shadow: none; +} + +.scheduled-tasks-toolbar-filter .MuiSelect-select { + color: #334155; + font-size: 14px; + font-weight: 500; +} + +.scheduled-tasks-toolbar-search .MuiInputBase-input { + color: #334155; + font-size: 14px; + font-weight: 400; +} + +.scheduled-tasks-toolbar-search .MuiInputBase-input::placeholder { + color: #94a3b8; + opacity: 1; +} + +.scheduled-tasks-toolbar-search .MuiOutlinedInput-notchedOutline { + border-color: #e5e7eb; +} + +.scheduled-tasks-toolbar-create.MuiButton-root { + min-width: 110px; + min-height: 42px; + border-radius: 4px; + box-shadow: none; + font-weight: 600; + text-transform: none; + white-space: nowrap; +} + +.scheduled-tasks-toolbar-refresh.MuiIconButton-root, +.scheduled-tasks-row-actions .MuiIconButton-root { + border: 1px solid rgba(203, 213, 225, 0.9); + border-radius: 2px; + background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); + color: #475569; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} + +.scheduled-tasks-toolbar-refresh.MuiIconButton-root { + width: 42px; + height: 42px; + border-radius: 4px; +} + +.scheduled-tasks-row-actions .MuiIconButton-root { + border: 0; + background: transparent; + box-shadow: none; + width: 34px; + height: 34px; +} + +.scheduled-tasks-toolbar-refresh.MuiIconButton-root:hover, +.scheduled-tasks-row-actions .MuiIconButton-root:hover { + background: #f1f5f9; + color: #334155; +} + +.scheduled-tasks-row-actions .scheduled-tasks-row-action-danger.MuiIconButton-root { + border-color: rgba(248, 113, 113, 0.3); + color: #b91c1c; +} + +.scheduled-tasks-panel.MuiPaper-root { + overflow-x: auto; + border: 1px solid #e2e8f0; + border-radius: 0; + box-shadow: none; +} + +.scheduled-tasks-list-frame { + overflow: hidden; + border: 1px solid var(--shell-surface-border); + border-radius: 2px; + background: transparent; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); +} + +.scheduled-tasks-list-content { + padding: 12px; +} + +.scheduled-tasks-page-shell .scheduled-tasks-toolbar { + position: relative; + padding: 0 0 12px; + border: 0; + background: transparent; +} + +.scheduled-tasks-page-shell .scheduled-tasks-toolbar+* { + border-radius: 0 0 2px 2px; +} + +.scheduled-tasks-table th { + padding: 13px 12px; + border-bottom: 1px solid #e2e8f0; + color: #64748b; + background: #f8fafc; + font-size: 13px; + font-weight: 700; + text-align: left; +} + +.scheduled-tasks-table td { + padding: 13px 12px; + border-bottom: 1px solid rgba(203, 213, 225, 0.9); + vertical-align: middle; +} + +.scheduled-tasks-group-row td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.scheduled-tasks-group-row .MuiChip-root { + height: 20px; + border-radius: 10px; + font-size: 11px; +} + +.scheduled-tasks-group-toggle.MuiIconButton-root { + width: 24px; + height: 24px; + color: #475569; +} + +.scheduled-tasks-actions-column { + text-align: right !important; +} + +.scheduled-tasks-actions-column .scheduled-tasks-row-actions { + justify-content: flex-end; +} + +.scheduled-tasks-scoped-overlay { + position: fixed; + z-index: 1400; +} + +.scheduled-tasks-scoped-backdrop { + position: absolute; + inset: 0; + background-color: rgba(15, 23, 42, 0.18); +} + +.scheduled-tasks-scoped-dialog { + position: relative; + display: flex; + flex-direction: column; + max-height: calc(100% - 32px); + margin: 16px auto; + border: 1px solid var(--shell-surface-border); + border-radius: 2px; + background: #fff; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16); + overflow: hidden; +} + +.scheduled-tasks-log-dialog { + width: min(960px, calc(100% - 24px)); + height: min(640px, calc(100% - 32px)); +} + +.scheduled-tasks-delete-dialog { + width: min(480px, calc(100% - 24px)); +} + +.scheduled-tasks-scoped-title { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--shell-surface-border); +} + +.scheduled-tasks-log-content.MuiDialogContent-root { + display: flex; + min-height: 0; + padding: 16px 20px; + border: 0; +} + +.scheduled-tasks-log-panel { + flex: 1; + min-height: 220px; + margin: 0; + padding: 12px; + overflow: auto; + background: #f4f6f8; + color: #212b36; + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.scheduled-tasks-scoped-actions { + padding: 12px 20px; + border-top: 1px solid var(--shell-surface-border); +} + +.scheduled-tasks-table-row { + background: #fff; +} + +.scheduled-tasks-table-row:hover { + background: #f8fafc; +} + +.scheduled-tasks-table .MuiChip-root { + height: 24px; + border-radius: 2px; + box-shadow: none; +} + +.scheduled-tasks-editor-field .MuiOutlinedInput-root { + min-height: 38px; + border-radius: 2px; +} + +.scheduled-tasks-editor-field .MuiInputBase-input, +.scheduled-tasks-editor-field .MuiSelect-select { + font-size: 14px; +} + +.scheduled-tasks-editor-field--multiline .MuiOutlinedInput-root { + min-height: 0; +} + +.scheduled-tasks-editor-content { + padding: 18px 20px; +} + +.scheduled-tasks-editor-form { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.scheduled-tasks-editor-form> :not(style) { + grid-column: 1 / -1; + margin: 0 !important; +} + +.scheduled-tasks-editor-form> :nth-child(1), +.scheduled-tasks-editor-form> :nth-child(2) { + grid-column: auto; +} + +.scheduled-tasks-editor-content .MuiOutlinedInput-root { + min-height: 38px; + border-radius: 2px !important; +} + +.scheduled-tasks-editor-content .MuiInputBase-input, +.scheduled-tasks-editor-content .MuiSelect-select { + font-size: 14px; +} + +.scheduled-tasks-editor-content .MuiInputBase-multiline { + min-height: 0; +} + +.scheduled-tasks-editor-content>.MuiStack-root>.MuiBox-root>.MuiTypography-root:first-child { + display: block; + margin-bottom: 6px; + color: #475569; + font-size: 14px; + font-weight: 400; +} + +.scheduled-tasks-editor-content .MuiFormHelperText-root { + margin-top: 4px; + margin-left: 0; + color: #64748b; + font-size: 12px; + line-height: 1.35; +} + +.scheduled-tasks-editor-actions { + padding: 16px 20px; +} + +.scheduled-tasks-editor-label { + display: block; + margin-bottom: 6px; + color: #475569; + font-size: 14px; + font-weight: 400; +} + +.scheduled-tasks-editor-header { + padding: 16px 20px; +} + +.scheduled-tasks-editor-hero { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.scheduled-tasks-editor-hero--plain { + grid-template-columns: minmax(0, 1fr) auto; +} + +.scheduled-tasks-editor-hero-icon { + display: flex; + width: 56px; + height: 56px; + align-items: center; + justify-content: center; + border: 1px solid rgba(147, 197, 253, 0.65); + border-radius: 4px; + background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); + color: #2563eb; +} + +.scheduled-tasks-editor-hero-icon svg { + font-size: 27px; +} + +.scheduled-tasks-editor-title { + color: #334155; + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +.scheduled-tasks-cycle-unit { + display: flex; + align-items: center; + color: #64748b; + font-size: 14px; + white-space: nowrap; +} + +.scheduled-tasks-cycle-unit--hour { + order: 2; +} + +.scheduled-tasks-cycle-unit--day { + order: 1; +} + +.scheduled-tasks-cycle-unit--minute { + order: 3; +} + +.scheduled-tasks-editor-description { + margin-top: 4px; + color: #475569; + font-size: 14px; + line-height: 1.5; +} + +.scheduled-tasks-editor-close.MuiIconButton-root { + align-self: start; + margin-right: -4px; + border-radius: 2px; + color: #94a3b8; +} + +.app-shell-root--dark .scheduled-tasks-toolbar-search .MuiOutlinedInput-root, +.app-shell-root--dark .scheduled-tasks-row-actions .MuiIconButton-root, +.app-shell-root--dark .scheduled-tasks-toolbar-refresh.MuiIconButton-root, +.app-shell-root--dark .scheduled-tasks-table-row { + background: #0f172a; +} + +.app-shell-root--dark .scheduled-tasks-toolbar-search .MuiInputBase-input { + color: #e5edf5; +} + +.app-shell-root--dark .scheduled-tasks-toolbar-search .MuiInputBase-input::placeholder { + color: #64748b; +} + +.app-shell-root--dark .scheduled-tasks-row-actions .MuiIconButton-root, +.app-shell-root--dark .scheduled-tasks-toolbar-refresh.MuiIconButton-root { + color: #f8fafc; +} + +.app-shell-root--dark .scheduled-tasks-panel.MuiPaper-root { + border-color: rgba(71, 85, 105, 0.65); + box-shadow: none; +} + +.app-shell-root--dark .scheduled-tasks-scoped-dialog { + border-color: rgba(71, 85, 105, 0.65); + background: #111827; + color: #e5edf5; +} + +.app-shell-root--dark .scheduled-tasks-log-panel { + background: #0f172a; + color: #e2e8f0; +} + +.app-shell-root--dark .scheduled-tasks-list-frame { + border-color: rgba(71, 85, 105, 0.65); + background: #111827; + box-shadow: 0 12px 28px rgba(2, 6, 23, 0.24); +} + +.app-shell-root--dark .scheduled-tasks-table th { + border-bottom-color: rgba(71, 85, 105, 0.65); + background: #162033; + color: #94a3b8; +} + +.app-shell-root--dark .scheduled-tasks-table td { + border-bottom-color: rgba(71, 85, 105, 0.45); +} + +.app-shell-root--dark .scheduled-tasks-table-row:hover, +.app-shell-root--dark .scheduled-tasks-toolbar-refresh.MuiIconButton-root:hover, +.app-shell-root--dark .scheduled-tasks-row-actions .MuiIconButton-root:hover { + background: #162033; + color: #ffffff; +} + +.app-shell-root--dark .scheduled-tasks-editor-hero-icon { + border-color: rgba(96, 165, 250, 0.28); + background: linear-gradient(180deg, #162033 0%, #1d4ed8 100%); + color: #dbeafe; +} + +.app-shell-root--dark .scheduled-tasks-editor-title { + color: #e5edf5; +} + +.app-shell-root--dark .scheduled-tasks-editor-description, +.app-shell-root--dark .scheduled-tasks-editor-label { + color: #94a3b8; +} + +.app-shell-root--dark .scheduled-tasks-editor-content>.MuiStack-root>.MuiBox-root>.MuiTypography-root:first-child, +.app-shell-root--dark .scheduled-tasks-editor-content .MuiFormHelperText-root { + color: #94a3b8; +} + +@media (max-width: 680px) { + .scheduled-tasks-toolbar { + align-items: stretch; + flex-direction: column; + } + + .scheduled-tasks-toolbar-filters, + .scheduled-tasks-toolbar-actions { + flex-wrap: wrap; + } + + .scheduled-tasks-toolbar-actions { + width: 100%; + } + + .scheduled-tasks-toolbar-search { + flex: 1 1 220px; + width: 100%; + min-width: 0; + } + + .scheduled-tasks-editor-form { + grid-template-columns: minmax(0, 1fr); + } + + .scheduled-tasks-editor-form> :nth-child(1), + .scheduled-tasks-editor-form> :nth-child(2) { + grid-column: 1 / -1; + } +} \ No newline at end of file diff --git a/console/src/features/scheduled-tasks/scheduled-tasks-page.tsx b/console/src/features/scheduled-tasks/scheduled-tasks-page.tsx new file mode 100644 index 000000000..11f30852c --- /dev/null +++ b/console/src/features/scheduled-tasks/scheduled-tasks-page.tsx @@ -0,0 +1,2254 @@ +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + InputAdornment, + MenuItem, + Paper, + Stack, + Switch, + SvgIcon, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useAppColorMode } from "../../app/providers/color-mode"; +import { PageDescriptionHeader } from "../../shared/design-system/page-description-header"; +import { + SurfaceFeedbackToast, + SurfaceStateCard, +} from "../../shared/design-system/standard-surfaces"; +import { getSurfacePalette } from "../../shared/design-system/surface-theme"; +import "./scheduled-tasks-page.css"; + +type ScheduledTask = { + task_id: string; + name: string; + target: "container" | "host"; + profile_id: string | null; + schedule: string; + timezone: string; + command: string; + execution_mode: "command" | "path" | "upload"; + script_path: string | null; + script_name: string | null; + timeout_seconds: number; + retry_count: number; + enabled: boolean; + last_run_at: string | null; + last_status: "never" | "running" | "success" | "failed" | "skipped"; + sync_status: "synced" | "failed" | "unreachable"; + syncing?: boolean; + next_run_at: string | null; + created_at: string; + updated_at: string; +}; + +type TaskForm = Pick< + ScheduledTask, + | "name" + | "target" + | "profile_id" + | "schedule" + | "command" + | "execution_mode" + | "script_path" + | "timeout_seconds" + | "retry_count" + | "enabled" +>; +type ScheduleMode = + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "intervalMinutes" + | "intervalHours" + | "custom"; +type TimeoutUnit = "seconds" | "minutes" | "hours"; +type TargetFilter = "all" | ScheduledTask["target"]; +type StatusFilter = "all" | ScheduledTask["last_status"]; +type EnabledFilter = "all" | "enabled" | "disabled"; + +type SavedHostProfile = { + profile_id: string; + name: string; + host: string; + username: string; +}; + +type ScheduledTaskRun = { + run_id: string; + task_id: string; + started_at: string; + finished_at: string | null; + status: ScheduledTask["last_status"]; + exit_code: number | null; + trigger: "cron" | "manual"; + log_path: string; +}; + +type ScheduledTasksResponse = { + tasks: ScheduledTask[]; +}; + +type HostAccessProfileResponse = { + saved_profiles: SavedHostProfile[]; +}; + +type HostCapability = { + capability_status: "ready" | "unavailable"; + timezone: string; + message: string; +}; + +const defaultTaskForm: TaskForm = { + name: "", + target: "container", + profile_id: null, + schedule: "30 2 * * *", + command: "", + execution_mode: "command", + script_path: null, + timeout_seconds: 300, + retry_count: 1, + enabled: true, +}; + +const taskQueryKey = ["scheduled-tasks"] as const; + +async function requestJson(input: string, init?: RequestInit): Promise { + const response = await fetch(input, { + credentials: "include", + headers: { + Accept: "application/json", + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + ...init, + }); + const payload = (await response.json().catch(() => null)) as + { details?: string; message?: string } | T | null; + if (!response.ok) { + const detail = + payload && typeof payload === "object" && "details" in payload + ? (payload.details ?? payload.message) + : null; + throw new Error(detail ?? `HTTP ${response.status}`); + } + return payload as T; +} + +function formatDateTime(value: string | null, formatter: Intl.DateTimeFormat) { + return value ? formatter.format(new Date(value)) : "—"; +} + +function statusTone( + status: ScheduledTask["last_status"], +): "default" | "success" | "warning" | "error" | "info" { + if (status === "success") return "success"; + if (status === "failed") return "error"; + if (status === "running") return "info"; + if (status === "skipped") return "warning"; + return "default"; +} + +function TaskStatusIcon({ status }: { status: ScheduledTask["last_status"] }) { + const path = status === "success" + ? "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-1.2 14.2L6.6 12l1.4-1.4 2.8 2.8 5.2-5.2 1.4 1.4-6.6 6.6Z" + : status === "failed" + ? "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm3.1 13.7-1.4 1.4-1.7-1.7-1.7 1.7-1.4-1.4 1.7-1.7-1.7-1.7 1.4-1.4 1.7 1.7 1.7-1.7 1.4 1.4-1.7 1.7 1.7 1.7Z" + : status === "running" + ? "M12 2a10 10 0 1 0 10 10h-2a8 8 0 1 1-8-8V2Z" + : status === "skipped" + ? "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-5 9h6V7l5 5-5 5v-4H7v-2Z" + : "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 5v5.6l3.8 2.3-1 1.7-4.8-2.9V7h2Z"; + const color = status === "success" ? "success.main" : status === "failed" ? "error.main" : status === "running" ? "info.main" : status === "skipped" ? "warning.main" : "text.disabled"; + return ; +} + +function TaskSyncStatusIcon({ status }: { status: "failed" | "unreachable" }) { + const path = status === "unreachable" + ? "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-1 4h2v7h-2V6Zm0 9h2v2h-2v-2Z" + : "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 5v6l4 2.4-1 1.7-5-3V7h2Z"; + return ; +} + +function isIntegerInRange(value: string, minimum: number, maximum: number) { + return ( + /^\d+$/.test(value) && Number(value) >= minimum && Number(value) <= maximum + ); +} + +function RunIcon() { + return ( + + + + ); +} + +function RefreshIcon() { + return ( + + + + ); +} + +function ExpandIcon({ expanded }: { expanded: boolean }) { + return ( + + + + ); +} + +function LogIcon() { + return ( + + + + ); +} + +function EditIcon() { + return ( + + + + ); +} + +function DeleteIcon() { + return ( + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function UploadIcon() { + return ; +} + +export function ScheduledTasksPage() { + const { t, i18n } = useTranslation("shell"); + const { colorMode } = useAppColorMode(); + const darkMode = colorMode === "dark"; + const palette = getSurfacePalette(darkMode); + const queryClient = useQueryClient(); + const formatter = new Intl.DateTimeFormat(i18n.resolvedLanguage, { + dateStyle: "medium", + timeStyle: "short", + }); + const [editorTask, setEditorTask] = useState< + ScheduledTask | null | undefined + >(undefined); + const [form, setForm] = useState(defaultTaskForm); + const [uploadFile, setUploadFile] = useState(null); + const [scheduleMode, setScheduleMode] = useState("daily"); + const [scheduleMinute, setScheduleMinute] = useState("30"); + const [scheduleHour, setScheduleHour] = useState("2"); + const [scheduleDay, setScheduleDay] = useState("1"); + const [scheduleWeekday, setScheduleWeekday] = useState("0"); + const [timeoutUnit, setTimeoutUnit] = useState("seconds"); + const [searchValue, setSearchValue] = useState(""); + const [targetFilter, setTargetFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [enabledFilter, setEnabledFilter] = useState("all"); + const [saving, setSaving] = useState(false); + const [pendingTaskId, setPendingTaskId] = useState(null); + const [refreshingTaskIds, setRefreshingTaskIds] = useState>(() => new Set()); + const [expandedTaskGroups, setExpandedTaskGroups] = useState>( + () => new Set(["container", "host"]), + ); + const [logTask, setLogTask] = useState(null); + const [taskRuns, setTaskRuns] = useState([]); + const [selectedRun, setSelectedRun] = useState(null); + const [logBefore, setLogBefore] = useState(null); + const [logContent, setLogContent] = useState(""); + const [logLoading, setLogLoading] = useState(false); + const [deleteTask, setDeleteTask] = useState(null); + const [feedback, setFeedback] = useState<{ + severity: "success" | "error" | "info"; + message: string; + } | null>(null); + const [scheduleError, setScheduleError] = useState(null); + const supportsEventSource = typeof window !== "undefined" && typeof EventSource !== "undefined"; + const pageShellRef = useRef(null); + const scheduleInputRef = useRef(null); + const [editorScope, setEditorScope] = useState<{ + top: number; + left: number; + width: number; + height: number; + } | null>(null); + + const tasksQuery = useQuery({ + queryKey: taskQueryKey, + queryFn: () => requestJson("/api/scheduled-tasks"), + refetchOnWindowFocus: false, + }); + const hostProfilesQuery = useQuery({ + queryKey: ["host-access-profiles"], + queryFn: () => requestJson("/api/host-access/profile"), + refetchOnWindowFocus: false, + }); + const hostCapabilityQuery = useQuery({ + queryKey: ["scheduled-task-host-capability", form.profile_id], + queryFn: () => + requestJson( + `/api/scheduled-tasks/hosts/${encodeURIComponent(form.profile_id ?? "")}/capability`, + { method: "POST" }, + ), + enabled: + editorTask !== undefined && + form.target === "host" && + Boolean(form.profile_id), + staleTime: 300_000, + retry: false, + }); + + const tasks = tasksQuery.data?.tasks ?? []; + const savedHostProfiles = hostProfilesQuery.data?.saved_profiles ?? []; + const filteredTasks = tasks.filter((task) => { + const query = searchValue.trim().toLowerCase(); + const searchableValues = [ + task.name, + task.command, + task.script_path ?? "", + task.script_name ?? "", + task.schedule, + task.target, + task.execution_mode, + targetLabel(task), + t(`scheduledTasks.executionModes.${task.execution_mode}`), + scheduleLabel(task.schedule), + ]; + return (!query || searchableValues.some((value) => value.toLowerCase().includes(query))) + && (targetFilter === "all" || task.target === targetFilter) + && (statusFilter === "all" || task.last_status === statusFilter) + && (enabledFilter === "all" || task.enabled === (enabledFilter === "enabled")); + }); + const taskGroups = (["container", "host"] as const) + .map((target) => ({ + target, + label: t(`scheduledTasks.${target === "container" ? "platform" : "host"}`), + tasks: filteredTasks.filter((task) => task.target === target), + })) + .filter((group) => group.tasks.length > 0); + const runningTaskIds = tasks + .filter((task) => task.last_status === "running") + .map((task) => task.task_id) + .join(","); + const executionReady = + form.execution_mode === "command" + ? Boolean(form.command.trim()) + : Boolean(form.script_path?.trim()); + + useEffect(() => { + if (!supportsEventSource || !tasksQuery.data) { + return; + } + const eventSource = new EventSource("/api/scheduled-tasks/stream", { withCredentials: true }); + const handleSnapshot = (event: Event) => { + try { + const payload = JSON.parse((event as MessageEvent).data) as ScheduledTasksResponse; + if (payload.tasks) { + queryClient.setQueryData(taskQueryKey, payload); + } + } catch { + // Keep the last local snapshot when a stream event is malformed. + } + }; + eventSource.addEventListener("snapshot", handleSnapshot); + return () => { + eventSource.removeEventListener("snapshot", handleSnapshot); + eventSource.close(); + }; + }, [Boolean(tasksQuery.data), queryClient, supportsEventSource]); + const editorSelectMenuProps = { + disablePortal: true, + slotProps: { + paper: { + sx: { + borderRadius: 0, + mt: 0.5, + zIndex: 1501, + backgroundColor: palette.panelBg, + color: palette.text, + }, + }, + }, + }; + + useEffect(() => { + if (!runningTaskIds) { + return undefined; + } + + let disposed = false; + const refreshRunningTasks = async () => { + const updates = await Promise.all( + runningTaskIds.split(",").map(async (taskId) => { + try { + return await requestJson( + `/api/scheduled-tasks/${taskId}/refresh-status`, + { method: "POST" }, + ); + } catch { + return null; + } + }), + ); + if (disposed) { + return; + } + const updatesById = new Map( + updates + .filter((task): task is ScheduledTask => task !== null) + .map((task) => [task.task_id, task]), + ); + queryClient.setQueryData( + taskQueryKey, + (current) => + current + ? { + ...current, + tasks: current.tasks.map( + (task) => updatesById.get(task.task_id) ?? task, + ), + } + : current, + ); + }; + + void refreshRunningTasks(); + const timer = window.setInterval(() => void refreshRunningTasks(), 1_500); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [queryClient, runningTaskIds]); + + useLayoutEffect(() => { + const shell = pageShellRef.current; + const main = shell?.closest("main"); + if (!shell || !(main instanceof HTMLElement)) return; + const update = () => { + const rect = main.getBoundingClientRect(); + setEditorScope({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(main); + window.addEventListener("resize", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); + + function applyVisualSchedule( + mode = scheduleMode, + values = { + minute: scheduleMinute, + hour: scheduleHour, + day: scheduleDay, + weekday: scheduleWeekday, + }, + ) { + if (mode === "custom") return; + const schedule = + mode === "hourly" + ? `${values.minute} * * * *` + : mode === "daily" + ? `${values.minute} ${values.hour} * * *` + : mode === "weekly" + ? `${values.minute} ${values.hour} * * ${values.weekday}` + : mode === "monthly" + ? `${values.minute} ${values.hour} ${values.day} * *` + : mode === "intervalMinutes" + ? `*/${Math.min(59, Math.max(1, Number(values.minute)))} * * * *` + : `0 */${Math.min(23, Math.max(1, Number(values.hour)))} * * *`; + setForm((current) => ({ ...current, schedule })); + } + + function setVisualScheduleMode(mode: ScheduleMode) { + setScheduleError(null); + if (mode !== "custom") { + const values = { + minute: /^\d+$/.test(scheduleMinute) ? scheduleMinute : "0", + hour: /^\d+$/.test(scheduleHour) ? scheduleHour : "0", + day: /^\d+$/.test(scheduleDay) ? scheduleDay : "1", + weekday: /^\d+$/.test(scheduleWeekday) ? scheduleWeekday : "0", + }; + setScheduleMinute(values.minute); + setScheduleHour(values.hour); + setScheduleDay(values.day); + setScheduleWeekday(values.weekday); + setScheduleMode(mode); + applyVisualSchedule(mode, values); + return; + } + setScheduleMode(mode); + applyVisualSchedule(mode); + } + + function toggleTaskGroup(target: ScheduledTask["target"]) { + setExpandedTaskGroups((current) => { + const next = new Set(current); + if (next.has(target)) { + next.delete(target); + } else { + next.add(target); + } + return next; + }); + } + + function localizedTaskError(error: unknown) { + const message = error instanceof Error ? error.message : ""; + if (/Schedule must be a (five-field|valid five-field) cron expression/i.test(message)) { + return t("scheduledTasks.validation.invalidSchedule"); + } + if (/A task name is required/i.test(message)) return t("scheduledTasks.errors.nameRequired"); + if (/A command is required/i.test(message)) return t("scheduledTasks.errors.commandRequired"); + if (/Script path must be an absolute path/i.test(message)) return t("scheduledTasks.errors.scriptPathInvalid"); + if (/Uploaded script content is invalid|Upload a script before selecting|Upload the script again/i.test(message)) return t("scheduledTasks.errors.scriptRequired"); + if (/Task input exceeds/i.test(message)) return t("scheduledTasks.errors.inputTooLong"); + if (/Timeout must be/i.test(message)) return t("scheduledTasks.errors.timeoutInvalid"); + if (/Retry count must be/i.test(message)) return t("scheduledTasks.errors.retryInvalid"); + if (/A saved SSH host profile is required|SSH host tasks require/i.test(message)) return t("scheduledTasks.errors.hostRequired"); + if (/Platform tasks cannot use/i.test(message)) return t("scheduledTasks.errors.platformHostMismatch"); + if (/A task with this name already exists/i.test(message)) return t("scheduledTasks.errors.nameExists"); + if (/Host is unavailable|SSH host|Scheduled Task Host Unavailable|inspect the SSH host/i.test(message)) return t("scheduledTasks.errors.hostUnavailable"); + if (/synchroniz|runner|remote task|crontab|task block/i.test(message)) return t("scheduledTasks.errors.syncFailed"); + if (/Scheduled Task Not Found/i.test(message)) return t("scheduledTasks.errors.notFound"); + return t("scheduledTasks.feedback.failed"); + } + + function hostCapabilityErrorMessage(error: Error) { + const message = error.message; + if (/Authentication failed|SSH Authentication Failed/i.test(message)) { + return t("scheduledTasks.hostCapability.authenticationFailed"); + } + if (/Timed out|timeout/i.test(message)) { + return t("scheduledTasks.hostCapability.connectionTimedOut"); + } + if (/Unable to connect|No route to host|Connection refused|Network is unreachable/i.test(message)) { + return t("scheduledTasks.hostCapability.connectionFailed"); + } + return t("scheduledTasks.hostCapability.checkFailed"); + } + + function openCreateDialog() { + setForm({ + ...defaultTaskForm, + command: t("scheduledTasks.fields.commandExample"), + }); + setTimeoutUnit("seconds"); + setUploadFile(null); + setScheduleMode("daily"); + setScheduleMinute("30"); + setScheduleHour("2"); + setScheduleDay("1"); + setScheduleWeekday("0"); + setEditorTask(null); + } + + function openEditDialog(task: ScheduledTask) { + setForm({ + name: task.name, + target: task.target, + profile_id: task.profile_id, + schedule: task.schedule, + command: task.command, + execution_mode: task.execution_mode ?? "command", + script_path: task.script_path, + timeout_seconds: task.timeout_seconds ?? 0, + retry_count: task.retry_count ?? 0, + enabled: task.enabled, + }); + setUploadFile(null); + setTimeoutUnit("seconds"); + const parts = task.schedule.split(" "); + const isHourly = + parts.length === 5 && + isIntegerInRange(parts[0], 0, 59) && + parts[1] === "*" && + parts[2] === "*" && + parts[3] === "*" && + parts[4] === "*"; + const isDaily = + parts.length === 5 && + isIntegerInRange(parts[0], 0, 59) && + isIntegerInRange(parts[1], 0, 23) && + parts[2] === "*" && + parts[3] === "*" && + parts[4] === "*"; + const isWeekly = + parts.length === 5 && + isIntegerInRange(parts[0], 0, 59) && + isIntegerInRange(parts[1], 0, 23) && + parts[2] === "*" && + parts[3] === "*" && + isIntegerInRange(parts[4], 0, 6); + const isMonthly = + parts.length === 5 && + isIntegerInRange(parts[0], 0, 59) && + isIntegerInRange(parts[1], 0, 23) && + isIntegerInRange(parts[2], 1, 31) && + parts[3] === "*" && + parts[4] === "*"; + const intervalMinuteMatch = /^\*\/(\d+)$/.exec(parts[0]); + const isIntervalMinutes = + parts.length === 5 && + intervalMinuteMatch !== null && + Number(intervalMinuteMatch[1]) >= 1 && + Number(intervalMinuteMatch[1]) <= 59 && + parts.slice(1).every((part) => part === "*"); + const intervalHourMatch = /^\*\/(\d+)$/.exec(parts[1]); + const isIntervalHours = + parts.length === 5 && + parts[0] === "0" && + intervalHourMatch !== null && + Number(intervalHourMatch[1]) >= 1 && + Number(intervalHourMatch[1]) <= 23 && + parts.slice(2).every((part) => part === "*"); + const visualMode: ScheduleMode | null = isHourly + ? "hourly" + : isDaily + ? "daily" + : isWeekly + ? "weekly" + : isMonthly + ? "monthly" + : isIntervalMinutes + ? "intervalMinutes" + : isIntervalHours + ? "intervalHours" + : null; + setScheduleMode(visualMode ?? "custom"); + if (visualMode) { + setScheduleMinute( + isIntervalMinutes ? intervalMinuteMatch![1] : parts[0], + ); + setScheduleHour( + isIntervalHours ? intervalHourMatch![1] : parts[1] === "*" ? "0" : parts[1], + ); + setScheduleDay(parts[2] === "*" ? "1" : parts[2]); + setScheduleWeekday(parts[4] === "*" ? "0" : parts[4]); + } + setEditorTask(task); + } + + async function refreshTasks() { + await queryClient.invalidateQueries({ queryKey: taskQueryKey }); + } + + async function syncTasks() { + await requestJson("/api/scheduled-tasks/sync", { method: "POST" }); + await refreshTasks(); + } + + async function saveTask() { + setSaving(true); + setScheduleError(null); + try { + const scriptContent = uploadFile ? await uploadFile.text() : undefined; + const payload = { + ...(form.target === "host" ? form : { ...form, profile_id: null }), + ...(uploadFile + ? { script_name: uploadFile.name, script_content: scriptContent } + : {}), + }; + if (editorTask) { + await requestJson(`/api/scheduled-tasks/${editorTask.task_id}`, { + method: "PUT", + body: JSON.stringify(payload), + }); + } else { + await requestJson("/api/scheduled-tasks", { + method: "POST", + body: JSON.stringify(payload), + }); + } + await refreshTasks(); + setEditorTask(undefined); + setFeedback({ + severity: "success", + message: t( + editorTask + ? "scheduledTasks.feedback.updated" + : "scheduledTasks.feedback.created", + ), + }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (/Schedule must be a (five-field|valid five-field) cron expression/i.test(message)) { + setScheduleMode("custom"); + setScheduleError(t("scheduledTasks.validation.invalidSchedule")); + requestAnimationFrame(() => scheduleInputRef.current?.focus()); + } + setFeedback({ + severity: "error", + message: localizedTaskError(error), + }); + } finally { + setSaving(false); + } + } + + function targetLabel(task: ScheduledTask) { + if (task.target === "container") { + return t("scheduledTasks.platform"); + } + const profile = savedHostProfiles.find( + (item) => item.profile_id === task.profile_id, + ); + return profile + ? t("scheduledTasks.hostLabel", { name: profile.name || profile.host }) + : t("scheduledTasks.hostUnavailable"); + } + + function scheduleLabel(schedule: string) { + const parts = schedule.trim().split(/\s+/); + if (parts.length !== 5) return schedule; + const [minute, hour, day, month, weekday] = parts; + const minuteInterval = /^\*\/(\d+)$/.exec(minute); + const hourInterval = /^\*\/(\d+)$/.exec(hour); + if (minuteInterval && hour === "*" && day === "*" && month === "*" && weekday === "*") { + return t("scheduledTasks.scheduleSummary.everyMinutes", { count: minuteInterval[1] }); + } + if (minute === "0" && hourInterval && day === "*" && month === "*" && weekday === "*") { + return t("scheduledTasks.scheduleSummary.everyHours", { count: hourInterval[1] }); + } + if (/^\d+$/.test(minute) && hour === "*" && day === "*" && month === "*" && weekday === "*") { + return t("scheduledTasks.scheduleSummary.hourlyAt", { minute: minute.padStart(2, "0") }); + } + if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === "*" && month === "*" && weekday === "*") { + return t("scheduledTasks.scheduleSummary.dailyAt", { time: `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}` }); + } + if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === "*" && month === "*" && /^\d+$/.test(weekday)) { + return t("scheduledTasks.scheduleSummary.weeklyAt", { weekday: t(`scheduledTasks.weekdays.${weekday}`), time: `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}` }); + } + if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && /^\d+$/.test(day) && month === "*" && weekday === "*") { + return t("scheduledTasks.scheduleSummary.monthlyAt", { day, time: `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}` }); + } + return t("scheduledTasks.scheduleSummary.custom"); + } + + async function updateTask( + task: ScheduledTask, + action: "toggle" | "run" | "refresh", + ) { + setPendingTaskId(task.task_id); + if (action === "refresh") { + setRefreshingTaskIds((current) => new Set(current).add(task.task_id)); + } + try { + if (action === "toggle") { + const updatedTask = await requestJson(`/api/scheduled-tasks/${task.task_id}/toggle`, { + method: "POST", + body: JSON.stringify({ enabled: !task.enabled }), + }); + queryClient.setQueryData( + taskQueryKey, + (current) => + current + ? { + ...current, + tasks: current.tasks.map((item) => + item.task_id === updatedTask.task_id ? updatedTask : item, + ), + } + : current, + ); + } else if (action === "run") { + await requestJson(`/api/scheduled-tasks/${task.task_id}/run`, { + method: "POST", + }); + queryClient.setQueryData( + taskQueryKey, + (current) => + current + ? { + ...current, + tasks: current.tasks.map((item) => + item.task_id === task.task_id + ? { ...item, last_status: "running" } + : item, + ), + } + : current, + ); + setFeedback({ + severity: "info", + message: t("scheduledTasks.feedback.started"), + }); + } else { + const refreshedTask = await requestJson( + `/api/scheduled-tasks/${task.task_id}/refresh-status`, + { method: "POST" }, + ); + queryClient.setQueryData( + taskQueryKey, + (current) => + current + ? { + ...current, + tasks: current.tasks.map((item) => + item.task_id === refreshedTask.task_id + ? refreshedTask + : item, + ), + } + : current, + ); + } + } catch (error) { + setFeedback({ + severity: "error", + message: localizedTaskError(error), + }); + } finally { + setPendingTaskId(null); + if (action === "refresh") { + setRefreshingTaskIds((current) => { + const next = new Set(current); + next.delete(task.task_id); + return next; + }); + } + } + } + + async function openLog(task: ScheduledTask) { + setLogTask(task); + setTaskRuns([]); + setSelectedRun(null); + setLogContent(""); + setLogLoading(true); + try { + const response = await requestJson<{ runs: ScheduledTaskRun[] }>( + `/api/scheduled-tasks/${task.task_id}/runs`, + ); + setTaskRuns(response.runs); + } catch (error) { + setFeedback({ + severity: "error", + message: localizedTaskError(error), + }); + } finally { + setLogLoading(false); + } + } + + async function openRunLog(task: ScheduledTask, run: ScheduledTaskRun, before?: number) { + setSelectedRun(run); + if (before === undefined) { + setLogContent(""); + setLogBefore(null); + } + setLogLoading(true); + try { + const query = before === undefined ? "" : `?before=${before}`; + const response = await requestJson<{ content: string; next_before: number | null }>( + `/api/scheduled-tasks/${task.task_id}/runs/${run.run_id}/log${query}`, + ); + setLogContent((current) => before === undefined ? response.content : `${response.content}${current ? `\n${current}` : ""}`); + setLogBefore(response.next_before); + } catch (error) { + setFeedback({ severity: "error", message: localizedTaskError(error) }); + } finally { + setLogLoading(false); + } + } + + function downloadRunLog(task: ScheduledTask, run: ScheduledTaskRun) { + const link = document.createElement("a"); + link.href = `/api/scheduled-tasks/${task.task_id}/runs/${run.run_id}/log/download`; + link.download = ""; + document.body.appendChild(link); + link.click(); + link.remove(); + } + + async function confirmDelete() { + if (!deleteTask) return; + setPendingTaskId(deleteTask.task_id); + try { + await requestJson(`/api/scheduled-tasks/${deleteTask.task_id}`, { + method: "DELETE", + }); + await refreshTasks(); + setDeleteTask(null); + setFeedback({ + severity: "success", + message: t("scheduledTasks.feedback.deleted"), + }); + } catch (error) { + setFeedback({ + severity: "error", + message: localizedTaskError(error), + }); + } finally { + setPendingTaskId(null); + } + } + + return ( + + + + {!hostProfilesQuery.isLoading && savedHostProfiles.length === 0 ? ( + + {t("scheduledTasks.hostEmpty")} + + ) : null} + + {tasksQuery.isLoading ? ( + + ) : null} + {tasksQuery.error ? ( + void tasksQuery.refetch()} + > + {t("scheduledTasks.actions.retry")} + + } + > + {tasksQuery.error.message} + + ) : null} + {!tasksQuery.isLoading && !tasksQuery.error ? ( + + + + + setTargetFilter(event.target.value as TargetFilter)} + slotProps={{ select: { MenuProps: { slotProps: { paper: { sx: { borderRadius: 0, mt: 0.5, "& .MuiMenuItem-root": { fontSize: 14, fontWeight: 500 } } } } } } }} + > + {t("scheduledTasks.filters.allLocations")} + {t("scheduledTasks.platform")} + {t("scheduledTasks.host")} + + setStatusFilter(event.target.value as StatusFilter)} + slotProps={{ select: { MenuProps: { slotProps: { paper: { sx: { borderRadius: 0, mt: 0.5, "& .MuiMenuItem-root": { fontSize: 14, fontWeight: 500 } } } } } } }} + > + {t("scheduledTasks.filters.allStatuses")} + {(["never", "running", "success", "failed", "skipped"] as StatusFilter[]).filter((status) => status !== "all").map((status) => {t(`scheduledTasks.status.${status}`)})} + + setEnabledFilter(event.target.value as EnabledFilter)} + slotProps={{ select: { MenuProps: { slotProps: { paper: { sx: { borderRadius: 0, mt: 0.5, "& .MuiMenuItem-root": { fontSize: 14, fontWeight: 500 } } } } } } }} + > + {t("scheduledTasks.filters.allEnabled")} + {t("scheduledTasks.filters.enabled")} + {t("scheduledTasks.filters.disabled")} + + + + setSearchValue(event.target.value)} + placeholder={t("scheduledTasks.filters.searchPlaceholder")} + size="small" + value={searchValue} + /> + + + + void syncTasks()} + size="small" + > + {tasksQuery.isFetching ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + {tasksQuery.isFetching ? ( + + + + ) : tasks.length === 0 ? ( + + + + ) : filteredTasks.length === 0 ? ( + + + + ) : ( + taskGroups.map((group) => { + const expanded = expandedTaskGroups.has(group.target); + return ( + + + + + {expanded ? group.tasks.map((task) => ( + + + + + + + + + + )) : null} + + ); + }) + )} + + + + + + ) : null} + {!tasksQuery.isLoading && + !tasksQuery.error && + !tasksQuery.isFetching && + filteredTasks.length > 0 ? ( + + {taskGroups.map((group) => { + const expanded = expandedTaskGroups.has(group.target); + return ( + + + toggleTaskGroup(group.target)} + size="small" + > + + + {group.label} + + + {expanded ? group.tasks.map((task) => ( + + + + + + {task.name} + + + void updateTask(task, "toggle")} + size="small" + slotProps={{ + input: { + "aria-label": t("scheduledTasks.actions.toggle", { + name: task.name, + }), + }, + }} + /> + + + {scheduleLabel(task.schedule)} + + + {t(`scheduledTasks.executionModes.${task.execution_mode}`)} · {task.execution_mode === "command" ? task.command : task.script_path ?? task.script_name} + + + + + {task.last_run_at ? ( + + {formatDateTime(task.last_run_at, formatter)} + + ) : null} + {task.sync_status === "unreachable" ? ( + + {t("scheduledTasks.hostUnavailable")} + + ) : null} + + + + + void updateTask(task, "run")} + size="small" + > + + + + + + void updateTask(task, "refresh")} + size="small" + > + + + + + void openLog(task)} + size="small" + > + + + + + openEditDialog(task)} + size="small" + > + + + + + setDeleteTask(task)} + size="small" + > + + + + + + + + )) : null} + + ); + })} + + ) : null} + {!tasksQuery.isLoading && + !tasksQuery.error && + (tasksQuery.isFetching || filteredTasks.length === 0) ? ( + + + {tasksQuery.isFetching ? ( + + ) : ( + <> + + {tasks.length === 0 + ? t("scheduledTasks.empty.title") + : t("scheduledTasks.empty.noResults")} + + {tasks.length === 0 ? ( + + {t("scheduledTasks.empty.description")} + + ) : null} + + )} + + + ) : null} + + {editorTask !== undefined && editorScope ? ( + + !saving && setEditorTask(undefined)} + sx={{ + position: "absolute", + inset: 0, + backgroundColor: "rgba(15, 23, 42, 0.18)", + }} + /> + + + + + + {t( + editorTask + ? "scheduledTasks.editor.editTitle" + : "scheduledTasks.editor.createTitle", + )} + + + setEditorTask(undefined)} + size="small" + > + + + + + + + + + {t("scheduledTasks.fields.name")} + + + setForm((current) => ({ + ...current, + name: event.target.value, + })) + } + placeholder={t("scheduledTasks.fields.name")} + required + size="small" + value={form.name} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + /> + + + + {t("scheduledTasks.fields.target")} + + + setForm((current) => ({ + ...current, + target: event.target.value as TaskForm["target"], + profile_id: + event.target.value === "host" + ? current.profile_id + : null, + })) + } + select + size="small" + value={form.target} + slotProps={{ select: { MenuProps: editorSelectMenuProps } }} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + > + + {t("scheduledTasks.platform")} + + {t("scheduledTasks.host")} + + + {form.target === "host" ? ( + + + {t("scheduledTasks.fields.host")} + + + + setForm((current) => ({ + ...current, + profile_id: event.target.value || null, + })) + } + required + select + size="small" + value={form.profile_id ?? ""} + slotProps={{ + select: { MenuProps: editorSelectMenuProps }, + }} + sx={{ flex: { md: "0 0 calc(50% - 4px)" }, "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + > + {savedHostProfiles.map((profile) => ( + + {profile.name || profile.host} ({profile.username}@ + {profile.host}) + + ))} + + {hostCapabilityQuery.isLoading ? ( + + {t("scheduledTasks.hostChecking")} + + ) : null} + {hostCapabilityQuery.data ? ( + + {hostCapabilityQuery.data.capability_status === "ready" + ? t("scheduledTasks.hostCapability.ready") + : t("scheduledTasks.hostCapability.unavailable")} + + ) : null} + {hostCapabilityQuery.error ? ( + + {hostCapabilityErrorMessage(hostCapabilityQuery.error)} + + ) : null} + + + ) : null} + + + {t("scheduledTasks.fields.schedule")} + + + + setVisualScheduleMode( + event.target.value as ScheduleMode, + ) + } + slotProps={{ + select: { MenuProps: editorSelectMenuProps }, + }} + sx={{ + minWidth: 220, + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + > + {( + [ + "hourly", + "daily", + "weekly", + "monthly", + "intervalMinutes", + "intervalHours", + "custom", + ] as ScheduleMode[] + ).map((mode) => ( + + {t(`scheduledTasks.scheduleModes.${mode}`)} + + ))} + + {scheduleMode !== "custom" ? ( + + {scheduleMode !== "intervalHours" ? ( + { + const minute = String(Math.min(59, Math.max( + scheduleMode === "intervalMinutes" ? 1 : 0, + Math.trunc(Number(event.target.value) || 0), + ))); + setScheduleMinute(minute); + applyVisualSchedule(scheduleMode, { + minute, + hour: scheduleHour, + day: scheduleDay, + weekday: scheduleWeekday, + }); + }} + slotProps={{ + input: { endAdornment: {t("scheduledTasks.scheduleControls.minute")} }, + htmlInput: { min: scheduleMode === "intervalMinutes" ? 1 : 0, max: 59, step: 1 }, + }} + sx={{ + minWidth: 120, + flex: "1 1 120px", + order: 3, + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + /> + ) : null} + {scheduleMode !== "hourly" && + scheduleMode !== "intervalMinutes" ? ( + { + const hour = String(Math.min(23, Math.max( + scheduleMode === "intervalHours" ? 1 : 0, + Math.trunc(Number(event.target.value) || 0), + ))); + setScheduleHour(hour); + applyVisualSchedule(scheduleMode, { + minute: scheduleMinute, + hour, + day: scheduleDay, + weekday: scheduleWeekday, + }); + }} + slotProps={{ + input: { endAdornment: {t("scheduledTasks.scheduleControls.hour")} }, + htmlInput: { min: scheduleMode === "intervalHours" ? 1 : 0, max: 23, step: 1 }, + }} + sx={{ + minWidth: 120, + flex: "1 1 120px", + order: 2, + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + /> + ) : null} + {scheduleMode === "weekly" ? ( + { + const weekday = event.target.value; + setScheduleWeekday(weekday); + applyVisualSchedule(scheduleMode, { + minute: scheduleMinute, + hour: scheduleHour, + day: scheduleDay, + weekday, + }); + }} + slotProps={{ + select: { MenuProps: editorSelectMenuProps }, + }} + sx={{ + width: 120, + order: 1, + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + > + {Array.from({ length: 7 }, (_, value) => ( + + {t(`scheduledTasks.weekdays.${value}`)} + + ))} + + ) : null} + {scheduleMode === "monthly" ? ( + { + const day = String(Math.min(31, Math.max( + 1, + Math.trunc(Number(event.target.value) || 1), + ))); + setScheduleDay(day); + applyVisualSchedule(scheduleMode, { + minute: scheduleMinute, + hour: scheduleHour, + day, + weekday: scheduleWeekday, + }); + }} + slotProps={{ + input: { endAdornment: {t("scheduledTasks.scheduleControls.day")} }, + htmlInput: { min: 1, max: 31, step: 1 }, + }} + sx={{ + minWidth: 120, + flex: "1 1 120px", + order: 1, + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + }} + /> + ) : null} + + ) : ( + { + setScheduleError(null); + setForm((current) => ({ + ...current, + schedule: event.target.value, + })); + } + } + placeholder="0 0 * * *" + size="small" + value={form.schedule} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + /> + )} + + + + + {t("scheduledTasks.fields.executionMode")} + + + setForm((current) => ({ + ...current, + execution_mode: event.target + .value as TaskForm["execution_mode"], + })) + } + slotProps={{ select: { MenuProps: editorSelectMenuProps } }} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + > + {(["command", "path"] as const).map((mode) => ( + + {t(`scheduledTasks.executionModes.${mode}`)} + + ))} + {form.execution_mode === "upload" ? ( + + {t("scheduledTasks.executionModes.upload")} + + ) : null} + + + {form.execution_mode === "command" || + form.execution_mode === "upload" ? ( + + + + {t("scheduledTasks.fields.command")} + + + + { + const file = event.target.files?.[0]; + if (!file) return; + if (form.execution_mode === "upload") { + setUploadFile(file); + return; + } + if (file) + void file + .text() + .then((content) => + setForm((current) => ({ + ...current, + command: content, + })), + ); + }} + /> + + + + + {form.execution_mode === "command" ? ( + + setForm((current) => ({ + ...current, + command: event.target.value, + })) + } + placeholder={t("scheduledTasks.fields.commandExample")} + required + size="small" + value={form.command} + sx={{ + "& .MuiOutlinedInput-root": { borderRadius: 0 }, + "& textarea": { resize: "vertical" }, + }} + /> + ) : ( + + {t("scheduledTasks.fields.uploadLegacyHint", { + name: uploadFile?.name ?? editorTask?.script_name ?? "-", + })} + + )} + + ) : null} + {form.execution_mode === "path" ? ( + + + {t("scheduledTasks.fields.scriptPath")} + + + setForm((current) => ({ + ...current, + script_path: event.target.value, + })) + } + placeholder="/opt/scripts/task.sh" + size="small" + value={form.script_path ?? ""} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + /> + + ) : null} + + + + {t("scheduledTasks.fields.timeout")} + + + { + const multiplier = + timeoutUnit === "hours" + ? 3600 + : timeoutUnit === "minutes" + ? 60 + : 1; + setForm((current) => ({ + ...current, + timeout_seconds: Math.min( + 86400, + Math.max( + 0, + Math.trunc(Number(event.target.value) || 0) * + multiplier, + ), + ), + })); + }} + size="small" + type="number" + value={Math.trunc( + form.timeout_seconds / + (timeoutUnit === "hours" + ? 3600 + : timeoutUnit === "minutes" + ? 60 + : 1), + )} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: "2px 0 0 2px", + height: 38, + }, + }} + /> + { + const unit = event.target.value as TimeoutUnit; + setTimeoutUnit(unit); + }} + slotProps={{ + select: { MenuProps: editorSelectMenuProps }, + }} + sx={{ + width: 132, + flexShrink: 0, + "& .MuiOutlinedInput-root": { + borderRadius: "0 2px 2px 0", + height: 38, + }, + }} + > + + {t("scheduledTasks.scheduleControls.seconds")} + + + {t("scheduledTasks.scheduleControls.minutes")} + + + {t("scheduledTasks.scheduleControls.hours")} + + + + + + + {t("scheduledTasks.fields.retryCount")} + + + setForm((current) => ({ + ...current, + retry_count: Math.min( + 10, + Math.max( + 0, + Math.trunc(Number(event.target.value) || 0), + ), + ), + })) + } + size="small" + type="number" + value={form.retry_count} + sx={{ "& .MuiOutlinedInput-root": { borderRadius: 0 } }} + helperText={t("scheduledTasks.fields.retryCountHint")} + /> + + + + + {t("scheduledTasks.fields.enabled")} + + + setForm((current) => ({ + ...current, + enabled: event.target.checked, + })) + } + /> + } + label={t("scheduledTasks.fields.enabledNow")} + /> + + + + + + + + + + ) : null} + + {logTask && editorScope ? ( + + setLogTask(null)} /> + + + + {selectedRun ? t("scheduledTasks.runLogTitle", { name: logTask.name }) : t("scheduledTasks.runHistoryTitle", { name: logTask.name })} + + setLogTask(null)} size="small"> + + + + + {selectedRun ? ( + + {logLoading ? t("scheduledTasks.loading") : logContent || t("scheduledTasks.logEmpty")} + + ) : logLoading ? ( + + + {t("scheduledTasks.loading")} + + ) : ( + + {taskRuns.map((run) => ( + + ))} + {!logLoading && !taskRuns.length ? {t("scheduledTasks.runEmpty")} : null} + + )} + + + {selectedRun && logBefore !== null ? : null} + {selectedRun ? : null} + {selectedRun ? : } + + + + + ) : null} + + {deleteTask && editorScope ? ( + + setDeleteTask(null)} /> + + + + {t("scheduledTasks.delete.title")} + + setDeleteTask(null)} size="small"> + + + + + + {t("scheduledTasks.delete.description", { name: deleteTask.name })} + + + + + + + + + ) : null} + + setFeedback(null)} + /> + + ); +} diff --git a/console/src/shared/i18n/resources.ts b/console/src/shared/i18n/resources.ts index 5ba7d9fe4..5e41bc713 100644 --- a/console/src/shared/i18n/resources.ts +++ b/console/src/shared/i18n/resources.ts @@ -142,6 +142,9 @@ const rawShellResources = { terminal: { label: 'Terminal', }, + scheduledTasks: { + label: 'Scheduled Tasks', + }, services: { label: 'Services', }, @@ -155,6 +158,41 @@ const rawShellResources = { label: 'Settings', }, }, + scheduledTasks: { + title: 'Scheduled Tasks', + description: 'Run commands or scripts on a schedule in the platform environment or a saved SSH host.', + platform: 'Inside platform', + host: 'SSH host', + hostLabel: 'Host · {{name}}', + hostUnavailable: 'Host connection unavailable', + hostChecking: 'Checking SSH host capability...', + hostCapability: { ready: 'Host is ready for scheduled tasks.', unavailable: 'The SSH host is missing a required command or directory permission.', checkFailed: 'Unable to check the SSH host for scheduled task support.', authenticationFailed: 'SSH host authentication failed. Check its credentials.', connectionTimedOut: 'The SSH host connection timed out.', connectionFailed: 'Unable to connect to the SSH host.' }, + hostEmpty: 'No saved SSH host is available. You can still create platform tasks.', + loading: 'Loading scheduled tasks...', + nextRun: 'Next: {{value}}', + syncFailed: 'Synchronization failed', + syncStatus: { taskSyncing: 'Syncing...' }, + logTitle: 'Task log: {{name}}', runHistoryTitle: 'Run history: {{name}}', runLogTitle: 'Run log: {{name}}', runEmpty: 'No execution records yet.', + logEmpty: 'No output yet.', + columns: { name: 'Name', schedule: 'Schedule', executionMode: 'Execution method', executionLocation: 'Execution location', lastRun: 'Last run', enabled: 'Enabled', actions: 'Actions' }, + status: { never: 'Never run', running: 'Running', success: 'Succeeded', failed: 'Failed', skipped: 'Skipped' }, + actions: { create: 'Create task', save: 'Save', cancel: 'Cancel', close: 'Close', retry: 'Retry', run: 'Run now', refresh: 'Refresh status', refreshList: 'Refresh tasks', refreshRuns: 'Refresh run history', logs: 'Run history', loadEarlier: 'Load earlier', download: 'Download log', back: 'Back', edit: 'Edit', delete: 'Delete', toggle: 'Toggle {{name}}' }, + fields: { name: 'Name', target: 'Execution location', host: 'Host', schedule: 'Execution cycle', command: 'Command', commandHint: 'Commands run with Shell. Do not enter passwords or private keys. Use absolute paths for files and redirect output when you need to retain it.', commandExample: '# Runs with Shell. Do not enter passwords or private keys.\n# Use absolute paths and redirect output when you need to retain it.\n# Example: append the current time to a log file.\necho "$(date)" >> /var/log/scheduled-task.log', enabled: 'Enable status', enabledNow: 'Enable', executionMode: 'Execution method', scriptPath: 'Script path', scriptUpload: 'Upload script', uploadLegacyHint: 'Current uploaded script: {{name}}. Select a file to replace it.', timeout: 'Timeout', retryCount: 'Failure retries', retryCountHint: '0 means do not retry after a failure.', uploadHint: 'The selected script is loaded into the command editor.' }, + executionModes: { command: 'Custom command', path: 'Script path', upload: 'Uploaded script' }, + triggers: { cron: 'Scheduled', manual: 'Manual' }, exitCode: 'Exit code {{code}}', + scheduleSummary: { everyMinutes: 'Every {{count}} minute(s)', everyHours: 'Every {{count}} hour(s)', hourlyAt: 'Every hour at :{{minute}}', dailyAt: 'Daily at {{time}}', weeklyAt: 'Every {{weekday}} at {{time}}', monthlyAt: 'Monthly on day {{day}} at {{time}}', custom: 'Custom Cron' }, + scheduleModes: { hourly: 'Every hour', daily: 'Every day', weekly: 'Every week', monthly: 'Every month', intervalMinutes: 'Every N minutes', intervalHours: 'Every N hours', custom: 'Custom' }, + scheduleControls: { minute: 'minute', hour: 'hour', weekday: 'weekday', day: 'day', preview: 'Preview', cron: 'Cron expression', visual: 'Choose schedule', custom: 'Custom Cron', customEnabled: 'Use custom Cron', seconds: 'seconds', minutes: 'minutes', hours: 'hours' }, + weekdays: { '0': 'Sunday', '1': 'Monday', '2': 'Tuesday', '3': 'Wednesday', '4': 'Thursday', '5': 'Friday', '6': 'Saturday' }, + presets: { daily: 'Daily at 02:30', hourly: 'Hourly', weekly: 'Sunday at 02:00', monthly: 'First day of month at 04:00' }, + empty: { title: 'No scheduled tasks', description: 'Create a task to run a command on a regular schedule.', noResults: 'No matching tasks' }, + filters: { searchPlaceholder: 'Search by name, command, script, location, or schedule', allLocations: 'All locations', allStatuses: 'All statuses', allEnabled: 'All enable states', enabled: 'Enabled', disabled: 'Disabled' }, + editor: { createTitle: 'Create scheduled task', editTitle: 'Edit scheduled task' }, + validation: { invalidSchedule: 'Enter a valid five-field Cron expression.' }, + errors: { nameRequired: 'Enter a task name.', commandRequired: 'Enter a command.', scriptPathInvalid: 'Enter an absolute script path.', scriptRequired: 'Upload a valid script before saving.', inputTooLong: 'One or more task fields exceed the allowed length.', timeoutInvalid: 'Timeout must be between 0 and 86400 seconds.', retryInvalid: 'Failure retries must be between 0 and 10.', hostRequired: 'Select a saved SSH host.', platformHostMismatch: 'Platform tasks cannot use an SSH host.', nameExists: 'A task with this name already exists.', hostUnavailable: 'The SSH host is unavailable for scheduled tasks.', syncFailed: 'The task could not be synchronized. Check the task host and try again.', notFound: 'This scheduled task no longer exists.' }, + delete: { title: 'Delete scheduled task?', description: 'Delete “{{name}}”? This stops future scheduling only. Commands that have already started continue until they finish or time out.' }, + feedback: { created: 'Scheduled task created.', updated: 'Scheduled task saved.', deleted: 'Scheduled task deleted.', started: 'Task started.', failed: 'The operation could not be completed.' }, + }, applicationsHubPage: { hero: { title: 'Applications', @@ -2497,6 +2535,9 @@ const rawShellResources = { terminal: { label: '终端', }, + scheduledTasks: { + label: '计划任务', + }, services: { label: '服务', }, @@ -2510,6 +2551,41 @@ const rawShellResources = { label: '设置', }, }, + scheduledTasks: { + title: '计划任务', + description: '按计划在平台环境或已保存的 SSH 主机上运行命令或脚本。', + platform: '平台内部', + host: 'SSH 主机', + hostLabel: '主机 · {{name}}', + hostUnavailable: '主机连接不可用', + hostChecking: '正在检查 SSH 主机能力...', + hostCapability: { ready: '主机已准备好执行计划任务。', unavailable: 'SSH 主机缺少所需命令或目录权限。', checkFailed: '无法检测 SSH 主机是否支持计划任务。', authenticationFailed: 'SSH 主机认证失败,请检查连接凭据。', connectionTimedOut: 'SSH 主机连接超时。', connectionFailed: '无法连接 SSH 主机。' }, + hostEmpty: '没有可用的已保存 SSH 主机,您仍可创建平台任务。', + loading: '正在加载计划任务...', + nextRun: '下次:{{value}}', + syncFailed: '同步失败', + syncStatus: { taskSyncing: '正在同步中...' }, + logTitle: '任务日志:{{name}}', runHistoryTitle: '执行记录:{{name}}', runLogTitle: '执行日志:{{name}}', runEmpty: '暂无执行记录。', + logEmpty: '暂无输出。', + columns: { name: '名称', schedule: '计划', executionMode: '执行方式', executionLocation: '执行位置', lastRun: '上次执行', enabled: '启用', actions: '操作' }, + status: { never: '从未执行', running: '运行中', success: '成功', failed: '失败', skipped: '已跳过' }, + actions: { create: '新建任务', save: '保存', cancel: '取消', close: '关闭', retry: '重试', run: '立即执行', refresh: '刷新状态', refreshList: '刷新任务', refreshRuns: '刷新执行记录', logs: '执行记录', loadEarlier: '加载更早内容', download: '下载日志', back: '返回', edit: '编辑', delete: '删除', toggle: '切换 {{name}}' }, + fields: { name: '名称', target: '执行位置', host: '主机', schedule: '执行周期', command: '命令', commandHint: '命令将由 Shell 执行,请勿填写密码或私钥。建议使用绝对路径;如需保留输出,请在命令中重定向日志。', commandExample: '# 命令将由 Shell 执行,请勿填写密码或私钥。\n# 建议使用绝对路径;如需保留输出,请在命令中重定向日志。\n# 示例:将当前时间追加到日志文件。\necho "$(date)" >> /var/log/scheduled-task.log', enabled: '启用状态', enabledNow: '启用', executionMode: '执行方式', scriptPath: '脚本路径', scriptUpload: '上传脚本', uploadLegacyHint: '当前已上传脚本:{{name}}。选择文件可替换该脚本。', timeout: '超时时间', retryCount: '失败重试次数', retryCountHint: '为 0 表示失败后不重试。', uploadHint: '选择的脚本会载入命令编辑器。' }, + executionModes: { command: '自定义命令', path: '脚本路径', upload: '已上传脚本' }, + triggers: { cron: '计划触发', manual: '手动触发' }, exitCode: '退出码 {{code}}', + scheduleSummary: { everyMinutes: '每 {{count}} 分钟执行', everyHours: '每 {{count}} 小时执行', hourlyAt: '每小时第 {{minute}} 分钟执行', dailyAt: '每天 {{time}} 执行', weeklyAt: '每{{weekday}} {{time}} 执行', monthlyAt: '每月 {{day}} 日 {{time}} 执行', custom: '自定义 Cron' }, + scheduleModes: { hourly: '每小时', daily: '每天', weekly: '每周', monthly: '每月', intervalMinutes: '每 N 分钟', intervalHours: '每 N 小时', custom: '自定义' }, + scheduleControls: { minute: '分钟', hour: '小时', weekday: '星期', day: '日', preview: '预览', cron: 'Cron 表达式', visual: '选择计划', custom: '自定义 Cron', customEnabled: '使用自定义 Cron', seconds: '秒', minutes: '分', hours: '时' }, + weekdays: { '0': '星期日', '1': '星期一', '2': '星期二', '3': '星期三', '4': '星期四', '5': '星期五', '6': '星期六' }, + presets: { daily: '每天 02:30', hourly: '每小时', weekly: '每周日 02:00', monthly: '每月 1 日 04:00' }, + empty: { title: '暂无计划任务', description: '新建任务以定期执行命令。', noResults: '没有匹配的任务' }, + filters: { searchPlaceholder: '按名称、命令、脚本、位置或计划搜索', allLocations: '全部位置', allStatuses: '全部状态', allEnabled: '全部启用状态', enabled: '已启用', disabled: '已停用' }, + editor: { createTitle: '新建计划任务', editTitle: '编辑计划任务' }, + validation: { invalidSchedule: '请输入有效的五段 Cron 表达式。' }, + errors: { nameRequired: '请输入任务名称。', commandRequired: '请输入命令。', scriptPathInvalid: '请输入绝对路径的脚本路径。', scriptRequired: '请上传有效脚本后再保存。', inputTooLong: '一个或多个任务字段超出允许长度。', timeoutInvalid: '超时时间必须在 0 到 86400 秒之间。', retryInvalid: '失败重试次数必须在 0 到 10 之间。', hostRequired: '请选择已保存的 SSH 主机。', platformHostMismatch: '平台内部任务不能使用 SSH 主机。', nameExists: '已存在同名计划任务。', hostUnavailable: 'SSH 主机当前无法执行计划任务。', syncFailed: '任务未能同步,请检查任务主机后重试。', notFound: '该计划任务已不存在。' }, + delete: { title: '删除计划任务?', description: '删除“{{name}}”?删除后将不再调度执行;已启动的命令会继续运行,直至完成或超时。' }, + feedback: { created: '计划任务已创建。', updated: '计划任务已保存。', deleted: '计划任务已删除。', started: '任务已启动。', failed: '操作未能完成。' }, + }, applicationsHubPage: { hero: { title: '应用', diff --git a/docker/Dockerfile b/docker/Dockerfile index 8fe56e803..8b3937284 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -82,7 +82,8 @@ RUN apt-get update && \ libstdc++6 \ logrotate \ openssl \ - sqlite3 && \ + sqlite3 \ + util-linux && \ rm -rf /var/lib/apt/lists/* ARG WEBSOFT9_PRODUCT_VERSION= From 2fea696098e0e0ada3c220079f321a51d9e474aa Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 11:55:46 +0800 Subject: [PATCH 07/11] docs: add AWS WordPress RDS deployment guide --- docs/README.md | 1 + ...s-cloudformation-websoft9-wordpress-rds.md | 231 ++++++++++++++++++ docs/deployment.md | 2 + 3 files changed, 234 insertions(+) create mode 100644 docs/aws-cloudformation-websoft9-wordpress-rds.md diff --git a/docs/README.md b/docs/README.md index 73d8d137d..be4a71006 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Welcome to the Websoft9 documentation. Websoft9 is a web-based PaaS/Linux Panel | [Developer Guide](developer.md) | Development environment setup, coding standards, and contribution workflow | | [API Reference](api-reference.md) | AppHub REST API endpoints and usage | | [Deployment](deployment.md) | Production deployment, cloud marketplace, and operations | +| [AWS CloudFormation WordPress + RDS](aws-cloudformation-websoft9-wordpress-rds.md) | AWS deployment architecture for Websoft9, WordPress, and RDS | | [FAQ](faq.md) | Frequently asked questions | ## Project Resources diff --git a/docs/aws-cloudformation-websoft9-wordpress-rds.md b/docs/aws-cloudformation-websoft9-wordpress-rds.md new file mode 100644 index 000000000..264d741dc --- /dev/null +++ b/docs/aws-cloudformation-websoft9-wordpress-rds.md @@ -0,0 +1,231 @@ +# AWS CloudFormation 部署 Websoft9、WordPress 与 RDS 方案 + +## 1. 目标与范围 + +本文描述在 AWS 中使用 CloudFormation 部署下列组合的推荐方案: + +- 一台运行 Websoft9 的 EC2 实例; +- 一个私有 Amazon RDS for MySQL 实例; +- 必要的安全组; +- 客户在 Websoft9 控制台中安装的 WordPress 外接 RDS 应用。 + +目标是让客户通过创建一个 CloudFormation Stack 获得 Websoft9 与私有 RDS 基础设施,再在 Websoft9 控制台中使用 `external-db` profile 安装 WordPress。 + +首期范围: + +- 支持 RDS MySQL 8.0; +- WordPress 通过 RDS endpoint 和标准用户名密码连接数据库; +- RDS 保持私网访问; +- CloudFormation 负责 AWS 基础设施,客户通过 Websoft9 负责 WordPress 安装与应用生命周期。 + +不在首期范围内: + +- 自动迁移已有 WordPress 数据; +- 通过首启脚本自动安装 WordPress; +- RDS IAM Database Authentication; +- Websoft9 当前外接数据库之外的应用模板; +- 通过 CloudFormation 自动变更或删除外部数据库数据。 + +## 2. 方案选择 + +面向 AWS Marketplace 客户,使用 CloudFormation 作为交付入口: + +- 客户在 AWS 控制台或 CI 中创建 Stack; +- 模板创建或引用 AWS 资源; +- 模板引用 Websoft9 Marketplace AMI; +- 客户登录 Websoft9 控制台,选择 WordPress 的“自定义”数据库 profile; +- 客户填写 RDS endpoint、端口、数据库名、用户名和密码后安装 WordPress。 + +AMI 是平台基座,CloudFormation 是方案编排层。二者互补,不互相替代。 + +## 3. 推荐架构 + +```mermaid +flowchart LR + Admin[客户管理员] --> Stack[CloudFormation Stack] + Stack --> EC2[EC2: Websoft9 AMI] + Stack --> RDS[RDS MySQL 8.0] + Stack --> SG[EC2 与 RDS 安全组] + EC2 -->|私网 endpoint:3306| RDS + Admin -->|在 Websoft9 控制台填写 RDS 凭据| WP[WordPress] + WP -->|私网 endpoint:3306| RDS +``` + +推荐采用“自带网络(Bring Your Own VPC)”模式: + +- 客户在参数中提供 VPC、EC2 子网和至少两个 RDS 私有子网; +- 模板创建 EC2、RDS 和数据库安全组; +- RDS 的 `PubliclyAccessible` 设为 `false`; +- RDS 安全组仅允许来自 Websoft9 EC2 安全组的 TCP `3306`; +- Websoft9 EC2 可以位于公有子网以承接用户访问,但访问 RDS 仍通过 VPC 私网地址进行。 + +Quick Start 模板可以额外提供新建 VPC 的能力,但应作为独立模板发布。它会引入 NAT Gateway、路由和更高的费用与权限需求,不宜作为企业默认方案。 + +## 4. CloudFormation 资源职责 + +| 资源 | 职责 | 关键配置 | +|---|---|---| +| `AWS::EC2::Instance` 或 Launch Template | 运行 Websoft9 AMI | EBS 加密、IMDSv2 | +| `AWS::RDS::DBInstance` | 托管 WordPress 数据库 | MySQL 8.0、私网、加密、备份、删除保护 | +| `AWS::RDS::DBSubnetGroup` | 放置 RDS | 至少两个可用区的私有子网 | +| `AWS::EC2::SecurityGroup` | 约束网络访问 | RDS 仅向 EC2 安全组开放 3306 | +| `AWS::Logs::*`(可选) | 保存 EC2 与平台日志 | CloudWatch Logs 或 CloudWatch Agent | + +生产 RDS 应至少配置: + +- Storage encryption; +- 自动备份和合理的保留期; +- Multi-AZ 是否启用由服务等级决定; +- `DeletionPolicy: Snapshot` 或 `Retain`; +- `DeletionProtection: true`,生产环境由显式流程解除; +- CloudWatch 监控和告警。 + +## 5. 网络与安全组 + +### 5.1 RDS 私网连接 + +Websoft9 与 RDS 应在同一个 VPC 或已建立受控路由的网络之间通信。WordPress 使用 RDS DNS endpoint,例如: + +```text +wordpress-prod.abc123.ap-southeast-1.rds.amazonaws.com:3306 +``` + +不得使用 RDS 解析出的 IP 地址。RDS 故障转移时底层 IP 可能变化,endpoint 会保持可用。 + +### 5.2 安全组规则 + +| 目标 | 入站来源 | 端口 | 用途 | +|---|---|---:|---| +| Websoft9 EC2 | 客户管理网段或负载均衡器 | 80 / 443 / 9000 | 网站访问与管理控制台 | +| RDS | Websoft9 EC2 安全组 | 3306 | WordPress 与安装前连接测试 | + +RDS 安全组禁止将 `3306` 开放给 `0.0.0.0/0`。即使 RDS 设置为公开可访问,也不应将其作为本方案的常规部署方式。 + +## 6. 数据库凭据与初始化 + +CloudFormation 首期只负责创建 RDS 实例。RDS 管理员用户名和密码由客户在创建 Stack 时填写;密码参数应使用 `NoEcho: true`,并且不得写入 Stack Output、EC2 User Data、Tag 或日志。 + +Websoft9 的 `external-db` 安装流程只验证连接并安装 WordPress,不创建数据库、账号或权限。因此在 WordPress 安装前,客户需要通过 RDS Query Editor、数据库客户端或既有运维流程完成: + +1. 创建 `wordpress` 数据库; +2. 创建 `wordpress_user`; +3. 仅向该数据库授予 WordPress 所需权限; +4. 妥善保存应用账号密码,并在 Websoft9 安装表单中填写。 + +RDS 管理员密码与 WordPress 应用账号应区分。WordPress 应使用仅授权目标数据库的专用账号。 + +## 7. 在 Websoft9 中安装 WordPress + +1. 等待 RDS 状态变为 `available`; +2. 登录 Websoft9 控制台; +3. 在应用商店选择 WordPress,进入安装; +4. 在“应用数据库”中选择“自定义”; +5. 填写 RDS endpoint、`3306`、数据库名、WordPress 专用账号与密码; +6. 使用“测试连接”验证私网连通性和数据库凭据; +7. 完成安装。 + +Websoft9 将连接参数保存为 WordPress 的运行配置。修改 RDS 密码后,客户必须同步更新该应用的配置并重部署,否则 WordPress 会因继续使用旧密码而连接失败。 + +## 8. WordPress 外接数据库参数映射 + +Websoft9 当前 WordPress `external-db` profile 使用以下标准连接信息: + +| Websoft9 设置 | AWS RDS 值 | +|---|---| +| `W9_DB_HOST_SET` | RDS endpoint | +| `W9_DB_PORT_SET` | `3306` | +| `W9_DB_NAME_SET` | 预创建的 WordPress 数据库名 | +| `W9_DB_USER_SET` | WordPress 专用 RDS 用户 | +| `W9_DB_PASSWORD_SET` | 客户填写的 WordPress 专用用户密码 | + +安装前,Websoft9 使用这些字段连接数据库并执行只读连接验证;成功后 WordPress 使用同一连接参数运行。Websoft9 不管理 RDS 生命周期,也不删除外部数据库或账号。 + +RDS MySQL 8.0 与当前 WordPress 兼容性声明相匹配。Aurora MySQL 应作为独立兼容性与运维验证范围处理后再纳入商品承诺。 + +## 9. TLS 与 IAM Database Authentication + +### 9.1 TLS + +RDS 支持 TLS,是否强制由客户的 RDS Parameter Group 和安全策略决定。若客户启用 MySQL `require_secure_transport=ON`,应用端必须同时具备 TLS 参数与 AWS RDS CA 证书。 + +当前外接数据库 profile 仅包含主机、端口、数据库名、用户名和密码。它尚未定义 TLS 模式、CA 证书分发或 WordPress 运行时 TLS 参数。因此首期 CloudFormation 方案应: + +- 明确声明使用 RDS 密码认证; +- 不将“强制 TLS”列为已支持能力; +- 在产品实现支持 SSL mode、CA 文件注入以及安装前/运行时一致校验后,再提供 TLS 强制部署选项。 + +### 9.2 IAM Database Authentication + +IAM Database Authentication 使用短时 token,不是永久数据库密码。WordPress 当前将固定连接凭据用于运行时连接,不能自动刷新 IAM token。因此首期不建议为 WordPress 启用 IAM Database Authentication。 + +高合规场景可后续评估 RDS Proxy、Secrets Manager 密码轮换与应用侧连接凭据刷新;这些不是当前外接数据库 profile 的能力。 + +## 10. 权限模型 + +CloudFormation 部署者不应被要求使用 `AdministratorAccess`,但需具备创建模板所列资源的权限。首期最小权限应覆盖指定 VPC 范围内的 EC2、RDS、Security Group、CloudFormation 以及可选 CloudWatch Logs 操作。 + +企业客户通常要求自带 VPC、子网、KMS Key、日志桶或安全组。模板应支持这些参数,而不试图创建或接管客户全部网络资源。 + +## 11. 失败、回滚与删除 + +| 场景 | 建议行为 | +|---|---| +| RDS 未就绪或网络不可达 | 客户等待 RDS 就绪并检查安全组、路由与 DNS;不尝试更改 RDS 数据 | +| 数据库连接验证失败 | Websoft9 不安装 WordPress;客户修正 endpoint、网络或凭据后再次测试 | +| WordPress 安装失败 | 查看 Websoft9 安装状态和无敏感信息的日志;不删除 RDS 数据 | +| Stack 创建失败 | 非生产测试可回滚临时资源;生产 RDS 应按 Snapshot/Retain 策略保留 | +| 删除 Stack | 默认保留或创建 RDS 快照;不得由 Websoft9 删除 RDS 数据库或账号 | +| 更新 Stack | 避免默认替换 EC2 或 RDS;AMI、应用版本、数据库变更须显式版本化与维护窗口 | + +CloudFormation 删除 RDS 与 Websoft9 卸载 WordPress 是两条独立生命周期。产品文档必须明确:Websoft9 的外部数据库卸载不会删除 RDS、数据库、账号或数据。 + +## 12. 可观测性与验收 + +应提供以下可观测信息: + +- CloudFormation Stack Events; +- Websoft9 容器与 AppHub 日志; +- WordPress 安装跟踪 ID; +- RDS CloudWatch 指标、错误日志和备份状态; +- 不包含密码的部署结果与故障原因。 + +建议验收标准: + +1. RDS 没有公网入站规则,且 `PubliclyAccessible=false`; +2. 只有 Websoft9 EC2 安全组可访问 RDS `3306`; +3. WordPress 通过 RDS endpoint 成功安装和访问; +4. 数据库密码未出现在 Stack Output、User Data 或日志; +5. 删除或卸载 WordPress 不会删除 RDS 数据; +6. RDS 故障转移后,WordPress 仍通过 endpoint 恢复连接。 + +## 13. 实施阶段 + +### 阶段一:验证模板 + +- 自带 VPC/子网参数; +- 单可用区 RDS MySQL 8.0; +- Websoft9 AMI; +- 外接数据库安装验证; +- 手工数据库初始化; +- 不启用 TLS 强制与 IAM Database Authentication。 + +### 阶段二:可交付方案 + +- CloudWatch 日志、告警与部署状态输出; +- RDS 备份、快照保留和删除保护; +- 最小 IAM 权限文档; +- WordPress + RDS 安装操作说明。 + +### 阶段三:企业能力 + +- Multi-AZ、KMS、私有访问、客户自带安全组/KMS; +- RDS TLS 端到端支持; +- 密码轮换与 RDS Proxy; +- Secrets Manager 与受控首启自动安装器; +- AWS Marketplace 商品参数、升级和支持流程。 + +## 14. 结论 + +CloudFormation 可以将 Websoft9 AMI、RDS 和网络编排为可重复部署的 AWS 基础设施。首期最可靠的基线是:私网 RDS MySQL 8.0、安全组到安全组放行、WordPress 专用低权限数据库账号,以及客户在 Websoft9 控制台中完成外接数据库安装。 + +在当前 Websoft9 外接数据库实现中,标准密码认证可直接适配 RDS。TLS 强制、IAM Database Authentication、Secrets Manager 与首启自动安装应在补齐运行时凭据与连接配置支持后,作为后续增强能力交付。 diff --git a/docs/deployment.md b/docs/deployment.md index 91dd0408e..eaf9cda70 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -48,6 +48,8 @@ Websoft9 is available on major cloud marketplaces: | Alibaba Cloud | [Websoft9 on Alibaba Cloud](https://marketplace.alibabacloud.com/products/201072001/sgcmjj00034378.html) | | Huawei Cloud | [Websoft9 on Huawei Cloud](https://marketplace.huaweicloud.com/intl/contents/bf4480ae-d0af-422c-b246-e2ec67743f4e) | +For an AWS CloudFormation deployment architecture that provisions Websoft9, WordPress, and a private RDS database, see [AWS CloudFormation Websoft9 WordPress + RDS](aws-cloudformation-websoft9-wordpress-rds.md). + ## Configuration ### Environment Variables From f3f41f362ce39236aaee761c292045e5e21f1423 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 15:53:16 +0800 Subject: [PATCH 08/11] fix(installer): use minor tag for fresh installs --- install/install.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/install/install.sh b/install/install.sh index 5cbe1c393..ca56e8053 100755 --- a/install/install.sh +++ b/install/install.sh @@ -195,6 +195,19 @@ _resolve_latest_version() { return 1 } +_resolve_initial_image_tag() { + local install_path="$1" + local version + + version="$(_resolve_latest_version "$install_path" 2>/dev/null || true)" + if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then + echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + return 0 + fi + + _resolve_target_image_tag "$install_path" +} + _resolve_current_modern_version() { local install_path="$1" local compose_file="${install_path}/docker-compose.yml" @@ -295,7 +308,7 @@ case "$env_kind" in empty) log_step "No Websoft9 installation detected. Starting a fresh install" if [ -z "$_OPT_VERSION_EXPLICIT" ]; then - _resolved="$(_resolve_target_image_tag "$OPT_PATH" 2>/dev/null || true)" + _resolved="$(_resolve_initial_image_tag "$OPT_PATH" 2>/dev/null || true)" [ -n "$_resolved" ] && OPT_VERSION="$_resolved" fi run_install "$OPT_CONSOLE_PORT" "$OPT_PATH" "$OPT_VERSION" From cb1520258c0e4ad3d921d4cdd52e7425216d9768 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 15:59:42 +0800 Subject: [PATCH 09/11] chore: prepare 2.4.0-dev release --- CHANGELOG.md | 14 ++++++++++++++ version.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 386261e9c..4699bbd93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Websoft9 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0-dev] - 2026-08-21 + +### Added +- **Scheduled Tasks** — Add managed scheduled tasks for recurring platform operations. +- **External Database Profiles** — Add install profiles for applications that use externally managed databases, including WordPress with external MySQL. +- **Platform Readiness** — Add a readiness endpoint and wait for the platform to become ready before the setup wizard proceeds. + +### Changed +- **Installation** — Use the major-minor image tag for fresh installations, while upgrades continue to use the full release version. +- **Image Pulling** — Use registry mirror fallback when pulling utility images. + +### Fixed +- **Terminal File Uploads** — Remove the gateway's default 1 MB API request limit for file uploads. + ## [2.3.4] - 2026-08-05 ### Fixed diff --git a/version.json b/version.json index 15415108e..7c59a083e 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "2.3.4", + "version": "2.4.0-dev", "channel": "" } \ No newline at end of file From 934c54975c617d996776a8d76db2735799a9e172 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 16:48:37 +0800 Subject: [PATCH 10/11] fix: restore scheduled tasks after upgrade --- apphub/src/cli/apphub_cli.py | 10 +++++++++ apphub/src/services/scheduled_tasks.py | 4 ++++ apphub/tests/test_scheduled_tasks.py | 30 ++++++++++++++++++++++++++ install/lib/upgrade-modern.sh | 7 ++++++ 4 files changed, 51 insertions(+) diff --git a/apphub/src/cli/apphub_cli.py b/apphub/src/cli/apphub_cli.py index 916be76a0..c9a424ddf 100755 --- a/apphub/src/cli/apphub_cli.py +++ b/apphub/src/cli/apphub_cli.py @@ -13,6 +13,7 @@ from src.services.product_auth import ProductAuthService from src.core.exception import CustomException from src.services.appstore_sync_manager import AppStoreSyncManager +from src.services.scheduled_tasks import ScheduledTaskService @click.group() def cli(): @@ -119,6 +120,15 @@ def upgrade(target, channel, dev, force_refresh): raise click.ClickException(str(e)) +@cli.command(hidden=True) +def reconcile_scheduled_tasks(): + """Restore local scheduled-task runners and cron after an upgrade.""" + try: + ScheduledTaskService().reconcile_local_schedule() + except Exception as e: + raise click.ClickException(str(e)) + + @cli.command(hidden=True) @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='New password for the system user') def resetpwd(password): diff --git a/apphub/src/services/scheduled_tasks.py b/apphub/src/services/scheduled_tasks.py index 0b9fe2026..05277d0ab 100644 --- a/apphub/src/services/scheduled_tasks.py +++ b/apphub/src/services/scheduled_tasks.py @@ -132,6 +132,10 @@ def start_sync(self, session_token: Optional[str]) -> dict[str, str]: self._start_background_sync(session_token, str(operator["id"])) return {"status": "started"} + def reconcile_local_schedule(self) -> None: + with self._lock: + self._sync() + def _start_background_sync(self, session_token: Optional[str], operator_id: str) -> None: with self._lock: if operator_id in self._background_syncing: diff --git a/apphub/tests/test_scheduled_tasks.py b/apphub/tests/test_scheduled_tasks.py index 9ab4440c7..db14c1835 100644 --- a/apphub/tests/test_scheduled_tasks.py +++ b/apphub/tests/test_scheduled_tasks.py @@ -189,6 +189,36 @@ def test_platform_task_crud_renders_cron_and_preserves_operator_isolation(monkey assert not (tmp_path / "tasks" / "scripts" / f"{task['task_id']}.sh").exists() +def test_reconcile_local_schedule_rebuilds_only_enabled_container_tasks(tmp_path): + cron_file = tmp_path / "websoft9-tasks" + host_access = FakeHostTaskAccessService() + service = ScheduledTaskService( + data_dir=str(tmp_path / "tasks"), + cron_file=str(cron_file), + auth_service=FakeAuthService(), + cron_reloader=lambda: None, + host_access_service=host_access, + ) + enabled = service.create_task("valid-session", {"name": "Enabled", "schedule": "* * * * *", "command": "date"}) + disabled = service.create_task("valid-session", {"name": "Disabled", "schedule": "* * * * *", "command": "echo disabled", "enabled": False}) + host_task = service.create_task( + "valid-session", {"name": "Remote", "target": "host", "profile_id": "profile-1", "schedule": "* * * * *", "command": "date"} + ) + + cron_file.unlink() + (tmp_path / "tasks" / "scripts" / f"{enabled['task_id']}.sh").unlink() + remote_commands_before_reconcile = len(host_access.client.commands) + service.reconcile_local_schedule() + + cron = cron_file.read_text(encoding="utf-8") + assert enabled["task_id"] in cron + assert disabled["task_id"] not in cron + assert host_task["task_id"] not in cron + assert (tmp_path / "tasks" / "scripts" / f"{enabled['task_id']}.sh").is_file() + assert not (tmp_path / "tasks" / "scripts" / f"{disabled['task_id']}.sh").exists() + assert len(host_access.client.commands) == remote_commands_before_reconcile + + def test_platform_task_rejects_profile_on_container_target(monkeypatch, tmp_path): service = ScheduledTaskService( data_dir=str(tmp_path / "tasks"), cron_file=str(tmp_path / "websoft9-tasks"), auth_service=FakeAuthService(), cron_reloader=lambda: None diff --git a/install/lib/upgrade-modern.sh b/install/lib/upgrade-modern.sh index a4c2238aa..9d1f63451 100755 --- a/install/lib/upgrade-modern.sh +++ b/install/lib/upgrade-modern.sh @@ -247,6 +247,13 @@ run_upgrade_modern() { die "$EXIT_VALIDATE" "Upgrade failed (post-upgrade validation)" fi + log_step "Restoring local scheduled tasks" + if ! docker exec "$MODERN_CONTAINER_NAME" websoft9 reconcile-scheduled-tasks; then + log_error "Scheduled task recovery failed, rolling back" + _upgrade_modern_rollback "$install_path" "$backup_dir" + die "$EXIT_VALIDATE" "Upgrade failed (scheduled task recovery)" + fi + log_info "==== Upgrade successful ====" print_runtime_summary upgrade "$install_path" "$console_port" "$backup_dir" } From 9f48ba6c92c0da838f92ead93b9610ac5227857b Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Fri, 21 Aug 2026 17:10:41 +0800 Subject: [PATCH 11/11] fix(ci): restore PR validation dependencies --- .github/workflows/ci-pr.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index d9f1ea0a8..8c59eab9d 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -58,9 +58,12 @@ jobs: fetch-depth: 0 - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + run: | + docker run --rm \ + -v "$GITHUB_WORKSPACE:/repo" \ + -w /repo \ + zricethezav/gitleaks:v8.30.1 \ + detect --source=/repo --redact # ── Change detection (skip irrelevant jobs) ── changes: @@ -306,7 +309,7 @@ jobs: timeout-minutes: 2 steps: - uses: actions/checkout@v4 - - uses: hadolint/hadolint-action@v3 + - uses: hadolint/hadolint-action@v3.4.0 with: dockerfile: docker/Dockerfile ignore: DL3008,DL3013 @@ -336,10 +339,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install validator dependencies run: | sudo apt-get update sudo apt-get install -y jq rsync wget zip + python -m pip install --upgrade pip + python -m pip install -r apphub/requirements.txt pytest - name: Smoke test AppHub appstore sync entrypoints run: |
{t('myAppsDetailPage.tabs.database.columns.source')}{t('myAppsDetailPage.tabs.database.columns.type')}{t('myAppsDetailPage.tabs.database.columns.host')}{t('myAppsDetailPage.tabs.database.columns.account')}{t(isExternalDatabase ? 'myAppsDetailPage.tabs.database.columns.address' : 'myAppsDetailPage.tabs.database.columns.host')}{t('myAppsDetailPage.tabs.database.columns.name')}{t(isExternalDatabase ? 'myAppsDetailPage.tabs.database.columns.username' : 'myAppsDetailPage.tabs.database.columns.account')} {t('myAppsDetailPage.tabs.database.columns.password')}{t('myAppsDetailPage.tabs.database.columns.tool')}{t('myAppsDetailPage.tabs.database.columns.tool')}
{t('myAppsDetailPage.tabs.database.custom')}{row.type} {row.host}{row.databaseName}{row.account}
- {showPasswords[row.type] ? row.password : '•'.repeat(Math.min(row.password.length, 16))} + {showPasswords[`${row.source}-${row.type}`] ? row.password : '•'.repeat(Math.min(row.password.length, 16))}
+ {!row.isExternal ? {row.toolApps.length > 0 ? (
{row.toolApps.map((tool) => ( @@ -1905,7 +1935,7 @@ export function MyAppDetailPage() { ))}
) : '-'} -
{t("scheduledTasks.columns.name")}{t("scheduledTasks.columns.executionMode")}{t("scheduledTasks.columns.executionLocation")}{t("scheduledTasks.columns.schedule")}{t("scheduledTasks.columns.lastRun")}{t("scheduledTasks.columns.enabled")} + {t("scheduledTasks.columns.actions")} +
+ + + + {t("scheduledTasks.loading")} + + +
+ + + + {t("scheduledTasks.empty.title")} + + + {t("scheduledTasks.empty.description")} + + + +
+ + + {t("scheduledTasks.empty.noResults")} + + +
+ + toggleTaskGroup(group.target)} + size="small" + > + + + + {group.label} + + + +
+ + {task.name} + + + + + {t(`scheduledTasks.executionModes.${task.execution_mode}`)} + + + {targetLabel(task)} + {scheduleLabel(task.schedule)} + + {task.syncing || refreshingTaskIds.has(task.task_id) ? ( + + + {t("scheduledTasks.syncStatus.taskSyncing")} + + ) : task.sync_status === "unreachable" ? ( + + + {t("scheduledTasks.hostUnavailable")} + + ) : task.sync_status === "failed" ? ( + + + {t("scheduledTasks.syncFailed")} + + ) : task.last_run_at ? ( + + + + {formatDateTime(task.last_run_at, formatter)} + + + ) : ( + + + {t("scheduledTasks.status.never")} + + )} + + void updateTask(task, "toggle")} + size="small" + slotProps={{ + input: { + "aria-label": t("scheduledTasks.actions.toggle", { + name: task.name, + }), + }, + }} + /> + + + + + void updateTask(task, "run")} + size="small" + > + + + + + + + void updateTask(task, "refresh")} + size="small" + > + {pendingTaskId === task.task_id ? : } + + + + + void openLog(task)} + size="small" + > + + + + + openEditDialog(task)} + size="small" + > + + + + + setDeleteTask(task)} + size="small" + > + + + + +