update check active

This commit is contained in:
2026-07-20 16:08:36 +07:00
parent 4a159cad71
commit db1ce68800
17 changed files with 1230 additions and 73 deletions

View File

@@ -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"
}
]
}
```

View File

@@ -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")

View File

@@ -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)
],
}

View File

@@ -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,

View File

@@ -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():

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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")

View File

@@ -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=(",", ":"))

View File

@@ -1,5 +1,5 @@
Package: local-installer-agent
Version: 1.0.0
Version: 1.0.3
Section: utils
Priority: optional
Architecture: amd64

View File

@@ -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"

View File

@@ -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__":

View File

@@ -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()

View File

@@ -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() {
<button
className="btn btn-primary compact"
type="button"
disabled={!agentHealth || rowTaskBusy || installBusy}
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || installBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('install', app);
@@ -852,11 +956,25 @@ function App() {
Install
</button>
)}
{canManageApps && app.installed && app.canUpdate && (
{canManageApps && app.installed && app.needsAttention && (
<button
className="btn btn-warning compact"
type="button"
disabled={!agentHealth || rowTaskBusy || updateBusy}
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || updateBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('update', app);
}}
>
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
Retry
</button>
)}
{canManageApps && app.installed && app.canUpdate && !app.needsAttention && (
<button
className="btn btn-warning compact"
type="button"
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || updateBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('update', app);
@@ -884,7 +1002,7 @@ function App() {
className="icon-button danger"
type="button"
title="Remove"
disabled={!agentHealth || rowTaskBusy || removeBusy}
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || removeBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('remove', app);
@@ -1102,20 +1220,28 @@ function LocalStatus({ app, task }) {
);
}
if (app.localStatus === 'update') {
return (
<span className="status-inline">
<span className="badge badge-warning">Update</span>
<small>{app.installed.version}</small>
</span>
if (app.installed) {
const serviceSummary = serviceCheckSummary(
app.installed.serviceCheckStatus,
app.installed.serviceChecks
);
}
if (app.localStatus === 'installed') {
const statusLabel = app.needsAttention
? 'Attention'
: app.localStatus === 'update'
? 'Update'
: 'Installed';
const statusTone = app.needsAttention || app.localStatus === 'update' ? 'warning' : 'success';
return (
<span className="status-inline">
<span className="badge badge-success">Installed</span>
<small>{app.installed.version}</small>
<span className="status-stack">
<span className="status-inline">
<span className={`badge badge-${statusTone}`}>{statusLabel}</span>
<small>{app.installed.version}</small>
</span>
{serviceSummary && (
<small className={app.installed.serviceCheckStatus === 'unhealthy' ? 'danger-text' : 'success-text'}>
{serviceSummary}
</small>
)}
</span>
);
}
@@ -1189,6 +1315,12 @@ function TaskPanel({ task, onClear, onRefresh }) {
<span>{component.currentStep || component.type || '-'}</span>
</div>
<span className={statusBadgeClass(component.status)}>{component.progress}%</span>
{component.type === 'apt' && (
<ServiceChecks
checks={component.serviceChecks}
status={component.serviceCheckStatus}
/>
)}
</div>
))}
{!components.length && (
@@ -1212,6 +1344,46 @@ function TaskPanel({ task, onClear, onRefresh }) {
);
}
function ServiceChecks({ checks = [], status = 'not-checked', showSource = false }) {
if (!checks.length) {
const message = serviceCheckSummary(status, checks);
if (!message && status === 'not-checked') return null;
return (
<div className="service-check-list">
<div className="service-check-empty">
<Activity size={14} aria-hidden="true" />
<span>{message || 'Service status unavailable'}</span>
</div>
</div>
);
}
return (
<div className="service-check-list" aria-label="Automatic service checks">
{checks.map((check) => (
<div className="service-check-row" key={`${check.componentId || ''}:${check.serviceName}`}>
<div className="service-check-name">
{check.healthy
? <CheckCircle2 className="service-check-icon healthy" size={14} aria-hidden="true" />
: <AlertCircle className="service-check-icon unhealthy" size={14} aria-hidden="true" />}
<span>
<strong>{check.serviceName}</strong>
<small>
{showSource && (check.packageName || check.componentId)
? `${check.packageName || check.componentId} · `
: ''}
{check.subState || check.activeState || 'unknown'} · {check.unitFileState || 'unknown'}
{check.checkedAt ? ` · ${check.stale ? 'last checked' : 'checked'} ${formatTaskTime(check.checkedAt)}` : ''}
</small>
</span>
</div>
<span className={`badge badge-${serviceCheckTone(check)}`}>{serviceCheckLabel(check)}</span>
</div>
))}
</div>
);
}
function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPackage, needsUpdate, onClose, onCopyUpdate, showAgentActions }) {
const statusTone = needsUpdate
? 'warning'
@@ -1306,6 +1478,8 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
const packages = detail?.packages || [];
const components = manifest?.components || [];
const installedServiceChecks = app?.installed?.serviceChecks || [];
const installedServiceStatus = app?.installed?.serviceCheckStatus || 'not-checked';
return (
<section className="panel app-detail-panel">
@@ -1355,6 +1529,23 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
<div className="table-empty compact-empty">{status.message || 'Chưa có component.'}</div>
)}
</div>
{app.installed && installedServiceStatus !== 'not-checked' && (
<div className="service-health-panel">
<div className="component-list-title">
<Activity size={15} aria-hidden="true" />
Service health
<span className={`badge badge-${installedServiceStatus === 'healthy' ? 'success' : installedServiceStatus === 'unhealthy' ? 'danger' : 'muted'}`}>
{serviceCheckSummary(installedServiceStatus, installedServiceChecks)}
</span>
</div>
<ServiceChecks
checks={installedServiceChecks}
status={installedServiceStatus}
showSource
/>
</div>
)}
</>
) : (
<div className="table-empty compact-empty">Chọn app để xem manifest.</div>

View File

@@ -181,7 +181,7 @@ export async function fetchAgentSystemInfo(agentBaseUrl) {
}
export async function fetchInstalledApps(agentBaseUrl) {
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 7000 });
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 20000 });
return Array.isArray(payload) ? payload.map(normalizeInstalledApp) : [];
}
@@ -256,6 +256,7 @@ function normalizePackageApp(app) {
}
function normalizeInstalledApp(app) {
const serviceChecks = normalizeServiceChecks(app.serviceChecks || app.service_checks);
return {
appId: String(app.appId || app.app_id || '').trim(),
appName: String(app.appName || app.app_name || '').trim(),
@@ -263,6 +264,12 @@ function normalizeInstalledApp(app) {
status: String(app.status || 'installed').trim(),
installedAt: app.installedAt || app.installed_at || '',
updatedAt: app.updatedAt || app.updated_at || '',
serviceCheckStatus: String(
app.serviceCheckStatus
|| app.service_check_status
|| deriveServiceCheckStatus(serviceChecks)
).trim(),
serviceChecks,
openUrl: normalizeOpenUrl(
app.openUrl
|| app.open_url
@@ -312,6 +319,7 @@ function normalizeLog(log) {
}
function normalizeComponent(component) {
const serviceChecks = normalizeServiceChecks(component.serviceChecks || component.service_checks);
return {
componentId: component.componentId || component.component_id,
type: component.type,
@@ -319,7 +327,50 @@ function normalizeComponent(component) {
progress: Number(component.progress || 0),
currentStep: component.currentStep || component.current_step,
errorMessage: component.errorMessage || component.error_message,
serviceCheckStatus: String(
component.serviceCheckStatus
|| component.service_check_status
|| deriveServiceCheckStatus(serviceChecks)
).trim(),
serviceChecks,
startedAt: component.startedAt || component.started_at,
finishedAt: component.finishedAt || component.finished_at
};
}
function normalizeServiceChecks(value) {
return Array.isArray(value) ? value.map(normalizeServiceCheck).filter((item) => item.serviceName) : [];
}
function normalizeServiceCheck(check) {
const activeState = String(check?.activeState || check?.active_state || check?.status || 'unknown').trim();
const active = typeof check?.active === 'boolean' ? check.active : activeState === 'active';
const readinessStatus = String(check?.readinessStatus || check?.readiness_status || '').trim();
const healthy = typeof check?.healthy === 'boolean'
? check.healthy
: active && (!readinessStatus || readinessStatus === 'ready');
return {
serviceName: String(check?.serviceName || check?.service_name || '').trim(),
componentId: String(check?.componentId || check?.component_id || '').trim(),
packageName: String(check?.packageName || check?.package_name || '').trim(),
loadState: String(check?.loadState || check?.load_state || 'unknown').trim(),
activeState,
subState: String(check?.subState || check?.sub_state || 'unknown').trim(),
unitFileState: String(check?.unitFileState || check?.unit_file_state || 'unknown').trim(),
readinessType: String(check?.readinessType || check?.readiness_type || '').trim(),
readinessStatus,
status: String(check?.status || activeState || 'unknown').trim(),
active,
enabled: Boolean(check?.enabled),
healthy,
stale: Boolean(check?.stale),
checkedAt: check?.checkedAt || check?.checked_at || '',
errorMessage: check?.errorMessage || check?.error_message || ''
};
}
function deriveServiceCheckStatus(serviceChecks) {
if (!serviceChecks.length) return 'not-checked';
return serviceChecks.every((check) => check.healthy) ? 'healthy' : 'unhealthy';
}

View File

@@ -782,6 +782,10 @@ tbody tr.selected-row {
color: var(--danger);
}
.success-text {
color: var(--success);
}
.action-col {
position: sticky;
right: 0;
@@ -847,6 +851,18 @@ tbody tr.selected-row td.action-col {
font-weight: 700;
}
.status-stack {
align-items: flex-start;
display: inline-flex;
flex-direction: column;
gap: 5px;
}
.status-stack > small {
font-size: 10px;
font-weight: 800;
}
.page-pager {
align-items: center;
background: #f8fafc;
@@ -1053,6 +1069,97 @@ tbody tr.selected-row td.action-col {
justify-content: center;
}
.service-check-list {
border-top: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 7px;
grid-column: 1 / -1;
padding-top: 8px;
}
.service-check-row {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
min-width: 0;
}
.service-check-name {
align-items: center;
display: flex;
gap: 7px;
min-width: 0;
}
.service-check-icon.healthy {
color: var(--success);
flex: 0 0 auto;
}
.service-check-icon.unhealthy {
color: var(--danger);
flex: 0 0 auto;
}
.component-item .service-check-name > span,
.service-check-name > span {
color: inherit;
font-size: inherit;
margin-top: 0;
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.component-item .service-check-name strong,
.service-check-name strong {
font-size: 11px;
}
.component-item .service-check-name small,
.service-check-name small {
color: #64748b;
display: block;
font-size: 10px;
margin-top: 2px;
overflow-wrap: anywhere;
white-space: normal;
}
.service-check-row > .badge {
display: inline-flex;
flex: 0 0 auto;
}
.service-check-empty {
align-items: center;
color: #64748b;
display: flex;
font-size: 11px;
font-weight: 700;
gap: 6px;
}
.service-health-panel {
border-top: 1px solid #eef2f7;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 14px;
}
.service-health-panel > .component-list-title {
justify-content: space-between;
}
.service-health-panel .service-check-list {
border-top: 0;
padding-top: 0;
}
.task-log-list {
border-top: 1px solid #eef2f7;
display: flex;