From db1ce68800fbc627815da31c24371ecbebd724b5 Mon Sep 17 00:00:00 2001 From: DungTT Date: Mon, 20 Jul 2026 16:08:36 +0700 Subject: [PATCH] update check active --- agent/README.md | 26 +- agent/app/api/apps.py | 54 +++- agent/app/api/tasks.py | 3 +- agent/app/config.py | 2 +- agent/app/core/command_runner.py | 3 +- agent/app/core/installer.py | 41 ++- agent/app/core/service_manager.py | 62 ++++- agent/app/core/task_runner.py | 154 ++++++++++- agent/app/storage/database.py | 32 ++- agent/app/storage/repository.py | 47 +++- agent/packaging/DEBIAN/control | 2 +- agent/scripts/build-deb.sh | 5 +- agent/tests/test_apt_components.py | 259 +++++++++++++++++- agent/tests/test_service_check_persistence.py | 194 +++++++++++++ web-client/src/main.jsx | 259 +++++++++++++++--- web-client/src/services/api.js | 53 +++- web-client/src/styles.css | 107 ++++++++ 17 files changed, 1230 insertions(+), 73 deletions(-) create mode 100644 agent/tests/test_service_check_persistence.py diff --git a/agent/README.md b/agent/README.md index cfdea3f..c5ff776 100644 --- a/agent/README.md +++ b/agent/README.md @@ -88,4 +88,28 @@ APT components use a fixed manifest contract and never accept shell commands: } ``` -The package name must be present in `ALLOWED_APT_PACKAGES` (default: `postgresql`). For PostgreSQL, the Agent refreshes the APT index, installs the distro package non-interactively, verifies `postgresql.service`, and waits for `pg_isready` before completing the task. +The package name must be present in `ALLOWED_APT_PACKAGES` (default: `postgresql`). After installation, the Agent discovers concrete systemd `.service` units shipped by the trusted package and reads their state with a fixed `systemctl show` argv (no shell command is accepted from the manifest or Web Client). The structured result is returned as `serviceCheckStatus` and `serviceChecks` from the task component API and is retained with the installed app for the UI. + +PostgreSQL keeps an additional trusted policy: the Agent enables and starts `postgresql.service`, verifies that it is active, and waits for `pg_isready`. Other discovered package services are checked read-only; they are not blindly enabled or started because packages can ship optional or one-shot units. + +`GET /apps/installed` refreshes the current systemd state for retained service names. For the trusted PostgreSQL policy it also re-runs `pg_isready`, so the UI does not report a healthy meta-service while the database is unavailable. + +Example task component service result: + +```json +{ + "serviceCheckStatus": "healthy", + "serviceChecks": [ + { + "serviceName": "postgresql.service", + "loadState": "loaded", + "activeState": "active", + "subState": "running", + "unitFileState": "enabled", + "healthy": true, + "readinessStatus": "ready", + "checkedAt": "2026-07-20T00:00:00Z" + } + ] +} +``` diff --git a/agent/app/api/apps.py b/agent/app/api/apps.py index d057a6c..18e4508 100644 --- a/agent/app/api/apps.py +++ b/agent/app/api/apps.py @@ -4,6 +4,9 @@ import uuid from fastapi import APIRouter, BackgroundTasks, HTTPException +from app.core.command_runner import CommandRunner +from app.core.installer import AptInstaller +from app.core.service_manager import ServiceManager from app.core.task_runner import TaskRunner from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest from app.storage.repository import Repository @@ -20,7 +23,56 @@ def _task_id(prefix: str) -> str: @router.get("/installed") def installed_apps() -> list[dict]: - return repository.list_installed_apps() + apps = repository.list_installed_apps() + command_runner = CommandRunner(repository) + service_manager = ServiceManager(command_runner) + apt_installer = AptInstaller(command_runner) + for app in apps: + service_checks: list[dict] = [] + component_statuses: list[str] = [] + for component in repository.list_installed_components(app["app_id"]): + check_status = component.get("service_check_status", "not-checked") + if check_status != "not-checked": + component_statuses.append(check_status) + for check in component.get("service_checks", []): + service_name = str(check.get("serviceName") or "").strip() + if not service_name: + continue + current_check = service_manager.get_service_status(service_name) + if current_check.get("status") == "unknown" and current_check.get("errorMessage"): + current_check = dict(check) + current_check["stale"] = True + elif check.get("readinessType") == "postgresql": + readiness_status = "ready" if apt_installer.is_postgresql_ready() else "failed" + current_check["readinessType"] = "postgresql" + current_check["readinessStatus"] = readiness_status + current_check["healthy"] = bool(current_check.get("healthy")) and readiness_status == "ready" + service_checks.append( + { + **current_check, + "componentId": component["component_id"], + "packageName": component.get("package_name"), + } + ) + + if service_checks and any(not check.get("healthy") for check in service_checks): + overall_status = "unhealthy" + elif service_checks: + overall_status = "healthy" + elif "unhealthy" in component_statuses: + overall_status = "unhealthy" + elif "checking" in component_statuses: + overall_status = "checking" + elif "healthy" in component_statuses: + overall_status = "healthy" + elif "not-applicable" in component_statuses: + overall_status = "not-applicable" + else: + overall_status = "not-checked" + + app["serviceCheckStatus"] = overall_status + app["serviceChecks"] = service_checks + return apps @router.post("/install") diff --git a/agent/app/api/tasks.py b/agent/app/api/tasks.py index fd4b573..4eacd79 100644 --- a/agent/app/api/tasks.py +++ b/agent/app/api/tasks.py @@ -65,10 +65,11 @@ def get_task_components(task_id: str) -> dict: "progress": item["progress"], "currentStep": item["current_step"], "errorMessage": item["error_message"], + "serviceCheckStatus": item["service_check_status"], + "serviceChecks": item["service_checks"], "startedAt": item["started_at"], "finishedAt": item["finished_at"], } for item in repository.get_task_components(task_id) ], } - diff --git a/agent/app/config.py b/agent/app/config.py index bc49744..2185a69 100644 --- a/agent/app/config.py +++ b/agent/app/config.py @@ -63,7 +63,7 @@ def _bool(name: str, default: bool) -> bool: def get_settings() -> Settings: robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://package.pnkr.cloud").rstrip("/") return Settings( - agent_version=os.getenv("AGENT_VERSION", "1.0.0"), + agent_version=os.getenv("AGENT_VERSION", "1.0.3"), host=os.getenv("AGENT_HOST", "0.0.0.0"), port=int(os.getenv("AGENT_PORT", "5010")), robot_package_base_url=robot_package_base_url, diff --git a/agent/app/core/command_runner.py b/agent/app/core/command_runner.py index ded7c80..0a716b9 100644 --- a/agent/app/core/command_runner.py +++ b/agent/app/core/command_runner.py @@ -51,6 +51,7 @@ class CommandRunner: command: list[str], timeout: int | None = None, env: Mapping[str, str] | None = None, + log_output: bool = True, ) -> subprocess.CompletedProcess[str]: if self.task_id: self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}") @@ -74,7 +75,7 @@ class CommandRunner: self.repository.add_log(self.task_id, "error", f"Command timed out: {' '.join(command)}") raise CommandError(command, 124, error.stdout or "", error.stderr or "") from error - if self.task_id: + if self.task_id and log_output: for line in result.stdout.splitlines(): self.repository.add_log(self.task_id, "debug", line) for line in result.stderr.splitlines(): diff --git a/agent/app/core/installer.py b/agent/app/core/installer.py index 248a807..9a88342 100644 --- a/agent/app/core/installer.py +++ b/agent/app/core/installer.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from pathlib import Path +from pathlib import Path, PurePosixPath from app.core.command_runner import CommandError, CommandRunner @@ -125,6 +125,38 @@ class AptInstaller(DebInstaller): env=APT_NONINTERACTIVE_ENV, ) + def discover_service_units(self, package_name: str) -> list[str]: + """Return concrete systemd service units shipped by an installed package. + + The package name is already constrained by the Agent APT allowlist. It is + still passed as a single argv item and no shell expansion is performed. + Template units are omitted because they cannot be meaningfully checked + without an instance name. + """ + try: + result = self.command_runner.run( + ["dpkg-query", "-L", package_name], + timeout=30, + log_output=False, + ) + except CommandError: + return [] + + service_names: set[str] = set() + for raw_path in result.stdout.splitlines(): + path = raw_path.strip() + if not path.endswith(".service"): + continue + if "/systemd/system/" not in path: + continue + + service_name = PurePosixPath(path).name + if "@" in service_name: + continue + service_names.add(service_name) + + return sorted(service_names) + def wait_for_postgresql( self, attempts: int = 6, @@ -141,3 +173,10 @@ class AptInstaller(DebInstaller): time.sleep(delay_seconds) raise RuntimeError("PostgreSQL did not become ready after installation") from last_error + + def is_postgresql_ready(self) -> bool: + try: + self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10) + return True + except Exception: + return False diff --git a/agent/app/core/service_manager.py b/agent/app/core/service_manager.py index 0318b56..47297e3 100644 --- a/agent/app/core/service_manager.py +++ b/agent/app/core/service_manager.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime, timezone + +from app.core.command_runner import CommandError from app.core.command_runner import CommandRunner @@ -31,17 +34,60 @@ class ServiceManager: raise RuntimeError(f"Service is not active: {service_name}") def get_service_status(self, service_name: str) -> dict[str, object]: - active = self._query(["systemctl", "is-active", service_name]) == "active" - enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled" + command = [ + "systemctl", + "show", + service_name, + "--no-pager", + "--property=LoadState", + "--property=ActiveState", + "--property=SubState", + "--property=UnitFileState", + ] + error_message: str | None = None + try: + output = self.command_runner.run(command, timeout=5).stdout + except CommandError as error: + output = error.stdout + error_message = str(error) + except Exception as error: + output = "" + error_message = str(error) + + properties = self._parse_properties(output) + load_state = properties.get("LoadState", "unknown") or "unknown" + active_state = properties.get("ActiveState", "unknown") or "unknown" + sub_state = properties.get("SubState", "unknown") or "unknown" + unit_file_state = properties.get("UnitFileState", "unknown") or "unknown" + active = load_state == "loaded" and active_state == "active" + enabled = unit_file_state in {"enabled", "enabled-runtime"} + + if load_state == "not-found": + status = "not-found" + elif active_state != "unknown": + status = active_state + else: + status = "unknown" + return { "serviceName": service_name, + "loadState": load_state, + "activeState": active_state, + "subState": sub_state, + "unitFileState": unit_file_state, "active": active, "enabled": enabled, - "status": "running" if active else "stopped", + "healthy": active, + "status": status, + "checkedAt": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + "errorMessage": error_message, } - def _query(self, command: list[str]) -> str: - try: - return self.command_runner.run(command).stdout.strip() - except Exception: - return "unknown" + @staticmethod + def _parse_properties(output: str) -> dict[str, str]: + properties: dict[str, str] = {} + for line in output.splitlines(): + key, separator, value = line.partition("=") + if separator and key: + properties[key.strip()] = value.strip() + return properties diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index 6c12c2f..b87e6e6 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -18,6 +18,18 @@ from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest from app.storage.repository import Repository, utc_now +APT_SERVICE_POLICIES: dict[str, dict[str, Any]] = { + "postgresql": { + "managedServices": ["postgresql.service"], + "readiness": "postgresql", + }, +} + + +class InstalledComponentVerificationError(RuntimeError): + """The package changed on disk, but its required runtime verification failed.""" + + class TaskRunner: def __init__(self, repository: Repository) -> None: self.repository = repository @@ -25,6 +37,7 @@ class TaskRunner: self.manifest_validator = ManifestValidator() def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None: + manifest: dict[str, Any] | None = None try: self._mark_started(task_id, f"starting {task_type}") self._require_root_if_available() @@ -49,6 +62,9 @@ class TaskRunner: finished_at=utc_now(), ) self.repository.add_log(task_id, "info", f"Task {task_id} completed") + except InstalledComponentVerificationError as error: + self._track_attention_install(task_id, manifest) + self._fail_task(task_id, error) except Exception as error: self._fail_task(task_id, error) @@ -332,30 +348,121 @@ class TaskRunner: installed_component = dict(component) installed_component["version"] = installed_version - if package_name == "postgresql": - service_name = "postgresql.service" + policy = APT_SERVICE_POLICIES.get(package_name, {}) + managed_services = list(policy.get("managedServices", [])) + discovered_services = installer.discover_service_units(package_name) + service_names = list(dict.fromkeys([*managed_services, *discovered_services])) + + self.repository.update_task_component( + task_id, + component_id, + progress=80, + current_step="checking package services", + service_check_status="checking", + service_checks=[], + ) + + verification_errors: list[str] = [] + if managed_services: self.repository.update_task_component( task_id, component_id, progress=85, - current_step="starting PostgreSQL service", + current_step="starting managed package services", ) - services.enable_service(service_name) - services.start_service(service_name) - services.assert_service_active(service_name) + for service_name in managed_services: + try: + services.enable_service(service_name) + services.start_service(service_name) + except Exception as error: + verification_errors.append(f"Could not start {service_name}: {error}") + readiness_status: str | None = None + if policy.get("readiness") == "postgresql": self.repository.update_task_component( task_id, component_id, - progress=95, + progress=90, current_step="checking PostgreSQL readiness", ) - installer.wait_for_postgresql() - self.repository.add_log(task_id, "info", "PostgreSQL service is active and accepting connections") - installed_component["serviceName"] = service_name + try: + installer.wait_for_postgresql() + readiness_status = "ready" + except Exception as error: + readiness_status = "failed" + verification_errors.append(f"PostgreSQL is not accepting connections: {error}") + service_checks: list[dict[str, Any]] = [] + self.repository.update_task_component( + task_id, + component_id, + progress=95, + current_step="reading service status", + ) + for service_name in service_names: + check = services.get_service_status(service_name) + if service_name in managed_services and readiness_status is not None: + check["readinessType"] = str(policy["readiness"]) + check["readinessStatus"] = readiness_status + check["healthy"] = bool(check.get("healthy")) and readiness_status == "ready" + service_checks.append(check) + + if check.get("healthy"): + self.repository.add_log( + task_id, + "info", + f"Service {service_name} is active ({check.get('subState', 'unknown')})", + ) + else: + self.repository.add_log( + task_id, + "warning", + f"Service {service_name} is not healthy " + f"(load={check.get('loadState', 'unknown')}, " + f"active={check.get('activeState', 'unknown')}, " + f"sub={check.get('subState', 'unknown')})", + ) + + if not service_names: + service_check_status = "not-applicable" + self.repository.add_log( + task_id, + "info", + f"APT package {package_name} does not expose a concrete systemd service unit", + ) + elif all(bool(check.get("healthy")) for check in service_checks): + service_check_status = "healthy" + else: + service_check_status = "unhealthy" + + self.repository.update_task_component( + task_id, + component_id, + progress=98, + current_step="service checks completed", + service_check_status=service_check_status, + service_checks=service_checks, + ) + installed_component["serviceCheckStatus"] = service_check_status + installed_component["serviceChecks"] = service_checks + if managed_services: + installed_component["serviceName"] = managed_services[0] self.repository.upsert_installed_component(app_id, installed_component) + unhealthy_managed_services = [ + str(check.get("serviceName")) + for check in service_checks + if check.get("serviceName") in managed_services and not check.get("healthy") + ] + if unhealthy_managed_services: + verification_errors.append( + f"Required service is not healthy: {', '.join(unhealthy_managed_services)}" + ) + if verification_errors: + raise InstalledComponentVerificationError( + "; ".join(dict.fromkeys(verification_errors)) + ) + def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None: component_id = component["componentId"] container_name = component["containerName"] @@ -426,6 +533,33 @@ class TaskRunner: except Exception as error: self.repository.add_log(task_id, "warning", f"Could not {action}: {error}") + def _track_attention_install(self, task_id: str, manifest: dict[str, Any] | None) -> None: + if not manifest: + return + try: + manifest_hash = hashlib.sha256( + self.repository.export_manifest_hash(manifest).encode("utf-8") + ).hexdigest() + self.repository.upsert_installed_app( + manifest["appId"], + manifest["appName"], + manifest["version"], + manifest_hash, + manifest.get("openUrl"), + status="attention", + ) + self.repository.add_log( + task_id, + "warning", + "The package was installed but its service needs attention; it remains available for status and removal", + ) + except Exception as tracking_error: + self.repository.add_log( + task_id, + "warning", + f"Could not persist installed package attention state: {tracking_error}", + ) + def _clean_cached_package_files(self, task_id: str, *identifiers: str | None) -> None: cache_dir = settings.cache_dir if not cache_dir.exists() or not cache_dir.is_dir(): diff --git a/agent/app/storage/database.py b/agent/app/storage/database.py index 10a92ee..00ed7ff 100644 --- a/agent/app/storage/database.py +++ b/agent/app/storage/database.py @@ -1,6 +1,8 @@ from __future__ import annotations import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from app.config import settings @@ -55,6 +57,8 @@ CREATE TABLE IF NOT EXISTS installed_components ( package_name TEXT, package_version TEXT, service_name TEXT, + service_check_status TEXT DEFAULT 'not-checked', + service_checks_json TEXT, docker_image TEXT, docker_digest TEXT, container_name TEXT, @@ -75,6 +79,8 @@ CREATE TABLE IF NOT EXISTS task_components ( progress INTEGER DEFAULT 0, current_step TEXT, error_message TEXT, + service_check_status TEXT DEFAULT 'not-checked', + service_checks_json TEXT, started_at TEXT, finished_at TEXT, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE @@ -96,10 +102,18 @@ def _ensure_column(connection: sqlite3.Connection, table: str, column: str, defi connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") -def get_connection() -> sqlite3.Connection: +@contextmanager +def get_connection() -> Iterator[sqlite3.Connection]: connection = sqlite3.connect(settings.db_path, timeout=30) connection.row_factory = sqlite3.Row - return connection + try: + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() def initialize_database() -> None: @@ -110,3 +124,17 @@ def initialize_database() -> None: with get_connection() as connection: connection.executescript(SCHEMA) _ensure_column(connection, "installed_apps", "open_url", "TEXT") + _ensure_column( + connection, + "installed_components", + "service_check_status", + "TEXT DEFAULT 'not-checked'", + ) + _ensure_column(connection, "installed_components", "service_checks_json", "TEXT") + _ensure_column( + connection, + "task_components", + "service_check_status", + "TEXT DEFAULT 'not-checked'", + ) + _ensure_column(connection, "task_components", "service_checks_json", "TEXT") diff --git a/agent/app/storage/repository.py b/agent/app/storage/repository.py index a7d9fd6..43027eb 100644 --- a/agent/app/storage/repository.py +++ b/agent/app/storage/repository.py @@ -17,6 +17,29 @@ def row_to_dict(row: Any) -> dict[str, Any] | None: return dict(row) +def _encode_service_checks(value: list[dict[str, Any]]) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _decode_service_checks(value: Any) -> list[dict[str, Any]]: + if not value: + return [] + try: + decoded = json.loads(str(value)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(decoded, list): + return [] + return [item for item in decoded if isinstance(item, dict)] + + +def _component_row(row: Any) -> dict[str, Any]: + item = dict(row) + item["service_checks"] = _decode_service_checks(item.pop("service_checks_json", None)) + item["service_check_status"] = item.get("service_check_status") or "not-checked" + return item + + class Repository: def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None: now = utc_now() @@ -118,6 +141,8 @@ class Repository: progress: int | None = None, current_step: str | None = None, error_message: str | None = None, + service_check_status: str | None = None, + service_checks: list[dict[str, Any]] | None = None, started_at: str | None = None, finished_at: str | None = None, ) -> None: @@ -128,6 +153,12 @@ class Repository: "progress": progress, "current_step": current_step, "error_message": error_message, + "service_check_status": service_check_status, + "service_checks_json": ( + _encode_service_checks(service_checks) + if service_checks is not None + else None + ), "started_at": started_at, "finished_at": finished_at, }.items(): @@ -147,14 +178,15 @@ class Repository: with get_connection() as connection: rows = connection.execute( """ - SELECT component_id, type, status, progress, current_step, error_message, started_at, finished_at + SELECT component_id, type, status, progress, current_step, error_message, + service_check_status, service_checks_json, started_at, finished_at FROM task_components WHERE task_id = ? ORDER BY install_order ASC, id ASC """, (task_id,), ).fetchall() - return [dict(row) for row in rows] + return [_component_row(row) for row in rows] def list_installed_apps(self) -> list[dict[str, Any]]: with get_connection() as connection: @@ -205,10 +237,11 @@ class Repository: """ INSERT INTO installed_components ( app_id, component_id, type, install_order, status, package_name, package_version, - service_name, docker_image, docker_digest, container_name, compose_project_name, + service_name, service_check_status, service_checks_json, + docker_image, docker_digest, container_name, compose_project_name, installed_at, updated_at ) - VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id, component_id) DO UPDATE SET type = excluded.type, install_order = excluded.install_order, @@ -216,6 +249,8 @@ class Repository: package_name = excluded.package_name, package_version = excluded.package_version, service_name = excluded.service_name, + service_check_status = excluded.service_check_status, + service_checks_json = excluded.service_checks_json, docker_image = excluded.docker_image, docker_digest = excluded.docker_digest, container_name = excluded.container_name, @@ -230,6 +265,8 @@ class Repository: component.get("packageName"), component.get("version"), component.get("serviceName"), + component.get("serviceCheckStatus", "not-checked"), + _encode_service_checks(component.get("serviceChecks", [])), component.get("image"), component.get("digest"), component.get("containerName"), @@ -250,7 +287,7 @@ class Repository: """, (app_id,), ).fetchall() - return [dict(row) for row in rows] + return [_component_row(row) for row in rows] def export_manifest_hash(self, manifest: dict[str, Any]) -> str: return json.dumps(manifest, sort_keys=True, separators=(",", ":")) diff --git a/agent/packaging/DEBIAN/control b/agent/packaging/DEBIAN/control index 5fdad06..9cba583 100644 --- a/agent/packaging/DEBIAN/control +++ b/agent/packaging/DEBIAN/control @@ -1,5 +1,5 @@ Package: local-installer-agent -Version: 1.0.0 +Version: 1.0.3 Section: utils Priority: optional Architecture: amd64 diff --git a/agent/scripts/build-deb.sh b/agent/scripts/build-deb.sh index ad21008..14c0b72 100644 --- a/agent/scripts/build-deb.sh +++ b/agent/scripts/build-deb.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="${VERSION:-1.0.0}" +VERSION="${VERSION:-1.0.3}" ARCH="${ARCH:-amd64}" AGENT_HOST="${AGENT_HOST:-0.0.0.0}" AGENT_PORT="${AGENT_PORT:-5010}" @@ -21,7 +21,8 @@ if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then exit 1 fi -rm -rf "${BUILD_ROOT}" +rm -rf "${BUILD_DIR}" +rm -f "${OUTPUT_PACKAGE}" mkdir -p "${BUILD_DIR}/opt/local-installer-agent" mkdir -p "${BUILD_DIR}/etc/local-installer-agent" diff --git a/agent/tests/test_apt_components.py b/agent/tests/test_apt_components.py index 79d7267..fafa82d 100644 --- a/agent/tests/test_apt_components.py +++ b/agent/tests/test_apt_components.py @@ -5,9 +5,11 @@ import unittest from types import SimpleNamespace from unittest.mock import patch +from app.core.command_runner import CommandError from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller from app.core.manifest_validator import ManifestValidator -from app.core.task_runner import TaskRunner +from app.core.service_manager import ServiceManager +from app.core.task_runner import InstalledComponentVerificationError, TaskRunner def apt_manifest(component: dict | None = None) -> dict: @@ -37,6 +39,7 @@ class FakeCommandRunner: command: list[str], timeout: int | None = None, env: dict[str, str] | None = None, + log_output: bool = True, ) -> subprocess.CompletedProcess[str]: self.calls.append((command, timeout, env)) return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="") @@ -89,6 +92,7 @@ class AptInstallerTests(unittest.TestCase): installer.update_package_index() installer.install_package("postgresql") installer.wait_for_postgresql(attempts=1, delay_seconds=0) + self.assertTrue(installer.is_postgresql_ready()) self.assertEqual(runner.calls[0][0], ["apt-get", "update"]) self.assertEqual( @@ -108,6 +112,78 @@ class AptInstallerTests(unittest.TestCase): ) self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV) self.assertEqual(runner.calls[2][0], ["pg_isready", "--timeout=5"]) + self.assertEqual(runner.calls[3][0], ["pg_isready", "--timeout=5"]) + + def test_discovers_concrete_service_units_without_shell_commands(self) -> None: + class PackageFileRunner(FakeCommandRunner): + def run( + self, + command: list[str], + timeout: int | None = None, + env: dict[str, str] | None = None, + log_output: bool = True, + ) -> subprocess.CompletedProcess[str]: + self.calls.append((command, timeout, env)) + return subprocess.CompletedProcess( + command, + 0, + stdout=( + "/usr/lib/systemd/system/example.service\n" + "/lib/systemd/system/example-worker.service\n" + "/lib/systemd/system/example@.service\n" + "/usr/share/doc/example/README\n" + ), + stderr="", + ) + + runner = PackageFileRunner() + services = AptInstaller(runner).discover_service_units("example") + + self.assertEqual(services, ["example-worker.service", "example.service"]) + self.assertEqual(runner.calls[0][0], ["dpkg-query", "-L", "example"]) + + +class ServiceManagerTests(unittest.TestCase): + def test_reports_full_active_service_state(self) -> None: + class StatusRunner: + def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + command, + 0, + stdout=( + "LoadState=loaded\n" + "ActiveState=active\n" + "SubState=running\n" + "UnitFileState=enabled\n" + ), + stderr="", + ) + + status = ServiceManager(StatusRunner()).get_service_status("example.service") + + self.assertTrue(status["active"]) + self.assertTrue(status["enabled"]) + self.assertTrue(status["healthy"]) + self.assertEqual(status["status"], "active") + self.assertEqual(status["subState"], "running") + + def test_preserves_inactive_state_from_nonzero_systemctl_result(self) -> None: + class InactiveRunner: + def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]: + stdout = ( + "LoadState=loaded\n" + "ActiveState=inactive\n" + "SubState=dead\n" + "UnitFileState=disabled\n" + ) + raise CommandError(command, 3, stdout, "") + + status = ServiceManager(InactiveRunner()).get_service_status("example.service") + + self.assertFalse(status["active"]) + self.assertFalse(status["healthy"]) + self.assertEqual(status["activeState"], "inactive") + self.assertEqual(status["unitFileState"], "disabled") class FakeRepository: @@ -126,6 +202,42 @@ class FakeRepository: self.installed_component = component +class FakeRunRepository: + def __init__(self) -> None: + self.task = {"current_component_id": "postgresql"} + self.task_updates: list[dict] = [] + self.logs: list[tuple[str, str]] = [] + self.installed_app_updates: list[dict] = [] + + def update_task(self, task_id: str, **fields: object) -> None: + self.task_updates.append(dict(fields)) + + def update_task_component(self, task_id: str, component_id: str, **fields: object) -> None: + return None + + def add_log(self, task_id: str, level: str, message: str) -> None: + self.logs.append((level, message)) + + def get_task(self, task_id: str) -> dict: + return self.task + + def export_manifest_hash(self, manifest: dict) -> str: + return "manifest" + + def upsert_installed_app( + self, + app_id: str, + app_name: str, + version: str, + manifest_hash: str, + open_url: str | None, + status: str = "installed", + ) -> None: + self.installed_app_updates.append( + {"app_id": app_id, "version": version, "status": status} + ) + + class FakeAptInstaller: actions: list[str] = [] @@ -145,6 +257,10 @@ class FakeAptInstaller: def wait_for_postgresql(self) -> None: self.actions.append("pg_isready") + def discover_service_units(self, package_name: str) -> list[str]: + self.actions.append(f"discover:{package_name}") + return [] + class FakeServiceManager: actions: list[str] = [] @@ -161,8 +277,61 @@ class FakeServiceManager: def assert_service_active(self, service_name: str) -> None: self.actions.append(f"active:{service_name}") + def get_service_status(self, service_name: str) -> dict[str, object]: + self.actions.append(f"status:{service_name}") + return { + "serviceName": service_name, + "loadState": "loaded", + "activeState": "active", + "subState": "running", + "unitFileState": "enabled", + "active": True, + "enabled": True, + "healthy": True, + "status": "active", + "checkedAt": "2026-07-20T00:00:00Z", + "errorMessage": None, + } + class AptTaskRunnerTests(unittest.TestCase): + def test_only_post_install_verification_failure_marks_app_attention(self) -> None: + manifest = apt_manifest() + repository = FakeRunRepository() + runner = TaskRunner(repository) + + with ( + patch.object(runner, "_require_root_if_available"), + patch.object(runner, "_resolve_manifest", return_value=manifest), + patch.object( + runner, + "_install_manifest", + side_effect=InstalledComponentVerificationError("service unhealthy"), + ), + ): + runner.run_install("task-attention", SimpleNamespace(), "install") + + self.assertEqual( + repository.installed_app_updates, + [{"app_id": "postgresql", "version": "16", "status": "attention"}], + ) + self.assertEqual(repository.task_updates[-1]["status"], "failed") + + def test_early_install_failure_does_not_overwrite_installed_app_version(self) -> None: + manifest = apt_manifest() + repository = FakeRunRepository() + runner = TaskRunner(repository) + + with ( + patch.object(runner, "_require_root_if_available"), + patch.object(runner, "_resolve_manifest", return_value=manifest), + patch.object(runner, "_install_manifest", side_effect=RuntimeError("apt update failed")), + ): + runner.run_install("task-early-failure", SimpleNamespace(), "update") + + self.assertEqual(repository.installed_app_updates, []) + self.assertEqual(repository.task_updates[-1]["status"], "failed") + def test_postgresql_install_verifies_service_and_readiness(self) -> None: repository = FakeRepository() FakeAptInstaller.actions = [] @@ -186,19 +355,101 @@ class AptTaskRunnerTests(unittest.TestCase): self.assertEqual( FakeAptInstaller.actions, - ["update", "install:postgresql", "version:postgresql", "pg_isready"], + [ + "update", + "install:postgresql", + "version:postgresql", + "discover:postgresql", + "pg_isready", + ], ) self.assertEqual( FakeServiceManager.actions, [ "enable:postgresql.service", "start:postgresql.service", - "active:postgresql.service", + "status:postgresql.service", ], ) self.assertEqual(repository.installed_component["version"], "16+257build1") self.assertEqual(repository.installed_component["serviceName"], "postgresql.service") - self.assertEqual(repository.component_updates[-1]["current_step"], "checking PostgreSQL readiness") + self.assertEqual(repository.installed_component["serviceCheckStatus"], "healthy") + self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessType"], "postgresql") + self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessStatus"], "ready") + self.assertEqual(repository.component_updates[-1]["service_check_status"], "healthy") + self.assertEqual(repository.component_updates[-1]["current_step"], "service checks completed") + + def test_generic_apt_service_is_checked_but_not_started_automatically(self) -> None: + class GenericAptInstaller(FakeAptInstaller): + def discover_service_units(self, package_name: str) -> list[str]: + self.actions.append(f"discover:{package_name}") + return ["example.service"] + + class InactiveServiceManager(FakeServiceManager): + def get_service_status(self, service_name: str) -> dict[str, object]: + self.actions.append(f"status:{service_name}") + return { + "serviceName": service_name, + "loadState": "loaded", + "activeState": "inactive", + "subState": "dead", + "unitFileState": "disabled", + "active": False, + "enabled": False, + "healthy": False, + "status": "inactive", + "checkedAt": "2026-07-20T00:00:00Z", + "errorMessage": None, + } + + repository = FakeRepository() + FakeAptInstaller.actions = [] + FakeServiceManager.actions = [] + + with ( + patch("app.core.task_runner.CommandRunner", return_value=object()), + patch("app.core.task_runner.AptInstaller", GenericAptInstaller), + patch("app.core.task_runner.ServiceManager", InactiveServiceManager), + ): + TaskRunner(repository)._install_apt_component( + "task-2", + "example-app", + { + "componentId": "example", + "type": "apt", + "packageName": "example", + "version": "1", + }, + ) + + self.assertNotIn("start:example.service", FakeServiceManager.actions) + self.assertNotIn("enable:example.service", FakeServiceManager.actions) + self.assertIn("status:example.service", FakeServiceManager.actions) + self.assertEqual(repository.installed_component["serviceCheckStatus"], "unhealthy") + + def test_apt_package_without_service_is_marked_not_applicable(self) -> None: + repository = FakeRepository() + FakeAptInstaller.actions = [] + FakeServiceManager.actions = [] + + with ( + patch("app.core.task_runner.CommandRunner", return_value=object()), + patch("app.core.task_runner.AptInstaller", FakeAptInstaller), + patch("app.core.task_runner.ServiceManager", FakeServiceManager), + ): + TaskRunner(repository)._install_apt_component( + "task-3", + "utility-app", + { + "componentId": "utility", + "type": "apt", + "packageName": "utility", + "version": "1", + }, + ) + + self.assertEqual(repository.installed_component["serviceCheckStatus"], "not-applicable") + self.assertEqual(repository.installed_component["serviceChecks"], []) if __name__ == "__main__": diff --git a/agent/tests/test_service_check_persistence.py b/agent/tests/test_service_check_persistence.py new file mode 100644 index 0000000..c3f4ff4 --- /dev/null +++ b/agent/tests/test_service_check_persistence.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from contextlib import closing +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from app.api import apps as apps_api +from app.api import tasks as tasks_api +from app.storage.database import initialize_database +from app.storage.repository import Repository + + +SERVICE_CHECK = { + "serviceName": "postgresql.service", + "loadState": "loaded", + "activeState": "active", + "subState": "running", + "unitFileState": "enabled", + "active": True, + "enabled": True, + "healthy": True, + "status": "active", + "readinessStatus": "ready", + "checkedAt": "2026-07-20T00:00:00Z", + "errorMessage": None, +} + + +class ServiceCheckPersistenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + root = Path(self.temp_dir.name) + fake_settings = SimpleNamespace( + db_path=root / "agent.db", + cache_dir=root / "cache", + log_dir=root / "logs", + ) + self.settings_patch = patch("app.storage.database.settings", fake_settings) + self.settings_patch.start() + initialize_database() + self.db_path = fake_settings.db_path + self.repository = Repository() + + def tearDown(self) -> None: + self.settings_patch.stop() + self.temp_dir.cleanup() + + def test_task_component_api_round_trips_structured_service_checks(self) -> None: + self.repository.create_task("task-1", "install", "postgresql", "PostgreSQL") + self.repository.create_task_component("task-1", "postgresql", "postgresql", "apt", 10) + self.repository.update_task_component( + "task-1", + "postgresql", + service_check_status="healthy", + service_checks=[SERVICE_CHECK], + ) + + with patch.object(tasks_api, "repository", self.repository): + response = tasks_api.get_task_components("task-1") + + component = response["components"][0] + self.assertEqual(component["serviceCheckStatus"], "healthy") + self.assertEqual(component["serviceChecks"], [SERVICE_CHECK]) + + def test_initialize_database_migrates_existing_component_tables(self) -> None: + with closing(sqlite3.connect(self.db_path)) as connection: + connection.execute("DROP TABLE task_components") + connection.execute("DROP TABLE installed_components") + connection.executescript( + """ + CREATE TABLE installed_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id TEXT NOT NULL, + component_id TEXT NOT NULL, + type TEXT NOT NULL, + install_order INTEGER NOT NULL, + status TEXT NOT NULL, + package_name TEXT, + package_version TEXT, + service_name TEXT, + docker_image TEXT, + docker_digest TEXT, + container_name TEXT, + compose_project_name TEXT, + installed_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(app_id, component_id) + ); + CREATE TABLE task_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + app_id TEXT NOT NULL, + component_id TEXT NOT NULL, + type TEXT NOT NULL, + install_order INTEGER NOT NULL, + status TEXT NOT NULL, + progress INTEGER DEFAULT 0, + current_step TEXT, + error_message TEXT, + started_at TEXT, + finished_at TEXT + ); + """ + ) + connection.commit() + + initialize_database() + + with closing(sqlite3.connect(self.db_path)) as connection: + installed_columns = { + row[1] for row in connection.execute("PRAGMA table_info(installed_components)") + } + task_columns = { + row[1] for row in connection.execute("PRAGMA table_info(task_components)") + } + self.assertTrue({"service_check_status", "service_checks_json"} <= installed_columns) + self.assertTrue({"service_check_status", "service_checks_json"} <= task_columns) + + def test_installed_apps_include_persisted_service_health(self) -> None: + self.repository.upsert_installed_app( + "postgresql", + "PostgreSQL", + "16", + "manifest-hash", + ) + self.repository.upsert_installed_component( + "postgresql", + { + "componentId": "postgresql", + "type": "apt", + "packageName": "postgresql", + "version": "16+257build1", + "serviceName": "postgresql.service", + "serviceCheckStatus": "healthy", + "serviceChecks": [SERVICE_CHECK], + }, + ) + + manager = SimpleNamespace(get_service_status=lambda _name: dict(SERVICE_CHECK)) + with ( + patch.object(apps_api, "repository", self.repository), + patch.object(apps_api, "ServiceManager", return_value=manager), + ): + response = apps_api.installed_apps() + + self.assertEqual(response[0]["serviceCheckStatus"], "healthy") + self.assertEqual(response[0]["serviceChecks"][0]["serviceName"], "postgresql.service") + self.assertEqual(response[0]["serviceChecks"][0]["componentId"], "postgresql") + + def test_installed_apps_refresh_postgresql_readiness(self) -> None: + stored_check = { + **SERVICE_CHECK, + "readinessType": "postgresql", + "readinessStatus": "ready", + } + self.repository.upsert_installed_app( + "postgresql", + "PostgreSQL", + "16", + "manifest-hash", + ) + self.repository.upsert_installed_component( + "postgresql", + { + "componentId": "postgresql", + "type": "apt", + "packageName": "postgresql", + "version": "16+257build1", + "serviceName": "postgresql.service", + "serviceCheckStatus": "healthy", + "serviceChecks": [stored_check], + }, + ) + service_manager = SimpleNamespace(get_service_status=lambda _name: dict(SERVICE_CHECK)) + apt_installer = SimpleNamespace(is_postgresql_ready=lambda: False) + + with ( + patch.object(apps_api, "repository", self.repository), + patch.object(apps_api, "ServiceManager", return_value=service_manager), + patch.object(apps_api, "AptInstaller", return_value=apt_installer), + ): + response = apps_api.installed_apps() + + self.assertEqual(response[0]["serviceCheckStatus"], "unhealthy") + self.assertEqual(response[0]["serviceChecks"][0]["readinessStatus"], "failed") + self.assertFalse(response[0]["serviceChecks"][0]["healthy"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web-client/src/main.jsx b/web-client/src/main.jsx index 800b26c..478e6b0 100644 --- a/web-client/src/main.jsx +++ b/web-client/src/main.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { Activity, @@ -121,6 +121,36 @@ function statusBadgeClass(status) { return `badge badge-${tone}`; } +function serviceCheckTone(check) { + if (check?.healthy) return 'success'; + if (check?.activeState === 'activating' || check?.status === 'checking') return 'warning'; + if (['failed', 'inactive', 'not-found'].includes(check?.status || check?.activeState)) return 'danger'; + return 'muted'; +} + +function serviceCheckLabel(check) { + if (check?.readinessStatus === 'failed') return 'Not ready'; + if (check?.healthy) return check.readinessStatus === 'ready' ? 'Active · Ready' : 'Active'; + if (check?.loadState === 'not-found' || check?.status === 'not-found') return 'Not found'; + if (check?.activeState === 'failed') return 'Failed'; + if (check?.activeState === 'inactive') return 'Inactive'; + if (check?.activeState === 'activating') return 'Activating'; + return 'Unknown'; +} + +function serviceCheckSummary(status, checks = []) { + if (status === 'checking') return 'Checking services'; + if (status === 'not-applicable') return 'No systemd service'; + if (status === 'unhealthy') { + const count = checks.filter((check) => !check.healthy).length || checks.length; + return `${count} service${count === 1 ? '' : 's'} need attention`; + } + if (status === 'healthy') { + return `${checks.length} service${checks.length === 1 ? '' : 's'} active`; + } + return ''; +} + function formatTaskTime(value) { if (!value) return '--'; const parsed = new Date(value); @@ -218,6 +248,7 @@ function App() { const [draftSettings, setDraftSettings] = useState(settings); const [apps, setApps] = useState([]); const [installedApps, setInstalledApps] = useState([]); + const [installedAppsReady, setInstalledAppsReady] = useState(false); const [latestAgentPackage, setLatestAgentPackage] = useState(null); const [agentHealth, setAgentHealth] = useState(null); const [systemInfo, setSystemInfo] = useState(null); @@ -234,9 +265,13 @@ function App() { const [activeTask, setActiveTask] = useState(null); const [endpointDialogOpen, setEndpointDialogOpen] = useState(false); const [agentDialogOpen, setAgentDialogOpen] = useState(false); + const agentEndpointRef = useRef(settings.agentBaseUrl); + const agentEndpointGenerationRef = useRef(0); + const agentRefreshGenerationRef = useRef(0); const packageBaseUrl = settings.packageBaseUrl; const agentBaseUrl = settings.agentBaseUrl; + agentEndpointRef.current = agentBaseUrl; const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`; const agentCommand = latestAgentPackage?.installCommand || installCommand; const clientOs = useMemo(() => detectClientOs(), []); @@ -260,12 +295,14 @@ function App() { const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode); const isInstalled = Boolean(installed); const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version); + const needsAttention = Boolean(isInstalled && installed.status === 'attention'); return { ...app, installed, localStatus: canUpdate ? 'update' : (isInstalled ? 'installed' : 'available'), - canUpdate + canUpdate, + needsAttention }; }); }, [apps, installedByAppId]); @@ -326,10 +363,21 @@ function App() { }, [packageBaseUrl]); const refreshAgent = useCallback(async () => { + const requestEndpoint = agentBaseUrl; + if (agentEndpointRef.current !== requestEndpoint) return false; + + const requestGeneration = ++agentRefreshGenerationRef.current; + const isCurrentRequest = () => ( + agentEndpointRef.current === requestEndpoint + && agentRefreshGenerationRef.current === requestGeneration + ); + if (!canUseAgentEndpoint) { + if (!isCurrentRequest()) return false; setAgentHealth(null); setSystemInfo(null); setInstalledApps([]); + setInstalledAppsReady(false); setAgentStatus({ state: 'warning', message: isClientWindows @@ -340,22 +388,37 @@ function App() { } setAgentStatus({ state: 'loading', message: `Checking ${agentTargetLabel}` }); + setInstalledAppsReady(false); try { - const health = await fetchAgentHealth(agentBaseUrl); + const health = await fetchAgentHealth(requestEndpoint); + if (!isCurrentRequest()) return false; setAgentHealth(health); setAgentStatus({ state: 'success', message: `${health.hostname || agentTargetLabel} online` }); - const [info, installed] = await Promise.all([ - fetchAgentSystemInfo(agentBaseUrl).catch(() => null), - fetchInstalledApps(agentBaseUrl) + const [infoResult, installedResult] = await Promise.allSettled([ + fetchAgentSystemInfo(requestEndpoint), + fetchInstalledApps(requestEndpoint) ]); - setSystemInfo(info); - setInstalledApps(installed); + if (!isCurrentRequest()) return false; + setSystemInfo(infoResult.status === 'fulfilled' ? infoResult.value : null); + if (installedResult.status === 'fulfilled') { + setInstalledApps(installedResult.value); + setInstalledAppsReady(true); + } else { + setInstalledApps([]); + setInstalledAppsReady(false); + setAgentStatus({ + state: 'warning', + message: `${health.hostname || agentTargetLabel} online · installed app status unavailable: ${getErrorMessage(installedResult.reason)}` + }); + } return true; } catch (error) { + if (!isCurrentRequest()) return false; setAgentHealth(null); setSystemInfo(null); setInstalledApps([]); + setInstalledAppsReady(false); setAgentStatus({ state: 'danger', message: getErrorMessage(error) }); return false; } @@ -392,13 +455,13 @@ function App() { try { const [nextTask, logs, components] = await Promise.all([ fetchTaskStatus(agentBaseUrl, taskId), - fetchTaskLogs(agentBaseUrl, taskId).catch(() => []), - fetchTaskComponents(agentBaseUrl, taskId).catch(() => []) + fetchTaskLogs(agentBaseUrl, taskId).catch(() => null), + fetchTaskComponents(agentBaseUrl, taskId).catch(() => null) ]); const snapshot = { ...nextTask, - logs, - components, + ...(logs ? { logs } : {}), + ...(components ? { components } : {}), pollError: '' }; @@ -483,31 +546,54 @@ function App() { return; } + if (!installedAppsReady) { + notify('warning', 'Installed app status is not ready. Refresh the Agent before running an action.'); + return; + } + if (action === 'remove' && !window.confirm(`Remove and clean ${app.appName} from ${agentTargetMachine}?`)) { return; } + const actionEndpoint = agentBaseUrl; + const endpointGeneration = agentEndpointGenerationRef.current; + const isCurrentEndpoint = () => ( + agentEndpointRef.current === actionEndpoint + && agentEndpointGenerationRef.current === endpointGeneration + ); + if (!isCurrentEndpoint()) { + notify('warning', 'Agent endpoint changed. Retry the action on the current Agent.'); + return; + } + const key = `${action}:${app.appId}`; setBusyAction(key); try { let queuedTask; if (action === 'install') { const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version); + if (!isCurrentEndpoint()) return; setSelectedManifest(manifest); setDetailStatus({ state: 'success', message: 'Manifest ready' }); - queuedTask = await queueInstall(agentBaseUrl, app); + queuedTask = await queueInstall(actionEndpoint, app); } else if (action === 'update') { const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version); + if (!isCurrentEndpoint()) return; setSelectedManifest(manifest); setDetailStatus({ state: 'success', message: 'Manifest ready' }); - queuedTask = await queueUpdate(agentBaseUrl, app, app.installed); + queuedTask = await queueUpdate(actionEndpoint, app, app.installed); } else { - queuedTask = await queueRemove(agentBaseUrl, app); + queuedTask = await queueRemove(actionEndpoint, app); } + if (!isCurrentEndpoint()) { + notify('warning', 'Agent endpoint changed while the request was running. Switch back to monitor the previous Agent.'); + return; + } startTask(queuedTask, action, app); notify('success', `Đã queue task ${queuedTask.taskId}`); } catch (error) { + if (!isCurrentEndpoint()) return; const message = getErrorMessage(error); if (action === 'install' || action === 'update') { setSelectedManifest(null); @@ -516,9 +602,9 @@ function App() { } notify('failure', message); } finally { - setBusyAction(''); + setBusyAction((current) => current === key ? '' : current); } - }, [agentBaseUrl, agentHealth, agentTargetLabel, agentTargetMachine, canManageApps, notify, packageBaseUrl, startPreflightFailedTask, startTask]); + }, [agentBaseUrl, agentHealth, agentTargetLabel, agentTargetMachine, canManageApps, installedAppsReady, notify, packageBaseUrl, startPreflightFailedTask, startTask]); const openEndpointDialog = useCallback(() => { setDraftSettings(settings); @@ -535,12 +621,30 @@ function App() { packageBaseUrl: normalizeUrl(draftSettings.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL), agentBaseUrl: normalizeUrl(draftSettings.agentBaseUrl || DEFAULT_AGENT_BASE_URL) }; + const endpointsChanged = ( + nextSettings.packageBaseUrl !== settings.packageBaseUrl + || nextSettings.agentBaseUrl !== settings.agentBaseUrl + ); + if (busyAction && endpointsChanged) { + notify('warning', 'Wait for the current Agent request to finish before changing endpoints.'); + return; + } + if (nextSettings.agentBaseUrl !== settings.agentBaseUrl) { + agentEndpointRef.current = nextSettings.agentBaseUrl; + agentEndpointGenerationRef.current += 1; + agentRefreshGenerationRef.current += 1; + setAgentHealth(null); + setSystemInfo(null); + setInstalledApps([]); + setInstalledAppsReady(false); + setActiveTask(null); + } setSettings(nextSettings); setDraftSettings(nextSettings); saveSettings(nextSettings); setEndpointDialogOpen(false); notify('info', 'Đã cập nhật endpoint test'); - }, [draftSettings, notify]); + }, [busyAction, draftSettings, notify, settings.agentBaseUrl, settings.packageBaseUrl]); const copyInstallCommand = useCallback(async () => { if (!canShowAgentCommand) { @@ -842,7 +946,7 @@ function App() { )} - {canManageApps && app.installed && app.canUpdate && ( + {canManageApps && app.installed && app.needsAttention && ( + )} + {canManageApps && app.installed && app.canUpdate && !app.needsAttention && ( +