update check active
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
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.core.task_runner import TaskRunner
|
||||||
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||||
from app.storage.repository import Repository
|
from app.storage.repository import Repository
|
||||||
@@ -20,7 +23,56 @@ def _task_id(prefix: str) -> str:
|
|||||||
|
|
||||||
@router.get("/installed")
|
@router.get("/installed")
|
||||||
def installed_apps() -> list[dict]:
|
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")
|
@router.post("/install")
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ def get_task_components(task_id: str) -> dict:
|
|||||||
"progress": item["progress"],
|
"progress": item["progress"],
|
||||||
"currentStep": item["current_step"],
|
"currentStep": item["current_step"],
|
||||||
"errorMessage": item["error_message"],
|
"errorMessage": item["error_message"],
|
||||||
|
"serviceCheckStatus": item["service_check_status"],
|
||||||
|
"serviceChecks": item["service_checks"],
|
||||||
"startedAt": item["started_at"],
|
"startedAt": item["started_at"],
|
||||||
"finishedAt": item["finished_at"],
|
"finishedAt": item["finished_at"],
|
||||||
}
|
}
|
||||||
for item in repository.get_task_components(task_id)
|
for item in repository.get_task_components(task_id)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def _bool(name: str, default: bool) -> bool:
|
|||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://package.pnkr.cloud").rstrip("/")
|
robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://package.pnkr.cloud").rstrip("/")
|
||||||
return Settings(
|
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"),
|
host=os.getenv("AGENT_HOST", "0.0.0.0"),
|
||||||
port=int(os.getenv("AGENT_PORT", "5010")),
|
port=int(os.getenv("AGENT_PORT", "5010")),
|
||||||
robot_package_base_url=robot_package_base_url,
|
robot_package_base_url=robot_package_base_url,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class CommandRunner:
|
|||||||
command: list[str],
|
command: list[str],
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
|
log_output: bool = True,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
if self.task_id:
|
if self.task_id:
|
||||||
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
|
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)}")
|
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
|
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():
|
for line in result.stdout.splitlines():
|
||||||
self.repository.add_log(self.task_id, "debug", line)
|
self.repository.add_log(self.task_id, "debug", line)
|
||||||
for line in result.stderr.splitlines():
|
for line in result.stderr.splitlines():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
from app.core.command_runner import CommandError, CommandRunner
|
from app.core.command_runner import CommandError, CommandRunner
|
||||||
|
|
||||||
@@ -125,6 +125,38 @@ class AptInstaller(DebInstaller):
|
|||||||
env=APT_NONINTERACTIVE_ENV,
|
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(
|
def wait_for_postgresql(
|
||||||
self,
|
self,
|
||||||
attempts: int = 6,
|
attempts: int = 6,
|
||||||
@@ -141,3 +173,10 @@ class AptInstaller(DebInstaller):
|
|||||||
time.sleep(delay_seconds)
|
time.sleep(delay_seconds)
|
||||||
|
|
||||||
raise RuntimeError("PostgreSQL did not become ready after installation") from last_error
|
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
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.command_runner import CommandError
|
||||||
from app.core.command_runner import CommandRunner
|
from app.core.command_runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -31,17 +34,60 @@ class ServiceManager:
|
|||||||
raise RuntimeError(f"Service is not active: {service_name}")
|
raise RuntimeError(f"Service is not active: {service_name}")
|
||||||
|
|
||||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||||
active = self._query(["systemctl", "is-active", service_name]) == "active"
|
command = [
|
||||||
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
|
"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 {
|
return {
|
||||||
"serviceName": service_name,
|
"serviceName": service_name,
|
||||||
|
"loadState": load_state,
|
||||||
|
"activeState": active_state,
|
||||||
|
"subState": sub_state,
|
||||||
|
"unitFileState": unit_file_state,
|
||||||
"active": active,
|
"active": active,
|
||||||
"enabled": enabled,
|
"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:
|
@staticmethod
|
||||||
try:
|
def _parse_properties(output: str) -> dict[str, str]:
|
||||||
return self.command_runner.run(command).stdout.strip()
|
properties: dict[str, str] = {}
|
||||||
except Exception:
|
for line in output.splitlines():
|
||||||
return "unknown"
|
key, separator, value = line.partition("=")
|
||||||
|
if separator and key:
|
||||||
|
properties[key.strip()] = value.strip()
|
||||||
|
return properties
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
|||||||
from app.storage.repository import Repository, utc_now
|
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:
|
class TaskRunner:
|
||||||
def __init__(self, repository: Repository) -> None:
|
def __init__(self, repository: Repository) -> None:
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
@@ -25,6 +37,7 @@ class TaskRunner:
|
|||||||
self.manifest_validator = ManifestValidator()
|
self.manifest_validator = ManifestValidator()
|
||||||
|
|
||||||
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
|
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
|
||||||
|
manifest: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
self._mark_started(task_id, f"starting {task_type}")
|
self._mark_started(task_id, f"starting {task_type}")
|
||||||
self._require_root_if_available()
|
self._require_root_if_available()
|
||||||
@@ -49,6 +62,9 @@ class TaskRunner:
|
|||||||
finished_at=utc_now(),
|
finished_at=utc_now(),
|
||||||
)
|
)
|
||||||
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
|
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:
|
except Exception as error:
|
||||||
self._fail_task(task_id, error)
|
self._fail_task(task_id, error)
|
||||||
|
|
||||||
@@ -332,30 +348,121 @@ class TaskRunner:
|
|||||||
installed_component = dict(component)
|
installed_component = dict(component)
|
||||||
installed_component["version"] = installed_version
|
installed_component["version"] = installed_version
|
||||||
|
|
||||||
if package_name == "postgresql":
|
policy = APT_SERVICE_POLICIES.get(package_name, {})
|
||||||
service_name = "postgresql.service"
|
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(
|
self.repository.update_task_component(
|
||||||
task_id,
|
task_id,
|
||||||
component_id,
|
component_id,
|
||||||
progress=85,
|
progress=85,
|
||||||
current_step="starting PostgreSQL service",
|
current_step="starting managed package services",
|
||||||
)
|
)
|
||||||
|
for service_name in managed_services:
|
||||||
|
try:
|
||||||
services.enable_service(service_name)
|
services.enable_service(service_name)
|
||||||
services.start_service(service_name)
|
services.start_service(service_name)
|
||||||
services.assert_service_active(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=90,
|
||||||
|
current_step="checking PostgreSQL readiness",
|
||||||
|
)
|
||||||
|
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(
|
self.repository.update_task_component(
|
||||||
task_id,
|
task_id,
|
||||||
component_id,
|
component_id,
|
||||||
progress=95,
|
progress=95,
|
||||||
current_step="checking PostgreSQL readiness",
|
current_step="reading service status",
|
||||||
)
|
)
|
||||||
installer.wait_for_postgresql()
|
for service_name in service_names:
|
||||||
self.repository.add_log(task_id, "info", "PostgreSQL service is active and accepting connections")
|
check = services.get_service_status(service_name)
|
||||||
installed_component["serviceName"] = 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)
|
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:
|
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
|
||||||
component_id = component["componentId"]
|
component_id = component["componentId"]
|
||||||
container_name = component["containerName"]
|
container_name = component["containerName"]
|
||||||
@@ -426,6 +533,33 @@ class TaskRunner:
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.repository.add_log(task_id, "warning", f"Could not {action}: {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:
|
def _clean_cached_package_files(self, task_id: str, *identifiers: str | None) -> None:
|
||||||
cache_dir = settings.cache_dir
|
cache_dir = settings.cache_dir
|
||||||
if not cache_dir.exists() or not cache_dir.is_dir():
|
if not cache_dir.exists() or not cache_dir.is_dir():
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -55,6 +57,8 @@ CREATE TABLE IF NOT EXISTS installed_components (
|
|||||||
package_name TEXT,
|
package_name TEXT,
|
||||||
package_version TEXT,
|
package_version TEXT,
|
||||||
service_name TEXT,
|
service_name TEXT,
|
||||||
|
service_check_status TEXT DEFAULT 'not-checked',
|
||||||
|
service_checks_json TEXT,
|
||||||
docker_image TEXT,
|
docker_image TEXT,
|
||||||
docker_digest TEXT,
|
docker_digest TEXT,
|
||||||
container_name TEXT,
|
container_name TEXT,
|
||||||
@@ -75,6 +79,8 @@ CREATE TABLE IF NOT EXISTS task_components (
|
|||||||
progress INTEGER DEFAULT 0,
|
progress INTEGER DEFAULT 0,
|
||||||
current_step TEXT,
|
current_step TEXT,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
|
service_check_status TEXT DEFAULT 'not-checked',
|
||||||
|
service_checks_json TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
finished_at TEXT,
|
finished_at TEXT,
|
||||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
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}")
|
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 = sqlite3.connect(settings.db_path, timeout=30)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
return connection
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def initialize_database() -> None:
|
def initialize_database() -> None:
|
||||||
@@ -110,3 +124,17 @@ def initialize_database() -> None:
|
|||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
connection.executescript(SCHEMA)
|
connection.executescript(SCHEMA)
|
||||||
_ensure_column(connection, "installed_apps", "open_url", "TEXT")
|
_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")
|
||||||
|
|||||||
@@ -17,6 +17,29 @@ def row_to_dict(row: Any) -> dict[str, Any] | None:
|
|||||||
return dict(row)
|
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:
|
class Repository:
|
||||||
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
|
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
@@ -118,6 +141,8 @@ class Repository:
|
|||||||
progress: int | None = None,
|
progress: int | None = None,
|
||||||
current_step: str | None = None,
|
current_step: str | None = None,
|
||||||
error_message: 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,
|
started_at: str | None = None,
|
||||||
finished_at: str | None = None,
|
finished_at: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -128,6 +153,12 @@ class Repository:
|
|||||||
"progress": progress,
|
"progress": progress,
|
||||||
"current_step": current_step,
|
"current_step": current_step,
|
||||||
"error_message": error_message,
|
"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,
|
"started_at": started_at,
|
||||||
"finished_at": finished_at,
|
"finished_at": finished_at,
|
||||||
}.items():
|
}.items():
|
||||||
@@ -147,14 +178,15 @@ class Repository:
|
|||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
rows = connection.execute(
|
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
|
FROM task_components
|
||||||
WHERE task_id = ?
|
WHERE task_id = ?
|
||||||
ORDER BY install_order ASC, id ASC
|
ORDER BY install_order ASC, id ASC
|
||||||
""",
|
""",
|
||||||
(task_id,),
|
(task_id,),
|
||||||
).fetchall()
|
).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]]:
|
def list_installed_apps(self) -> list[dict[str, Any]]:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
@@ -205,10 +237,11 @@ class Repository:
|
|||||||
"""
|
"""
|
||||||
INSERT INTO installed_components (
|
INSERT INTO installed_components (
|
||||||
app_id, component_id, type, install_order, status, package_name, package_version,
|
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
|
installed_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(app_id, component_id) DO UPDATE SET
|
ON CONFLICT(app_id, component_id) DO UPDATE SET
|
||||||
type = excluded.type,
|
type = excluded.type,
|
||||||
install_order = excluded.install_order,
|
install_order = excluded.install_order,
|
||||||
@@ -216,6 +249,8 @@ class Repository:
|
|||||||
package_name = excluded.package_name,
|
package_name = excluded.package_name,
|
||||||
package_version = excluded.package_version,
|
package_version = excluded.package_version,
|
||||||
service_name = excluded.service_name,
|
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_image = excluded.docker_image,
|
||||||
docker_digest = excluded.docker_digest,
|
docker_digest = excluded.docker_digest,
|
||||||
container_name = excluded.container_name,
|
container_name = excluded.container_name,
|
||||||
@@ -230,6 +265,8 @@ class Repository:
|
|||||||
component.get("packageName"),
|
component.get("packageName"),
|
||||||
component.get("version"),
|
component.get("version"),
|
||||||
component.get("serviceName"),
|
component.get("serviceName"),
|
||||||
|
component.get("serviceCheckStatus", "not-checked"),
|
||||||
|
_encode_service_checks(component.get("serviceChecks", [])),
|
||||||
component.get("image"),
|
component.get("image"),
|
||||||
component.get("digest"),
|
component.get("digest"),
|
||||||
component.get("containerName"),
|
component.get("containerName"),
|
||||||
@@ -250,7 +287,7 @@ class Repository:
|
|||||||
""",
|
""",
|
||||||
(app_id,),
|
(app_id,),
|
||||||
).fetchall()
|
).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:
|
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
|
||||||
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Package: local-installer-agent
|
Package: local-installer-agent
|
||||||
Version: 1.0.0
|
Version: 1.0.3
|
||||||
Section: utils
|
Section: utils
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${VERSION:-1.0.0}"
|
VERSION="${VERSION:-1.0.3}"
|
||||||
ARCH="${ARCH:-amd64}"
|
ARCH="${ARCH:-amd64}"
|
||||||
AGENT_HOST="${AGENT_HOST:-0.0.0.0}"
|
AGENT_HOST="${AGENT_HOST:-0.0.0.0}"
|
||||||
AGENT_PORT="${AGENT_PORT:-5010}"
|
AGENT_PORT="${AGENT_PORT:-5010}"
|
||||||
@@ -21,7 +21,8 @@ if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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}/opt/local-installer-agent"
|
||||||
mkdir -p "${BUILD_DIR}/etc/local-installer-agent"
|
mkdir -p "${BUILD_DIR}/etc/local-installer-agent"
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.core.command_runner import CommandError
|
||||||
from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller
|
from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller
|
||||||
from app.core.manifest_validator import ManifestValidator
|
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:
|
def apt_manifest(component: dict | None = None) -> dict:
|
||||||
@@ -37,6 +39,7 @@ class FakeCommandRunner:
|
|||||||
command: list[str],
|
command: list[str],
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
env: dict[str, str] | None = None,
|
env: dict[str, str] | None = None,
|
||||||
|
log_output: bool = True,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
self.calls.append((command, timeout, env))
|
self.calls.append((command, timeout, env))
|
||||||
return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="")
|
return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="")
|
||||||
@@ -89,6 +92,7 @@ class AptInstallerTests(unittest.TestCase):
|
|||||||
installer.update_package_index()
|
installer.update_package_index()
|
||||||
installer.install_package("postgresql")
|
installer.install_package("postgresql")
|
||||||
installer.wait_for_postgresql(attempts=1, delay_seconds=0)
|
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(runner.calls[0][0], ["apt-get", "update"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -108,6 +112,78 @@ class AptInstallerTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV)
|
self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV)
|
||||||
self.assertEqual(runner.calls[2][0], ["pg_isready", "--timeout=5"])
|
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:
|
class FakeRepository:
|
||||||
@@ -126,6 +202,42 @@ class FakeRepository:
|
|||||||
self.installed_component = component
|
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:
|
class FakeAptInstaller:
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
@@ -145,6 +257,10 @@ class FakeAptInstaller:
|
|||||||
def wait_for_postgresql(self) -> None:
|
def wait_for_postgresql(self) -> None:
|
||||||
self.actions.append("pg_isready")
|
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:
|
class FakeServiceManager:
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
@@ -161,8 +277,61 @@ class FakeServiceManager:
|
|||||||
def assert_service_active(self, service_name: str) -> None:
|
def assert_service_active(self, service_name: str) -> None:
|
||||||
self.actions.append(f"active:{service_name}")
|
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):
|
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:
|
def test_postgresql_install_verifies_service_and_readiness(self) -> None:
|
||||||
repository = FakeRepository()
|
repository = FakeRepository()
|
||||||
FakeAptInstaller.actions = []
|
FakeAptInstaller.actions = []
|
||||||
@@ -186,19 +355,101 @@ class AptTaskRunnerTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
FakeAptInstaller.actions,
|
FakeAptInstaller.actions,
|
||||||
["update", "install:postgresql", "version:postgresql", "pg_isready"],
|
[
|
||||||
|
"update",
|
||||||
|
"install:postgresql",
|
||||||
|
"version:postgresql",
|
||||||
|
"discover:postgresql",
|
||||||
|
"pg_isready",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
FakeServiceManager.actions,
|
FakeServiceManager.actions,
|
||||||
[
|
[
|
||||||
"enable:postgresql.service",
|
"enable:postgresql.service",
|
||||||
"start:postgresql.service",
|
"start:postgresql.service",
|
||||||
"active:postgresql.service",
|
"status:postgresql.service",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(repository.installed_component["version"], "16+257build1")
|
self.assertEqual(repository.installed_component["version"], "16+257build1")
|
||||||
self.assertEqual(repository.installed_component["serviceName"], "postgresql.service")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
194
agent/tests/test_service_check_persistence.py
Normal file
194
agent/tests/test_service_check_persistence.py
Normal 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()
|
||||||
@@ -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 { createRoot } from 'react-dom/client';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -121,6 +121,36 @@ function statusBadgeClass(status) {
|
|||||||
return `badge badge-${tone}`;
|
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) {
|
function formatTaskTime(value) {
|
||||||
if (!value) return '--';
|
if (!value) return '--';
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -218,6 +248,7 @@ function App() {
|
|||||||
const [draftSettings, setDraftSettings] = useState(settings);
|
const [draftSettings, setDraftSettings] = useState(settings);
|
||||||
const [apps, setApps] = useState([]);
|
const [apps, setApps] = useState([]);
|
||||||
const [installedApps, setInstalledApps] = useState([]);
|
const [installedApps, setInstalledApps] = useState([]);
|
||||||
|
const [installedAppsReady, setInstalledAppsReady] = useState(false);
|
||||||
const [latestAgentPackage, setLatestAgentPackage] = useState(null);
|
const [latestAgentPackage, setLatestAgentPackage] = useState(null);
|
||||||
const [agentHealth, setAgentHealth] = useState(null);
|
const [agentHealth, setAgentHealth] = useState(null);
|
||||||
const [systemInfo, setSystemInfo] = useState(null);
|
const [systemInfo, setSystemInfo] = useState(null);
|
||||||
@@ -234,9 +265,13 @@ function App() {
|
|||||||
const [activeTask, setActiveTask] = useState(null);
|
const [activeTask, setActiveTask] = useState(null);
|
||||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||||
const [agentDialogOpen, setAgentDialogOpen] = 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 packageBaseUrl = settings.packageBaseUrl;
|
||||||
const agentBaseUrl = settings.agentBaseUrl;
|
const agentBaseUrl = settings.agentBaseUrl;
|
||||||
|
agentEndpointRef.current = agentBaseUrl;
|
||||||
const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`;
|
const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`;
|
||||||
const agentCommand = latestAgentPackage?.installCommand || installCommand;
|
const agentCommand = latestAgentPackage?.installCommand || installCommand;
|
||||||
const clientOs = useMemo(() => detectClientOs(), []);
|
const clientOs = useMemo(() => detectClientOs(), []);
|
||||||
@@ -260,12 +295,14 @@ function App() {
|
|||||||
const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode);
|
const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode);
|
||||||
const isInstalled = Boolean(installed);
|
const isInstalled = Boolean(installed);
|
||||||
const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version);
|
const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version);
|
||||||
|
const needsAttention = Boolean(isInstalled && installed.status === 'attention');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
installed,
|
installed,
|
||||||
localStatus: canUpdate ? 'update' : (isInstalled ? 'installed' : 'available'),
|
localStatus: canUpdate ? 'update' : (isInstalled ? 'installed' : 'available'),
|
||||||
canUpdate
|
canUpdate,
|
||||||
|
needsAttention
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [apps, installedByAppId]);
|
}, [apps, installedByAppId]);
|
||||||
@@ -326,10 +363,21 @@ function App() {
|
|||||||
}, [packageBaseUrl]);
|
}, [packageBaseUrl]);
|
||||||
|
|
||||||
const refreshAgent = useCallback(async () => {
|
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 (!canUseAgentEndpoint) {
|
||||||
|
if (!isCurrentRequest()) return false;
|
||||||
setAgentHealth(null);
|
setAgentHealth(null);
|
||||||
setSystemInfo(null);
|
setSystemInfo(null);
|
||||||
setInstalledApps([]);
|
setInstalledApps([]);
|
||||||
|
setInstalledAppsReady(false);
|
||||||
setAgentStatus({
|
setAgentStatus({
|
||||||
state: 'warning',
|
state: 'warning',
|
||||||
message: isClientWindows
|
message: isClientWindows
|
||||||
@@ -340,22 +388,37 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAgentStatus({ state: 'loading', message: `Checking ${agentTargetLabel}` });
|
setAgentStatus({ state: 'loading', message: `Checking ${agentTargetLabel}` });
|
||||||
|
setInstalledAppsReady(false);
|
||||||
try {
|
try {
|
||||||
const health = await fetchAgentHealth(agentBaseUrl);
|
const health = await fetchAgentHealth(requestEndpoint);
|
||||||
|
if (!isCurrentRequest()) return false;
|
||||||
setAgentHealth(health);
|
setAgentHealth(health);
|
||||||
setAgentStatus({ state: 'success', message: `${health.hostname || agentTargetLabel} online` });
|
setAgentStatus({ state: 'success', message: `${health.hostname || agentTargetLabel} online` });
|
||||||
|
|
||||||
const [info, installed] = await Promise.all([
|
const [infoResult, installedResult] = await Promise.allSettled([
|
||||||
fetchAgentSystemInfo(agentBaseUrl).catch(() => null),
|
fetchAgentSystemInfo(requestEndpoint),
|
||||||
fetchInstalledApps(agentBaseUrl)
|
fetchInstalledApps(requestEndpoint)
|
||||||
]);
|
]);
|
||||||
setSystemInfo(info);
|
if (!isCurrentRequest()) return false;
|
||||||
setInstalledApps(installed);
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentRequest()) return false;
|
||||||
setAgentHealth(null);
|
setAgentHealth(null);
|
||||||
setSystemInfo(null);
|
setSystemInfo(null);
|
||||||
setInstalledApps([]);
|
setInstalledApps([]);
|
||||||
|
setInstalledAppsReady(false);
|
||||||
setAgentStatus({ state: 'danger', message: getErrorMessage(error) });
|
setAgentStatus({ state: 'danger', message: getErrorMessage(error) });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -392,13 +455,13 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const [nextTask, logs, components] = await Promise.all([
|
const [nextTask, logs, components] = await Promise.all([
|
||||||
fetchTaskStatus(agentBaseUrl, taskId),
|
fetchTaskStatus(agentBaseUrl, taskId),
|
||||||
fetchTaskLogs(agentBaseUrl, taskId).catch(() => []),
|
fetchTaskLogs(agentBaseUrl, taskId).catch(() => null),
|
||||||
fetchTaskComponents(agentBaseUrl, taskId).catch(() => [])
|
fetchTaskComponents(agentBaseUrl, taskId).catch(() => null)
|
||||||
]);
|
]);
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
...nextTask,
|
...nextTask,
|
||||||
logs,
|
...(logs ? { logs } : {}),
|
||||||
components,
|
...(components ? { components } : {}),
|
||||||
pollError: ''
|
pollError: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -483,31 +546,54 @@ function App() {
|
|||||||
return;
|
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}?`)) {
|
if (action === 'remove' && !window.confirm(`Remove and clean ${app.appName} from ${agentTargetMachine}?`)) {
|
||||||
return;
|
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}`;
|
const key = `${action}:${app.appId}`;
|
||||||
setBusyAction(key);
|
setBusyAction(key);
|
||||||
try {
|
try {
|
||||||
let queuedTask;
|
let queuedTask;
|
||||||
if (action === 'install') {
|
if (action === 'install') {
|
||||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||||
|
if (!isCurrentEndpoint()) return;
|
||||||
setSelectedManifest(manifest);
|
setSelectedManifest(manifest);
|
||||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||||
queuedTask = await queueInstall(agentBaseUrl, app);
|
queuedTask = await queueInstall(actionEndpoint, app);
|
||||||
} else if (action === 'update') {
|
} else if (action === 'update') {
|
||||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||||
|
if (!isCurrentEndpoint()) return;
|
||||||
setSelectedManifest(manifest);
|
setSelectedManifest(manifest);
|
||||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||||
queuedTask = await queueUpdate(agentBaseUrl, app, app.installed);
|
queuedTask = await queueUpdate(actionEndpoint, app, app.installed);
|
||||||
} else {
|
} 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);
|
startTask(queuedTask, action, app);
|
||||||
notify('success', `Đã queue task ${queuedTask.taskId}`);
|
notify('success', `Đã queue task ${queuedTask.taskId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentEndpoint()) return;
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
if (action === 'install' || action === 'update') {
|
if (action === 'install' || action === 'update') {
|
||||||
setSelectedManifest(null);
|
setSelectedManifest(null);
|
||||||
@@ -516,9 +602,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
notify('failure', message);
|
notify('failure', message);
|
||||||
} finally {
|
} 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(() => {
|
const openEndpointDialog = useCallback(() => {
|
||||||
setDraftSettings(settings);
|
setDraftSettings(settings);
|
||||||
@@ -535,12 +621,30 @@ function App() {
|
|||||||
packageBaseUrl: normalizeUrl(draftSettings.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL),
|
packageBaseUrl: normalizeUrl(draftSettings.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL),
|
||||||
agentBaseUrl: normalizeUrl(draftSettings.agentBaseUrl || DEFAULT_AGENT_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);
|
setSettings(nextSettings);
|
||||||
setDraftSettings(nextSettings);
|
setDraftSettings(nextSettings);
|
||||||
saveSettings(nextSettings);
|
saveSettings(nextSettings);
|
||||||
setEndpointDialogOpen(false);
|
setEndpointDialogOpen(false);
|
||||||
notify('info', 'Đã cập nhật endpoint test');
|
notify('info', 'Đã cập nhật endpoint test');
|
||||||
}, [draftSettings, notify]);
|
}, [busyAction, draftSettings, notify, settings.agentBaseUrl, settings.packageBaseUrl]);
|
||||||
|
|
||||||
const copyInstallCommand = useCallback(async () => {
|
const copyInstallCommand = useCallback(async () => {
|
||||||
if (!canShowAgentCommand) {
|
if (!canShowAgentCommand) {
|
||||||
@@ -842,7 +946,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary compact"
|
className="btn btn-primary compact"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!agentHealth || rowTaskBusy || installBusy}
|
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || installBusy}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
runAppAction('install', app);
|
runAppAction('install', app);
|
||||||
@@ -852,11 +956,25 @@ function App() {
|
|||||||
Install
|
Install
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canManageApps && app.installed && app.canUpdate && (
|
{canManageApps && app.installed && app.needsAttention && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-warning compact"
|
className="btn btn-warning compact"
|
||||||
type="button"
|
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) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
runAppAction('update', app);
|
runAppAction('update', app);
|
||||||
@@ -884,7 +1002,7 @@ function App() {
|
|||||||
className="icon-button danger"
|
className="icon-button danger"
|
||||||
type="button"
|
type="button"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
disabled={!agentHealth || rowTaskBusy || removeBusy}
|
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || removeBusy}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
runAppAction('remove', app);
|
runAppAction('remove', app);
|
||||||
@@ -1102,20 +1220,28 @@ function LocalStatus({ app, task }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.localStatus === 'update') {
|
if (app.installed) {
|
||||||
|
const serviceSummary = serviceCheckSummary(
|
||||||
|
app.installed.serviceCheckStatus,
|
||||||
|
app.installed.serviceChecks
|
||||||
|
);
|
||||||
|
const statusLabel = app.needsAttention
|
||||||
|
? 'Attention'
|
||||||
|
: app.localStatus === 'update'
|
||||||
|
? 'Update'
|
||||||
|
: 'Installed';
|
||||||
|
const statusTone = app.needsAttention || app.localStatus === 'update' ? 'warning' : 'success';
|
||||||
return (
|
return (
|
||||||
|
<span className="status-stack">
|
||||||
<span className="status-inline">
|
<span className="status-inline">
|
||||||
<span className="badge badge-warning">Update</span>
|
<span className={`badge badge-${statusTone}`}>{statusLabel}</span>
|
||||||
<small>{app.installed.version}</small>
|
<small>{app.installed.version}</small>
|
||||||
</span>
|
</span>
|
||||||
);
|
{serviceSummary && (
|
||||||
}
|
<small className={app.installed.serviceCheckStatus === 'unhealthy' ? 'danger-text' : 'success-text'}>
|
||||||
|
{serviceSummary}
|
||||||
if (app.localStatus === 'installed') {
|
</small>
|
||||||
return (
|
)}
|
||||||
<span className="status-inline">
|
|
||||||
<span className="badge badge-success">Installed</span>
|
|
||||||
<small>{app.installed.version}</small>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1315,12 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
|||||||
<span>{component.currentStep || component.type || '-'}</span>
|
<span>{component.currentStep || component.type || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={statusBadgeClass(component.status)}>{component.progress}%</span>
|
<span className={statusBadgeClass(component.status)}>{component.progress}%</span>
|
||||||
|
{component.type === 'apt' && (
|
||||||
|
<ServiceChecks
|
||||||
|
checks={component.serviceChecks}
|
||||||
|
status={component.serviceCheckStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!components.length && (
|
{!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 }) {
|
function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPackage, needsUpdate, onClose, onCopyUpdate, showAgentActions }) {
|
||||||
const statusTone = needsUpdate
|
const statusTone = needsUpdate
|
||||||
? 'warning'
|
? 'warning'
|
||||||
@@ -1306,6 +1478,8 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
|||||||
function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||||
const packages = detail?.packages || [];
|
const packages = detail?.packages || [];
|
||||||
const components = manifest?.components || [];
|
const components = manifest?.components || [];
|
||||||
|
const installedServiceChecks = app?.installed?.serviceChecks || [];
|
||||||
|
const installedServiceStatus = app?.installed?.serviceCheckStatus || 'not-checked';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel app-detail-panel">
|
<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 className="table-empty compact-empty">{status.message || 'Chưa có component.'}</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
<div className="table-empty compact-empty">Chọn app để xem manifest.</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export async function fetchAgentSystemInfo(agentBaseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchInstalledApps(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) : [];
|
return Array.isArray(payload) ? payload.map(normalizeInstalledApp) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +256,7 @@ function normalizePackageApp(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInstalledApp(app) {
|
function normalizeInstalledApp(app) {
|
||||||
|
const serviceChecks = normalizeServiceChecks(app.serviceChecks || app.service_checks);
|
||||||
return {
|
return {
|
||||||
appId: String(app.appId || app.app_id || '').trim(),
|
appId: String(app.appId || app.app_id || '').trim(),
|
||||||
appName: String(app.appName || app.app_name || '').trim(),
|
appName: String(app.appName || app.app_name || '').trim(),
|
||||||
@@ -263,6 +264,12 @@ function normalizeInstalledApp(app) {
|
|||||||
status: String(app.status || 'installed').trim(),
|
status: String(app.status || 'installed').trim(),
|
||||||
installedAt: app.installedAt || app.installed_at || '',
|
installedAt: app.installedAt || app.installed_at || '',
|
||||||
updatedAt: app.updatedAt || app.updated_at || '',
|
updatedAt: app.updatedAt || app.updated_at || '',
|
||||||
|
serviceCheckStatus: String(
|
||||||
|
app.serviceCheckStatus
|
||||||
|
|| app.service_check_status
|
||||||
|
|| deriveServiceCheckStatus(serviceChecks)
|
||||||
|
).trim(),
|
||||||
|
serviceChecks,
|
||||||
openUrl: normalizeOpenUrl(
|
openUrl: normalizeOpenUrl(
|
||||||
app.openUrl
|
app.openUrl
|
||||||
|| app.open_url
|
|| app.open_url
|
||||||
@@ -312,6 +319,7 @@ function normalizeLog(log) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeComponent(component) {
|
function normalizeComponent(component) {
|
||||||
|
const serviceChecks = normalizeServiceChecks(component.serviceChecks || component.service_checks);
|
||||||
return {
|
return {
|
||||||
componentId: component.componentId || component.component_id,
|
componentId: component.componentId || component.component_id,
|
||||||
type: component.type,
|
type: component.type,
|
||||||
@@ -319,7 +327,50 @@ function normalizeComponent(component) {
|
|||||||
progress: Number(component.progress || 0),
|
progress: Number(component.progress || 0),
|
||||||
currentStep: component.currentStep || component.current_step,
|
currentStep: component.currentStep || component.current_step,
|
||||||
errorMessage: component.errorMessage || component.error_message,
|
errorMessage: component.errorMessage || component.error_message,
|
||||||
|
serviceCheckStatus: String(
|
||||||
|
component.serviceCheckStatus
|
||||||
|
|| component.service_check_status
|
||||||
|
|| deriveServiceCheckStatus(serviceChecks)
|
||||||
|
).trim(),
|
||||||
|
serviceChecks,
|
||||||
startedAt: component.startedAt || component.started_at,
|
startedAt: component.startedAt || component.started_at,
|
||||||
finishedAt: component.finishedAt || component.finished_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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -782,6 +782,10 @@ tbody tr.selected-row {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.action-col {
|
.action-col {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -847,6 +851,18 @@ tbody tr.selected-row td.action-col {
|
|||||||
font-weight: 700;
|
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 {
|
.page-pager {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@@ -1053,6 +1069,97 @@ tbody tr.selected-row td.action-col {
|
|||||||
justify-content: center;
|
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 {
|
.task-log-list {
|
||||||
border-top: 1px solid #eef2f7;
|
border-top: 1px solid #eef2f7;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user