Compare commits
7 Commits
4a159cad71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad80afbfdd | |||
| 9b9c607fd1 | |||
| 166276d19b | |||
| a836645728 | |||
| 3da8608cf1 | |||
| 0714b442be | |||
| db1ce68800 |
@@ -2,7 +2,7 @@
|
||||
|
||||
FastAPI service that runs on each Linux client and listens on `127.0.0.1:5010`.
|
||||
|
||||
It accepts install, update, remove, task, log, installed-app, and service-control requests from `robot.installer`. It stores state in local SQLite and installs trusted `.deb` components downloaded from `robot.package`, allowlisted Ubuntu APT packages, plus Docker image components from allowed registries when Docker support is enabled.
|
||||
It accepts install, update, remove, task, log, installed-app, and service-control requests from `robot.installer`. It stores state in local SQLite and installs trusted `.deb` components downloaded from `robot.package`, Ubuntu APT packages, plus Docker image components from allowed registries when Docker support is enabled.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -88,4 +88,49 @@ 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.
|
||||
`ALLOWED_APT_PACKAGES=*` allows every syntactically valid APT package name and is the default for newly built Agents. This lets an administrator enter an exact package code on the Web Server without updating each Agent's configuration for every new package.
|
||||
|
||||
To enable this behavior on an already deployed Agent:
|
||||
|
||||
```bash
|
||||
sudo sed -i \
|
||||
's/^ALLOWED_APT_PACKAGES=.*/ALLOWED_APT_PACKAGES=*/' \
|
||||
/etc/local-installer-agent/agent.env
|
||||
sudo systemctl restart local-installer-agent
|
||||
```
|
||||
|
||||
To restore a restricted allowlist, use a comma-separated list without spaces:
|
||||
|
||||
```bash
|
||||
ALLOWED_APT_PACKAGES=postgresql,nginx,redis-server,curl
|
||||
```
|
||||
|
||||
Every allowed package must be available from the APT sources configured on the target client. The Agent runs `apt-get update`, but it does not add third-party repositories or signing keys.
|
||||
|
||||
APT packages can execute maintainer scripts as root during installation. Wildcard mode should therefore only be used when the Web Server and package-upload accounts are trusted and access-controlled.
|
||||
|
||||
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 app.core.command_runner import CommandRunner
|
||||
from app.core.installer import AptInstaller
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.core.task_runner import TaskRunner
|
||||
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||
from app.storage.repository import Repository
|
||||
@@ -20,7 +23,56 @@ def _task_id(prefix: str) -> str:
|
||||
|
||||
@router.get("/installed")
|
||||
def installed_apps() -> list[dict]:
|
||||
return repository.list_installed_apps()
|
||||
apps = repository.list_installed_apps()
|
||||
command_runner = CommandRunner(repository)
|
||||
service_manager = ServiceManager(command_runner)
|
||||
apt_installer = AptInstaller(command_runner)
|
||||
for app in apps:
|
||||
service_checks: list[dict] = []
|
||||
component_statuses: list[str] = []
|
||||
for component in repository.list_installed_components(app["app_id"]):
|
||||
check_status = component.get("service_check_status", "not-checked")
|
||||
if check_status != "not-checked":
|
||||
component_statuses.append(check_status)
|
||||
for check in component.get("service_checks", []):
|
||||
service_name = str(check.get("serviceName") or "").strip()
|
||||
if not service_name:
|
||||
continue
|
||||
current_check = service_manager.get_service_status(service_name)
|
||||
if current_check.get("status") == "unknown" and current_check.get("errorMessage"):
|
||||
current_check = dict(check)
|
||||
current_check["stale"] = True
|
||||
elif check.get("readinessType") == "postgresql":
|
||||
readiness_status = "ready" if apt_installer.is_postgresql_ready() else "failed"
|
||||
current_check["readinessType"] = "postgresql"
|
||||
current_check["readinessStatus"] = readiness_status
|
||||
current_check["healthy"] = bool(current_check.get("healthy")) and readiness_status == "ready"
|
||||
service_checks.append(
|
||||
{
|
||||
**current_check,
|
||||
"componentId": component["component_id"],
|
||||
"packageName": component.get("package_name"),
|
||||
}
|
||||
)
|
||||
|
||||
if service_checks and any(not check.get("healthy") for check in service_checks):
|
||||
overall_status = "unhealthy"
|
||||
elif service_checks:
|
||||
overall_status = "healthy"
|
||||
elif "unhealthy" in component_statuses:
|
||||
overall_status = "unhealthy"
|
||||
elif "checking" in component_statuses:
|
||||
overall_status = "checking"
|
||||
elif "healthy" in component_statuses:
|
||||
overall_status = "healthy"
|
||||
elif "not-applicable" in component_statuses:
|
||||
overall_status = "not-applicable"
|
||||
else:
|
||||
overall_status = "not-checked"
|
||||
|
||||
app["serviceCheckStatus"] = overall_status
|
||||
app["serviceChecks"] = service_checks
|
||||
return apps
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
|
||||
@@ -65,10 +65,11 @@ def get_task_components(task_id: str) -> dict:
|
||||
"progress": item["progress"],
|
||||
"currentStep": item["current_step"],
|
||||
"errorMessage": item["error_message"],
|
||||
"serviceCheckStatus": item["service_check_status"],
|
||||
"serviceChecks": item["service_checks"],
|
||||
"startedAt": item["started_at"],
|
||||
"finishedAt": item["finished_at"],
|
||||
}
|
||||
for item in repository.get_task_components(task_id)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ def _bool(name: str, default: bool) -> bool:
|
||||
def get_settings() -> Settings:
|
||||
robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://package.pnkr.cloud").rstrip("/")
|
||||
return Settings(
|
||||
agent_version=os.getenv("AGENT_VERSION", "1.0.0"),
|
||||
agent_version=os.getenv("AGENT_VERSION", "1.0.4"),
|
||||
host=os.getenv("AGENT_HOST", "0.0.0.0"),
|
||||
port=int(os.getenv("AGENT_PORT", "5010")),
|
||||
robot_package_base_url=robot_package_base_url,
|
||||
@@ -86,7 +86,7 @@ def get_settings() -> Settings:
|
||||
),
|
||||
allowed_apt_packages=_csv(
|
||||
os.getenv("ALLOWED_APT_PACKAGES"),
|
||||
["postgresql"],
|
||||
["*"],
|
||||
),
|
||||
allowed_docker_registries=_csv_with_defaults(
|
||||
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
|
||||
|
||||
@@ -51,6 +51,7 @@ class CommandRunner:
|
||||
command: list[str],
|
||||
timeout: int | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if self.task_id:
|
||||
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
|
||||
@@ -74,7 +75,7 @@ class CommandRunner:
|
||||
self.repository.add_log(self.task_id, "error", f"Command timed out: {' '.join(command)}")
|
||||
raise CommandError(command, 124, error.stdout or "", error.stderr or "") from error
|
||||
|
||||
if self.task_id:
|
||||
if self.task_id and log_output:
|
||||
for line in result.stdout.splitlines():
|
||||
self.repository.add_log(self.task_id, "debug", line)
|
||||
for line in result.stderr.splitlines():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from app.core.command_runner import CommandError, CommandRunner
|
||||
|
||||
@@ -125,6 +125,38 @@ class AptInstaller(DebInstaller):
|
||||
env=APT_NONINTERACTIVE_ENV,
|
||||
)
|
||||
|
||||
def discover_service_units(self, package_name: str) -> list[str]:
|
||||
"""Return concrete systemd service units shipped by an installed package.
|
||||
|
||||
The package name is already syntax-validated by the manifest validator. It
|
||||
is passed as a single argv item and no shell expansion is performed.
|
||||
Template units are omitted because they cannot be meaningfully checked
|
||||
without an instance name.
|
||||
"""
|
||||
try:
|
||||
result = self.command_runner.run(
|
||||
["dpkg-query", "-L", package_name],
|
||||
timeout=30,
|
||||
log_output=False,
|
||||
)
|
||||
except CommandError:
|
||||
return []
|
||||
|
||||
service_names: set[str] = set()
|
||||
for raw_path in result.stdout.splitlines():
|
||||
path = raw_path.strip()
|
||||
if not path.endswith(".service"):
|
||||
continue
|
||||
if "/systemd/system/" not in path:
|
||||
continue
|
||||
|
||||
service_name = PurePosixPath(path).name
|
||||
if "@" in service_name:
|
||||
continue
|
||||
service_names.add(service_name)
|
||||
|
||||
return sorted(service_names)
|
||||
|
||||
def wait_for_postgresql(
|
||||
self,
|
||||
attempts: int = 6,
|
||||
@@ -141,3 +173,10 @@ class AptInstaller(DebInstaller):
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
raise RuntimeError("PostgreSQL did not become ready after installation") from last_error
|
||||
|
||||
def is_postgresql_ready(self) -> bool:
|
||||
try:
|
||||
self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -18,7 +18,7 @@ class ManifestValidator:
|
||||
elif component_type == "apt":
|
||||
component = AptComponent.model_validate(raw_component).model_dump(by_alias=True)
|
||||
allowed_packages = set(settings.allowed_apt_packages)
|
||||
if component["packageName"] not in allowed_packages:
|
||||
if "*" not in allowed_packages and component["packageName"] not in allowed_packages:
|
||||
raise ValueError(
|
||||
f"APT package is not allowed: {component['packageName']}"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.command_runner import CommandError
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
@@ -31,17 +34,60 @@ class ServiceManager:
|
||||
raise RuntimeError(f"Service is not active: {service_name}")
|
||||
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
active = self._query(["systemctl", "is-active", service_name]) == "active"
|
||||
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
|
||||
command = [
|
||||
"systemctl",
|
||||
"show",
|
||||
service_name,
|
||||
"--no-pager",
|
||||
"--property=LoadState",
|
||||
"--property=ActiveState",
|
||||
"--property=SubState",
|
||||
"--property=UnitFileState",
|
||||
]
|
||||
error_message: str | None = None
|
||||
try:
|
||||
output = self.command_runner.run(command, timeout=5).stdout
|
||||
except CommandError as error:
|
||||
output = error.stdout
|
||||
error_message = str(error)
|
||||
except Exception as error:
|
||||
output = ""
|
||||
error_message = str(error)
|
||||
|
||||
properties = self._parse_properties(output)
|
||||
load_state = properties.get("LoadState", "unknown") or "unknown"
|
||||
active_state = properties.get("ActiveState", "unknown") or "unknown"
|
||||
sub_state = properties.get("SubState", "unknown") or "unknown"
|
||||
unit_file_state = properties.get("UnitFileState", "unknown") or "unknown"
|
||||
active = load_state == "loaded" and active_state == "active"
|
||||
enabled = unit_file_state in {"enabled", "enabled-runtime"}
|
||||
|
||||
if load_state == "not-found":
|
||||
status = "not-found"
|
||||
elif active_state != "unknown":
|
||||
status = active_state
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"loadState": load_state,
|
||||
"activeState": active_state,
|
||||
"subState": sub_state,
|
||||
"unitFileState": unit_file_state,
|
||||
"active": active,
|
||||
"enabled": enabled,
|
||||
"status": "running" if active else "stopped",
|
||||
"healthy": active,
|
||||
"status": status,
|
||||
"checkedAt": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
"errorMessage": error_message,
|
||||
}
|
||||
|
||||
def _query(self, command: list[str]) -> str:
|
||||
try:
|
||||
return self.command_runner.run(command).stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
@staticmethod
|
||||
def _parse_properties(output: str) -> dict[str, str]:
|
||||
properties: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if separator and key:
|
||||
properties[key.strip()] = value.strip()
|
||||
return properties
|
||||
|
||||
@@ -18,6 +18,18 @@ from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||
from app.storage.repository import Repository, utc_now
|
||||
|
||||
|
||||
APT_SERVICE_POLICIES: dict[str, dict[str, Any]] = {
|
||||
"postgresql": {
|
||||
"managedServices": ["postgresql.service"],
|
||||
"readiness": "postgresql",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class InstalledComponentVerificationError(RuntimeError):
|
||||
"""The package changed on disk, but its required runtime verification failed."""
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(self, repository: Repository) -> None:
|
||||
self.repository = repository
|
||||
@@ -25,6 +37,7 @@ class TaskRunner:
|
||||
self.manifest_validator = ManifestValidator()
|
||||
|
||||
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
|
||||
manifest: dict[str, Any] | None = None
|
||||
try:
|
||||
self._mark_started(task_id, f"starting {task_type}")
|
||||
self._require_root_if_available()
|
||||
@@ -49,6 +62,9 @@ class TaskRunner:
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
|
||||
except InstalledComponentVerificationError as error:
|
||||
self._track_attention_install(task_id, manifest)
|
||||
self._fail_task(task_id, error)
|
||||
except Exception as error:
|
||||
self._fail_task(task_id, error)
|
||||
|
||||
@@ -332,30 +348,121 @@ class TaskRunner:
|
||||
installed_component = dict(component)
|
||||
installed_component["version"] = installed_version
|
||||
|
||||
if package_name == "postgresql":
|
||||
service_name = "postgresql.service"
|
||||
policy = APT_SERVICE_POLICIES.get(package_name, {})
|
||||
managed_services = list(policy.get("managedServices", []))
|
||||
discovered_services = installer.discover_service_units(package_name)
|
||||
service_names = list(dict.fromkeys([*managed_services, *discovered_services]))
|
||||
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
progress=80,
|
||||
current_step="checking package services",
|
||||
service_check_status="checking",
|
||||
service_checks=[],
|
||||
)
|
||||
|
||||
verification_errors: list[str] = []
|
||||
if managed_services:
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
progress=85,
|
||||
current_step="starting PostgreSQL service",
|
||||
current_step="starting managed package services",
|
||||
)
|
||||
services.enable_service(service_name)
|
||||
services.start_service(service_name)
|
||||
services.assert_service_active(service_name)
|
||||
for service_name in managed_services:
|
||||
try:
|
||||
services.enable_service(service_name)
|
||||
services.start_service(service_name)
|
||||
except Exception as error:
|
||||
verification_errors.append(f"Could not start {service_name}: {error}")
|
||||
|
||||
readiness_status: str | None = None
|
||||
if policy.get("readiness") == "postgresql":
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
progress=95,
|
||||
progress=90,
|
||||
current_step="checking PostgreSQL readiness",
|
||||
)
|
||||
installer.wait_for_postgresql()
|
||||
self.repository.add_log(task_id, "info", "PostgreSQL service is active and accepting connections")
|
||||
installed_component["serviceName"] = service_name
|
||||
try:
|
||||
installer.wait_for_postgresql()
|
||||
readiness_status = "ready"
|
||||
except Exception as error:
|
||||
readiness_status = "failed"
|
||||
verification_errors.append(f"PostgreSQL is not accepting connections: {error}")
|
||||
|
||||
service_checks: list[dict[str, Any]] = []
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
progress=95,
|
||||
current_step="reading service status",
|
||||
)
|
||||
for service_name in service_names:
|
||||
check = services.get_service_status(service_name)
|
||||
if service_name in managed_services and readiness_status is not None:
|
||||
check["readinessType"] = str(policy["readiness"])
|
||||
check["readinessStatus"] = readiness_status
|
||||
check["healthy"] = bool(check.get("healthy")) and readiness_status == "ready"
|
||||
service_checks.append(check)
|
||||
|
||||
if check.get("healthy"):
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"info",
|
||||
f"Service {service_name} is active ({check.get('subState', 'unknown')})",
|
||||
)
|
||||
else:
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"warning",
|
||||
f"Service {service_name} is not healthy "
|
||||
f"(load={check.get('loadState', 'unknown')}, "
|
||||
f"active={check.get('activeState', 'unknown')}, "
|
||||
f"sub={check.get('subState', 'unknown')})",
|
||||
)
|
||||
|
||||
if not service_names:
|
||||
service_check_status = "not-applicable"
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"info",
|
||||
f"APT package {package_name} does not expose a concrete systemd service unit",
|
||||
)
|
||||
elif all(bool(check.get("healthy")) for check in service_checks):
|
||||
service_check_status = "healthy"
|
||||
else:
|
||||
service_check_status = "unhealthy"
|
||||
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
progress=98,
|
||||
current_step="service checks completed",
|
||||
service_check_status=service_check_status,
|
||||
service_checks=service_checks,
|
||||
)
|
||||
installed_component["serviceCheckStatus"] = service_check_status
|
||||
installed_component["serviceChecks"] = service_checks
|
||||
if managed_services:
|
||||
installed_component["serviceName"] = managed_services[0]
|
||||
self.repository.upsert_installed_component(app_id, installed_component)
|
||||
|
||||
unhealthy_managed_services = [
|
||||
str(check.get("serviceName"))
|
||||
for check in service_checks
|
||||
if check.get("serviceName") in managed_services and not check.get("healthy")
|
||||
]
|
||||
if unhealthy_managed_services:
|
||||
verification_errors.append(
|
||||
f"Required service is not healthy: {', '.join(unhealthy_managed_services)}"
|
||||
)
|
||||
if verification_errors:
|
||||
raise InstalledComponentVerificationError(
|
||||
"; ".join(dict.fromkeys(verification_errors))
|
||||
)
|
||||
|
||||
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
|
||||
component_id = component["componentId"]
|
||||
container_name = component["containerName"]
|
||||
@@ -426,6 +533,33 @@ class TaskRunner:
|
||||
except Exception as error:
|
||||
self.repository.add_log(task_id, "warning", f"Could not {action}: {error}")
|
||||
|
||||
def _track_attention_install(self, task_id: str, manifest: dict[str, Any] | None) -> None:
|
||||
if not manifest:
|
||||
return
|
||||
try:
|
||||
manifest_hash = hashlib.sha256(
|
||||
self.repository.export_manifest_hash(manifest).encode("utf-8")
|
||||
).hexdigest()
|
||||
self.repository.upsert_installed_app(
|
||||
manifest["appId"],
|
||||
manifest["appName"],
|
||||
manifest["version"],
|
||||
manifest_hash,
|
||||
manifest.get("openUrl"),
|
||||
status="attention",
|
||||
)
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"warning",
|
||||
"The package was installed but its service needs attention; it remains available for status and removal",
|
||||
)
|
||||
except Exception as tracking_error:
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"warning",
|
||||
f"Could not persist installed package attention state: {tracking_error}",
|
||||
)
|
||||
|
||||
def _clean_cached_package_files(self, task_id: str, *identifiers: str | None) -> None:
|
||||
cache_dir = settings.cache_dir
|
||||
if not cache_dir.exists() or not cache_dir.is_dir():
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
@@ -55,6 +57,8 @@ CREATE TABLE IF NOT EXISTS installed_components (
|
||||
package_name TEXT,
|
||||
package_version TEXT,
|
||||
service_name TEXT,
|
||||
service_check_status TEXT DEFAULT 'not-checked',
|
||||
service_checks_json TEXT,
|
||||
docker_image TEXT,
|
||||
docker_digest TEXT,
|
||||
container_name TEXT,
|
||||
@@ -75,6 +79,8 @@ CREATE TABLE IF NOT EXISTS task_components (
|
||||
progress INTEGER DEFAULT 0,
|
||||
current_step TEXT,
|
||||
error_message TEXT,
|
||||
service_check_status TEXT DEFAULT 'not-checked',
|
||||
service_checks_json TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
@@ -96,10 +102,18 @@ def _ensure_column(connection: sqlite3.Connection, table: str, column: str, defi
|
||||
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
@contextmanager
|
||||
def get_connection() -> Iterator[sqlite3.Connection]:
|
||||
connection = sqlite3.connect(settings.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
@@ -110,3 +124,17 @@ def initialize_database() -> None:
|
||||
with get_connection() as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
_ensure_column(connection, "installed_apps", "open_url", "TEXT")
|
||||
_ensure_column(
|
||||
connection,
|
||||
"installed_components",
|
||||
"service_check_status",
|
||||
"TEXT DEFAULT 'not-checked'",
|
||||
)
|
||||
_ensure_column(connection, "installed_components", "service_checks_json", "TEXT")
|
||||
_ensure_column(
|
||||
connection,
|
||||
"task_components",
|
||||
"service_check_status",
|
||||
"TEXT DEFAULT 'not-checked'",
|
||||
)
|
||||
_ensure_column(connection, "task_components", "service_checks_json", "TEXT")
|
||||
|
||||
@@ -17,6 +17,29 @@ def row_to_dict(row: Any) -> dict[str, Any] | None:
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _encode_service_checks(value: list[dict[str, Any]]) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _decode_service_checks(value: Any) -> list[dict[str, Any]]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
decoded = json.loads(str(value))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(decoded, list):
|
||||
return []
|
||||
return [item for item in decoded if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _component_row(row: Any) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
item["service_checks"] = _decode_service_checks(item.pop("service_checks_json", None))
|
||||
item["service_check_status"] = item.get("service_check_status") or "not-checked"
|
||||
return item
|
||||
|
||||
|
||||
class Repository:
|
||||
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
|
||||
now = utc_now()
|
||||
@@ -118,6 +141,8 @@ class Repository:
|
||||
progress: int | None = None,
|
||||
current_step: str | None = None,
|
||||
error_message: str | None = None,
|
||||
service_check_status: str | None = None,
|
||||
service_checks: list[dict[str, Any]] | None = None,
|
||||
started_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
) -> None:
|
||||
@@ -128,6 +153,12 @@ class Repository:
|
||||
"progress": progress,
|
||||
"current_step": current_step,
|
||||
"error_message": error_message,
|
||||
"service_check_status": service_check_status,
|
||||
"service_checks_json": (
|
||||
_encode_service_checks(service_checks)
|
||||
if service_checks is not None
|
||||
else None
|
||||
),
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
}.items():
|
||||
@@ -147,14 +178,15 @@ class Repository:
|
||||
with get_connection() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT component_id, type, status, progress, current_step, error_message, started_at, finished_at
|
||||
SELECT component_id, type, status, progress, current_step, error_message,
|
||||
service_check_status, service_checks_json, started_at, finished_at
|
||||
FROM task_components
|
||||
WHERE task_id = ?
|
||||
ORDER BY install_order ASC, id ASC
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
return [_component_row(row) for row in rows]
|
||||
|
||||
def list_installed_apps(self) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
@@ -205,10 +237,11 @@ class Repository:
|
||||
"""
|
||||
INSERT INTO installed_components (
|
||||
app_id, component_id, type, install_order, status, package_name, package_version,
|
||||
service_name, docker_image, docker_digest, container_name, compose_project_name,
|
||||
service_name, service_check_status, service_checks_json,
|
||||
docker_image, docker_digest, container_name, compose_project_name,
|
||||
installed_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(app_id, component_id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
install_order = excluded.install_order,
|
||||
@@ -216,6 +249,8 @@ class Repository:
|
||||
package_name = excluded.package_name,
|
||||
package_version = excluded.package_version,
|
||||
service_name = excluded.service_name,
|
||||
service_check_status = excluded.service_check_status,
|
||||
service_checks_json = excluded.service_checks_json,
|
||||
docker_image = excluded.docker_image,
|
||||
docker_digest = excluded.docker_digest,
|
||||
container_name = excluded.container_name,
|
||||
@@ -230,6 +265,8 @@ class Repository:
|
||||
component.get("packageName"),
|
||||
component.get("version"),
|
||||
component.get("serviceName"),
|
||||
component.get("serviceCheckStatus", "not-checked"),
|
||||
_encode_service_checks(component.get("serviceChecks", [])),
|
||||
component.get("image"),
|
||||
component.get("digest"),
|
||||
component.get("containerName"),
|
||||
@@ -250,7 +287,7 @@ class Repository:
|
||||
""",
|
||||
(app_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
return [_component_row(row) for row in rows]
|
||||
|
||||
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
|
||||
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Package: local-installer-agent
|
||||
Version: 1.0.0
|
||||
Version: 1.0.4
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
@@ -7,4 +7,4 @@ Maintainer: Robot Team <admin@robot.package>
|
||||
Depends: python3, python3-venv, python3-pip, curl
|
||||
Description: Local Installer Agent for robot.installer
|
||||
A local background service that installs, updates, and removes trusted .deb,
|
||||
allowlisted APT, and Docker apps on the user's Linux machine.
|
||||
APT repository packages, and Docker apps on the user's Linux machine.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:-1.0.0}"
|
||||
VERSION="${VERSION:-1.0.4}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
AGENT_HOST="${AGENT_HOST:-0.0.0.0}"
|
||||
AGENT_PORT="${AGENT_PORT:-5010}"
|
||||
ALLOWED_APT_PACKAGES="${ALLOWED_APT_PACKAGES:-*}"
|
||||
DEB_COMPRESSION="${DEB_COMPRESSION:-gzip}"
|
||||
PKG_NAME="local-installer-agent"
|
||||
BUILD_ROOT="${BUILD_ROOT:-build}"
|
||||
@@ -21,7 +22,14 @@ if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "${BUILD_ROOT}"
|
||||
if [[ "$ALLOWED_APT_PACKAGES" != "*" && ! "$ALLOWED_APT_PACKAGES" =~ ^[a-zA-Z0-9._+-]+(,[a-zA-Z0-9._+-]+)*$ ]]; then
|
||||
echo "Invalid ALLOWED_APT_PACKAGES: ${ALLOWED_APT_PACKAGES}" >&2
|
||||
echo "Use * for any valid APT package or a comma-separated list without spaces." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "${BUILD_DIR}"
|
||||
rm -f "${OUTPUT_PACKAGE}"
|
||||
|
||||
mkdir -p "${BUILD_DIR}/opt/local-installer-agent"
|
||||
mkdir -p "${BUILD_DIR}/etc/local-installer-agent"
|
||||
@@ -60,7 +68,7 @@ AGENT_PORT=${AGENT_PORT}
|
||||
ROBOT_PACKAGE_BASE_URL=https://package.pnkr.cloud
|
||||
ALLOWED_ORIGINS=https://app.pnkr.cloud,https://package.pnkr.cloud,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080
|
||||
ALLOWED_DOWNLOAD_HOSTS=package.pnkr.cloud
|
||||
ALLOWED_APT_PACKAGES=postgresql
|
||||
ALLOWED_APT_PACKAGES=${ALLOWED_APT_PACKAGES}
|
||||
ALLOWED_DOCKER_REGISTRIES=registry.robot.package,docker.io
|
||||
CACHE_DIR=/var/cache/local-installer-agent/packages
|
||||
APP_DIR=/opt/robot-apps
|
||||
|
||||
@@ -5,9 +5,11 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core.command_runner import CommandError
|
||||
from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller
|
||||
from app.core.manifest_validator import ManifestValidator
|
||||
from app.core.task_runner import TaskRunner
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.core.task_runner import InstalledComponentVerificationError, TaskRunner
|
||||
|
||||
|
||||
def apt_manifest(component: dict | None = None) -> dict:
|
||||
@@ -37,12 +39,28 @@ class FakeCommandRunner:
|
||||
command: list[str],
|
||||
timeout: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.calls.append((command, timeout, env))
|
||||
return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="")
|
||||
|
||||
|
||||
class AptManifestTests(unittest.TestCase):
|
||||
def test_wildcard_accepts_any_valid_apt_package_name(self) -> None:
|
||||
validator_settings = SimpleNamespace(allowed_apt_packages=["*"])
|
||||
with patch("app.core.manifest_validator.settings", validator_settings):
|
||||
manifest = ManifestValidator().validate(
|
||||
apt_manifest(
|
||||
{
|
||||
"componentId": "nginx-extras",
|
||||
"type": "apt",
|
||||
"packageName": "nginx-extras",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["components"][0]["packageName"], "nginx-extras")
|
||||
|
||||
def test_postgresql_is_accepted_when_allowlisted(self) -> None:
|
||||
validator_settings = SimpleNamespace(allowed_apt_packages=["postgresql"])
|
||||
with patch("app.core.manifest_validator.settings", validator_settings):
|
||||
@@ -51,6 +69,23 @@ class AptManifestTests(unittest.TestCase):
|
||||
self.assertEqual(manifest["components"][0]["type"], "apt")
|
||||
self.assertEqual(manifest["components"][0]["packageName"], "postgresql")
|
||||
|
||||
def test_any_package_in_a_multi_package_allowlist_is_accepted(self) -> None:
|
||||
validator_settings = SimpleNamespace(
|
||||
allowed_apt_packages=["postgresql", "nginx", "redis-server"]
|
||||
)
|
||||
with patch("app.core.manifest_validator.settings", validator_settings):
|
||||
manifest = ManifestValidator().validate(
|
||||
apt_manifest(
|
||||
{
|
||||
"componentId": "redis-server",
|
||||
"type": "apt",
|
||||
"packageName": "redis-server",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["components"][0]["packageName"], "redis-server")
|
||||
|
||||
def test_non_allowlisted_package_is_rejected(self) -> None:
|
||||
validator_settings = SimpleNamespace(allowed_apt_packages=["postgresql"])
|
||||
with patch("app.core.manifest_validator.settings", validator_settings):
|
||||
@@ -89,6 +124,7 @@ class AptInstallerTests(unittest.TestCase):
|
||||
installer.update_package_index()
|
||||
installer.install_package("postgresql")
|
||||
installer.wait_for_postgresql(attempts=1, delay_seconds=0)
|
||||
self.assertTrue(installer.is_postgresql_ready())
|
||||
|
||||
self.assertEqual(runner.calls[0][0], ["apt-get", "update"])
|
||||
self.assertEqual(
|
||||
@@ -108,6 +144,78 @@ class AptInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV)
|
||||
self.assertEqual(runner.calls[2][0], ["pg_isready", "--timeout=5"])
|
||||
self.assertEqual(runner.calls[3][0], ["pg_isready", "--timeout=5"])
|
||||
|
||||
def test_discovers_concrete_service_units_without_shell_commands(self) -> None:
|
||||
class PackageFileRunner(FakeCommandRunner):
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
timeout: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.calls.append((command, timeout, env))
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"/usr/lib/systemd/system/example.service\n"
|
||||
"/lib/systemd/system/example-worker.service\n"
|
||||
"/lib/systemd/system/example@.service\n"
|
||||
"/usr/share/doc/example/README\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
runner = PackageFileRunner()
|
||||
services = AptInstaller(runner).discover_service_units("example")
|
||||
|
||||
self.assertEqual(services, ["example-worker.service", "example.service"])
|
||||
self.assertEqual(runner.calls[0][0], ["dpkg-query", "-L", "example"])
|
||||
|
||||
|
||||
class ServiceManagerTests(unittest.TestCase):
|
||||
def test_reports_full_active_service_state(self) -> None:
|
||||
class StatusRunner:
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"LoadState=loaded\n"
|
||||
"ActiveState=active\n"
|
||||
"SubState=running\n"
|
||||
"UnitFileState=enabled\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
status = ServiceManager(StatusRunner()).get_service_status("example.service")
|
||||
|
||||
self.assertTrue(status["active"])
|
||||
self.assertTrue(status["enabled"])
|
||||
self.assertTrue(status["healthy"])
|
||||
self.assertEqual(status["status"], "active")
|
||||
self.assertEqual(status["subState"], "running")
|
||||
|
||||
def test_preserves_inactive_state_from_nonzero_systemctl_result(self) -> None:
|
||||
class InactiveRunner:
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
stdout = (
|
||||
"LoadState=loaded\n"
|
||||
"ActiveState=inactive\n"
|
||||
"SubState=dead\n"
|
||||
"UnitFileState=disabled\n"
|
||||
)
|
||||
raise CommandError(command, 3, stdout, "")
|
||||
|
||||
status = ServiceManager(InactiveRunner()).get_service_status("example.service")
|
||||
|
||||
self.assertFalse(status["active"])
|
||||
self.assertFalse(status["healthy"])
|
||||
self.assertEqual(status["activeState"], "inactive")
|
||||
self.assertEqual(status["unitFileState"], "disabled")
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
@@ -126,6 +234,42 @@ class FakeRepository:
|
||||
self.installed_component = component
|
||||
|
||||
|
||||
class FakeRunRepository:
|
||||
def __init__(self) -> None:
|
||||
self.task = {"current_component_id": "postgresql"}
|
||||
self.task_updates: list[dict] = []
|
||||
self.logs: list[tuple[str, str]] = []
|
||||
self.installed_app_updates: list[dict] = []
|
||||
|
||||
def update_task(self, task_id: str, **fields: object) -> None:
|
||||
self.task_updates.append(dict(fields))
|
||||
|
||||
def update_task_component(self, task_id: str, component_id: str, **fields: object) -> None:
|
||||
return None
|
||||
|
||||
def add_log(self, task_id: str, level: str, message: str) -> None:
|
||||
self.logs.append((level, message))
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
return self.task
|
||||
|
||||
def export_manifest_hash(self, manifest: dict) -> str:
|
||||
return "manifest"
|
||||
|
||||
def upsert_installed_app(
|
||||
self,
|
||||
app_id: str,
|
||||
app_name: str,
|
||||
version: str,
|
||||
manifest_hash: str,
|
||||
open_url: str | None,
|
||||
status: str = "installed",
|
||||
) -> None:
|
||||
self.installed_app_updates.append(
|
||||
{"app_id": app_id, "version": version, "status": status}
|
||||
)
|
||||
|
||||
|
||||
class FakeAptInstaller:
|
||||
actions: list[str] = []
|
||||
|
||||
@@ -145,6 +289,10 @@ class FakeAptInstaller:
|
||||
def wait_for_postgresql(self) -> None:
|
||||
self.actions.append("pg_isready")
|
||||
|
||||
def discover_service_units(self, package_name: str) -> list[str]:
|
||||
self.actions.append(f"discover:{package_name}")
|
||||
return []
|
||||
|
||||
|
||||
class FakeServiceManager:
|
||||
actions: list[str] = []
|
||||
@@ -161,8 +309,61 @@ class FakeServiceManager:
|
||||
def assert_service_active(self, service_name: str) -> None:
|
||||
self.actions.append(f"active:{service_name}")
|
||||
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
self.actions.append(f"status:{service_name}")
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"loadState": "loaded",
|
||||
"activeState": "active",
|
||||
"subState": "running",
|
||||
"unitFileState": "enabled",
|
||||
"active": True,
|
||||
"enabled": True,
|
||||
"healthy": True,
|
||||
"status": "active",
|
||||
"checkedAt": "2026-07-20T00:00:00Z",
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
|
||||
class AptTaskRunnerTests(unittest.TestCase):
|
||||
def test_only_post_install_verification_failure_marks_app_attention(self) -> None:
|
||||
manifest = apt_manifest()
|
||||
repository = FakeRunRepository()
|
||||
runner = TaskRunner(repository)
|
||||
|
||||
with (
|
||||
patch.object(runner, "_require_root_if_available"),
|
||||
patch.object(runner, "_resolve_manifest", return_value=manifest),
|
||||
patch.object(
|
||||
runner,
|
||||
"_install_manifest",
|
||||
side_effect=InstalledComponentVerificationError("service unhealthy"),
|
||||
),
|
||||
):
|
||||
runner.run_install("task-attention", SimpleNamespace(), "install")
|
||||
|
||||
self.assertEqual(
|
||||
repository.installed_app_updates,
|
||||
[{"app_id": "postgresql", "version": "16", "status": "attention"}],
|
||||
)
|
||||
self.assertEqual(repository.task_updates[-1]["status"], "failed")
|
||||
|
||||
def test_early_install_failure_does_not_overwrite_installed_app_version(self) -> None:
|
||||
manifest = apt_manifest()
|
||||
repository = FakeRunRepository()
|
||||
runner = TaskRunner(repository)
|
||||
|
||||
with (
|
||||
patch.object(runner, "_require_root_if_available"),
|
||||
patch.object(runner, "_resolve_manifest", return_value=manifest),
|
||||
patch.object(runner, "_install_manifest", side_effect=RuntimeError("apt update failed")),
|
||||
):
|
||||
runner.run_install("task-early-failure", SimpleNamespace(), "update")
|
||||
|
||||
self.assertEqual(repository.installed_app_updates, [])
|
||||
self.assertEqual(repository.task_updates[-1]["status"], "failed")
|
||||
|
||||
def test_postgresql_install_verifies_service_and_readiness(self) -> None:
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
@@ -186,19 +387,101 @@ class AptTaskRunnerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
FakeAptInstaller.actions,
|
||||
["update", "install:postgresql", "version:postgresql", "pg_isready"],
|
||||
[
|
||||
"update",
|
||||
"install:postgresql",
|
||||
"version:postgresql",
|
||||
"discover:postgresql",
|
||||
"pg_isready",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
FakeServiceManager.actions,
|
||||
[
|
||||
"enable:postgresql.service",
|
||||
"start:postgresql.service",
|
||||
"active:postgresql.service",
|
||||
"status:postgresql.service",
|
||||
],
|
||||
)
|
||||
self.assertEqual(repository.installed_component["version"], "16+257build1")
|
||||
self.assertEqual(repository.installed_component["serviceName"], "postgresql.service")
|
||||
self.assertEqual(repository.component_updates[-1]["current_step"], "checking PostgreSQL readiness")
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "healthy")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessType"], "postgresql")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessStatus"], "ready")
|
||||
self.assertEqual(repository.component_updates[-1]["service_check_status"], "healthy")
|
||||
self.assertEqual(repository.component_updates[-1]["current_step"], "service checks completed")
|
||||
|
||||
def test_generic_apt_service_is_checked_but_not_started_automatically(self) -> None:
|
||||
class GenericAptInstaller(FakeAptInstaller):
|
||||
def discover_service_units(self, package_name: str) -> list[str]:
|
||||
self.actions.append(f"discover:{package_name}")
|
||||
return ["example.service"]
|
||||
|
||||
class InactiveServiceManager(FakeServiceManager):
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
self.actions.append(f"status:{service_name}")
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"loadState": "loaded",
|
||||
"activeState": "inactive",
|
||||
"subState": "dead",
|
||||
"unitFileState": "disabled",
|
||||
"active": False,
|
||||
"enabled": False,
|
||||
"healthy": False,
|
||||
"status": "inactive",
|
||||
"checkedAt": "2026-07-20T00:00:00Z",
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
FakeServiceManager.actions = []
|
||||
|
||||
with (
|
||||
patch("app.core.task_runner.CommandRunner", return_value=object()),
|
||||
patch("app.core.task_runner.AptInstaller", GenericAptInstaller),
|
||||
patch("app.core.task_runner.ServiceManager", InactiveServiceManager),
|
||||
):
|
||||
TaskRunner(repository)._install_apt_component(
|
||||
"task-2",
|
||||
"example-app",
|
||||
{
|
||||
"componentId": "example",
|
||||
"type": "apt",
|
||||
"packageName": "example",
|
||||
"version": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertNotIn("start:example.service", FakeServiceManager.actions)
|
||||
self.assertNotIn("enable:example.service", FakeServiceManager.actions)
|
||||
self.assertIn("status:example.service", FakeServiceManager.actions)
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "unhealthy")
|
||||
|
||||
def test_apt_package_without_service_is_marked_not_applicable(self) -> None:
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
FakeServiceManager.actions = []
|
||||
|
||||
with (
|
||||
patch("app.core.task_runner.CommandRunner", return_value=object()),
|
||||
patch("app.core.task_runner.AptInstaller", FakeAptInstaller),
|
||||
patch("app.core.task_runner.ServiceManager", FakeServiceManager),
|
||||
):
|
||||
TaskRunner(repository)._install_apt_component(
|
||||
"task-3",
|
||||
"utility-app",
|
||||
{
|
||||
"componentId": "utility",
|
||||
"type": "apt",
|
||||
"packageName": "utility",
|
||||
"version": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "not-applicable")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
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()
|
||||
BIN
web-client/image/logo_PNKX.png
Normal file
BIN
web-client/image/logo_PNKX.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -4,13 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png" />
|
||||
<title>Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
323
web-client/package-lock.json
generated
323
web-client/package-lock.json
generated
@@ -8,21 +8,21 @@
|
||||
"name": "robot-installer-web-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fluentui/react-icons": "^2.0.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -31,29 +31,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -70,13 +70,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -86,13 +86,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -102,36 +102,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -150,52 +150,52 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -234,32 +234,41 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -267,18 +276,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
@@ -695,6 +710,64 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluentui/react-icons": {
|
||||
"version": "2.0.333",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.333.tgz",
|
||||
"integrity": "sha512-HvS5kKw9tGweI3Poy6OGAiFqrt6HGElO46DFhNBeoIQRK/q35mZ2ep0u762MFuvOfLX3bVGSB8frORFTjh1E7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@griffel/react": "^1.6.1",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/montserrat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
|
||||
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/core": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.21.3.tgz",
|
||||
"integrity": "sha512-FMnlwhtmCRWvXEg2j/6W90wzvW+PFqdrsWFslfmxwS6l9X73gO0dnndKphht9ZOsJODvmLdqlnU1Lh8igg2mKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.0",
|
||||
"@griffel/style-types": "^1.4.2",
|
||||
"csstype": "^3.2.3",
|
||||
"rtl-css-js": "^1.16.1",
|
||||
"stylis": "^4.4.0",
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/react": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.7.6.tgz",
|
||||
"integrity": "sha512-hqkbKRfSN/jKvPFzMoaA+gzoA9INfDyDqt7ypHOJP3KjZWp+n5f/QxLpmvqE3ssqDDS7YsFKN6qPmYBRC2SA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@griffel/core": "^1.21.3",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/style-types": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.2.tgz",
|
||||
"integrity": "sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1178,9 +1251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz",
|
||||
"integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -1190,9 +1263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1209,10 +1282,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1223,9 +1296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1248,6 +1321,12 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1266,9 +1345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.361",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
|
||||
"integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
|
||||
"version": "1.5.395",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
|
||||
"integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@@ -1400,15 +1479,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.468.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
||||
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1434,9 +1504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1562,6 +1632,15 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-css-js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
||||
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -1586,6 +1665,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
|
||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -1602,6 +1687,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -1633,9 +1724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
"type": "module",
|
||||
"description": "Public web client for installing Robot applications through the Local Installer Agent.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-icons": "^2.0.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
Box,
|
||||
CheckCircle2,
|
||||
Clipboard,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
PackageCheck,
|
||||
Play,
|
||||
PlugZap,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
WifiOff,
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
ArrowCounterclockwise20Regular as RotateCcw,
|
||||
ArrowDownload20Regular as Download,
|
||||
ArrowSync20Regular as RefreshCcw,
|
||||
Board20Regular as Cpu,
|
||||
Box20Regular as Box,
|
||||
CheckmarkCircle20Filled as CheckCircle2,
|
||||
Clipboard20Regular as Clipboard,
|
||||
Delete20Regular as Trash2,
|
||||
Dismiss20Regular as X,
|
||||
ErrorCircle20Filled as XCircle,
|
||||
HardDrive20Regular as HardDrive,
|
||||
Open20Regular as ExternalLink,
|
||||
Play20Filled as Play,
|
||||
PlugConnected20Regular as PlugZap,
|
||||
Pulse20Regular as Activity,
|
||||
Search20Regular as Search,
|
||||
Server20Regular as Server,
|
||||
Settings20Regular as Settings,
|
||||
ShieldCheckmark20Filled as ShieldCheck,
|
||||
SpinnerIos20Regular as Loader2,
|
||||
Warning20Filled as AlertCircle,
|
||||
WifiOff20Regular as WifiOff
|
||||
} from '@fluentui/react-icons';
|
||||
import '@fontsource-variable/montserrat';
|
||||
import {
|
||||
DEFAULT_AGENT_BASE_URL,
|
||||
DEFAULT_PACKAGE_BASE_URL,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
queueRemove,
|
||||
queueUpdate
|
||||
} from './services/api.js';
|
||||
import logoPnkx from '../image/logo_PNKX.png';
|
||||
import './styles.css';
|
||||
|
||||
const SETTINGS_KEY = 'robot-installer-client-settings';
|
||||
@@ -121,6 +122,36 @@ function statusBadgeClass(status) {
|
||||
return `badge badge-${tone}`;
|
||||
}
|
||||
|
||||
function serviceCheckTone(check) {
|
||||
if (check?.healthy) return 'success';
|
||||
if (check?.activeState === 'activating' || check?.status === 'checking') return 'warning';
|
||||
if (['failed', 'inactive', 'not-found'].includes(check?.status || check?.activeState)) return 'danger';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function serviceCheckLabel(check) {
|
||||
if (check?.readinessStatus === 'failed') return 'Not ready';
|
||||
if (check?.healthy) return check.readinessStatus === 'ready' ? 'Active · Ready' : 'Active';
|
||||
if (check?.loadState === 'not-found' || check?.status === 'not-found') return 'Not found';
|
||||
if (check?.activeState === 'failed') return 'Failed';
|
||||
if (check?.activeState === 'inactive') return 'Inactive';
|
||||
if (check?.activeState === 'activating') return 'Activating';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function serviceCheckSummary(status, checks = []) {
|
||||
if (status === 'checking') return 'Checking services';
|
||||
if (status === 'not-applicable') return 'No systemd service';
|
||||
if (status === 'unhealthy') {
|
||||
const count = checks.filter((check) => !check.healthy).length || checks.length;
|
||||
return `${count} service${count === 1 ? '' : 's'} need attention`;
|
||||
}
|
||||
if (status === 'healthy') {
|
||||
return `${checks.length} service${checks.length === 1 ? '' : 's'} active`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatTaskTime(value) {
|
||||
if (!value) return '--';
|
||||
const parsed = new Date(value);
|
||||
@@ -218,6 +249,7 @@ function App() {
|
||||
const [draftSettings, setDraftSettings] = useState(settings);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [installedApps, setInstalledApps] = useState([]);
|
||||
const [installedAppsReady, setInstalledAppsReady] = useState(false);
|
||||
const [latestAgentPackage, setLatestAgentPackage] = useState(null);
|
||||
const [agentHealth, setAgentHealth] = useState(null);
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
@@ -234,9 +266,13 @@ function App() {
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||
const agentEndpointRef = useRef(settings.agentBaseUrl);
|
||||
const agentEndpointGenerationRef = useRef(0);
|
||||
const agentRefreshGenerationRef = useRef(0);
|
||||
|
||||
const packageBaseUrl = settings.packageBaseUrl;
|
||||
const agentBaseUrl = settings.agentBaseUrl;
|
||||
agentEndpointRef.current = agentBaseUrl;
|
||||
const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`;
|
||||
const agentCommand = latestAgentPackage?.installCommand || installCommand;
|
||||
const clientOs = useMemo(() => detectClientOs(), []);
|
||||
@@ -260,12 +296,14 @@ function App() {
|
||||
const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode);
|
||||
const isInstalled = Boolean(installed);
|
||||
const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version);
|
||||
const needsAttention = Boolean(isInstalled && installed.status === 'attention');
|
||||
|
||||
return {
|
||||
...app,
|
||||
installed,
|
||||
localStatus: canUpdate ? 'update' : (isInstalled ? 'installed' : 'available'),
|
||||
canUpdate
|
||||
canUpdate,
|
||||
needsAttention
|
||||
};
|
||||
});
|
||||
}, [apps, installedByAppId]);
|
||||
@@ -326,10 +364,21 @@ function App() {
|
||||
}, [packageBaseUrl]);
|
||||
|
||||
const refreshAgent = useCallback(async () => {
|
||||
const requestEndpoint = agentBaseUrl;
|
||||
if (agentEndpointRef.current !== requestEndpoint) return false;
|
||||
|
||||
const requestGeneration = ++agentRefreshGenerationRef.current;
|
||||
const isCurrentRequest = () => (
|
||||
agentEndpointRef.current === requestEndpoint
|
||||
&& agentRefreshGenerationRef.current === requestGeneration
|
||||
);
|
||||
|
||||
if (!canUseAgentEndpoint) {
|
||||
if (!isCurrentRequest()) return false;
|
||||
setAgentHealth(null);
|
||||
setSystemInfo(null);
|
||||
setInstalledApps([]);
|
||||
setInstalledAppsReady(false);
|
||||
setAgentStatus({
|
||||
state: 'warning',
|
||||
message: isClientWindows
|
||||
@@ -340,22 +389,37 @@ function App() {
|
||||
}
|
||||
|
||||
setAgentStatus({ state: 'loading', message: `Checking ${agentTargetLabel}` });
|
||||
setInstalledAppsReady(false);
|
||||
try {
|
||||
const health = await fetchAgentHealth(agentBaseUrl);
|
||||
const health = await fetchAgentHealth(requestEndpoint);
|
||||
if (!isCurrentRequest()) return false;
|
||||
setAgentHealth(health);
|
||||
setAgentStatus({ state: 'success', message: `${health.hostname || agentTargetLabel} online` });
|
||||
|
||||
const [info, installed] = await Promise.all([
|
||||
fetchAgentSystemInfo(agentBaseUrl).catch(() => null),
|
||||
fetchInstalledApps(agentBaseUrl)
|
||||
const [infoResult, installedResult] = await Promise.allSettled([
|
||||
fetchAgentSystemInfo(requestEndpoint),
|
||||
fetchInstalledApps(requestEndpoint)
|
||||
]);
|
||||
setSystemInfo(info);
|
||||
setInstalledApps(installed);
|
||||
if (!isCurrentRequest()) return false;
|
||||
setSystemInfo(infoResult.status === 'fulfilled' ? infoResult.value : null);
|
||||
if (installedResult.status === 'fulfilled') {
|
||||
setInstalledApps(installedResult.value);
|
||||
setInstalledAppsReady(true);
|
||||
} else {
|
||||
setInstalledApps([]);
|
||||
setInstalledAppsReady(false);
|
||||
setAgentStatus({
|
||||
state: 'warning',
|
||||
message: `${health.hostname || agentTargetLabel} online · installed app status unavailable: ${getErrorMessage(installedResult.reason)}`
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest()) return false;
|
||||
setAgentHealth(null);
|
||||
setSystemInfo(null);
|
||||
setInstalledApps([]);
|
||||
setInstalledAppsReady(false);
|
||||
setAgentStatus({ state: 'danger', message: getErrorMessage(error) });
|
||||
return false;
|
||||
}
|
||||
@@ -392,13 +456,13 @@ function App() {
|
||||
try {
|
||||
const [nextTask, logs, components] = await Promise.all([
|
||||
fetchTaskStatus(agentBaseUrl, taskId),
|
||||
fetchTaskLogs(agentBaseUrl, taskId).catch(() => []),
|
||||
fetchTaskComponents(agentBaseUrl, taskId).catch(() => [])
|
||||
fetchTaskLogs(agentBaseUrl, taskId).catch(() => null),
|
||||
fetchTaskComponents(agentBaseUrl, taskId).catch(() => null)
|
||||
]);
|
||||
const snapshot = {
|
||||
...nextTask,
|
||||
logs,
|
||||
components,
|
||||
...(logs ? { logs } : {}),
|
||||
...(components ? { components } : {}),
|
||||
pollError: ''
|
||||
};
|
||||
|
||||
@@ -483,31 +547,54 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedAppsReady) {
|
||||
notify('warning', 'Installed app status is not ready. Refresh the Agent before running an action.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'remove' && !window.confirm(`Remove and clean ${app.appName} from ${agentTargetMachine}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionEndpoint = agentBaseUrl;
|
||||
const endpointGeneration = agentEndpointGenerationRef.current;
|
||||
const isCurrentEndpoint = () => (
|
||||
agentEndpointRef.current === actionEndpoint
|
||||
&& agentEndpointGenerationRef.current === endpointGeneration
|
||||
);
|
||||
if (!isCurrentEndpoint()) {
|
||||
notify('warning', 'Agent endpoint changed. Retry the action on the current Agent.');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${action}:${app.appId}`;
|
||||
setBusyAction(key);
|
||||
try {
|
||||
let queuedTask;
|
||||
if (action === 'install') {
|
||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||
if (!isCurrentEndpoint()) return;
|
||||
setSelectedManifest(manifest);
|
||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||
queuedTask = await queueInstall(agentBaseUrl, app);
|
||||
queuedTask = await queueInstall(actionEndpoint, app);
|
||||
} else if (action === 'update') {
|
||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||
if (!isCurrentEndpoint()) return;
|
||||
setSelectedManifest(manifest);
|
||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||
queuedTask = await queueUpdate(agentBaseUrl, app, app.installed);
|
||||
queuedTask = await queueUpdate(actionEndpoint, app, app.installed);
|
||||
} else {
|
||||
queuedTask = await queueRemove(agentBaseUrl, app);
|
||||
queuedTask = await queueRemove(actionEndpoint, app);
|
||||
}
|
||||
|
||||
if (!isCurrentEndpoint()) {
|
||||
notify('warning', 'Agent endpoint changed while the request was running. Switch back to monitor the previous Agent.');
|
||||
return;
|
||||
}
|
||||
startTask(queuedTask, action, app);
|
||||
notify('success', `Đã queue task ${queuedTask.taskId}`);
|
||||
} catch (error) {
|
||||
if (!isCurrentEndpoint()) return;
|
||||
const message = getErrorMessage(error);
|
||||
if (action === 'install' || action === 'update') {
|
||||
setSelectedManifest(null);
|
||||
@@ -516,9 +603,9 @@ function App() {
|
||||
}
|
||||
notify('failure', message);
|
||||
} finally {
|
||||
setBusyAction('');
|
||||
setBusyAction((current) => current === key ? '' : current);
|
||||
}
|
||||
}, [agentBaseUrl, agentHealth, agentTargetLabel, agentTargetMachine, canManageApps, notify, packageBaseUrl, startPreflightFailedTask, startTask]);
|
||||
}, [agentBaseUrl, agentHealth, agentTargetLabel, agentTargetMachine, canManageApps, installedAppsReady, notify, packageBaseUrl, startPreflightFailedTask, startTask]);
|
||||
|
||||
const openEndpointDialog = useCallback(() => {
|
||||
setDraftSettings(settings);
|
||||
@@ -535,12 +622,30 @@ function App() {
|
||||
packageBaseUrl: normalizeUrl(draftSettings.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL),
|
||||
agentBaseUrl: normalizeUrl(draftSettings.agentBaseUrl || DEFAULT_AGENT_BASE_URL)
|
||||
};
|
||||
const endpointsChanged = (
|
||||
nextSettings.packageBaseUrl !== settings.packageBaseUrl
|
||||
|| nextSettings.agentBaseUrl !== settings.agentBaseUrl
|
||||
);
|
||||
if (busyAction && endpointsChanged) {
|
||||
notify('warning', 'Wait for the current Agent request to finish before changing endpoints.');
|
||||
return;
|
||||
}
|
||||
if (nextSettings.agentBaseUrl !== settings.agentBaseUrl) {
|
||||
agentEndpointRef.current = nextSettings.agentBaseUrl;
|
||||
agentEndpointGenerationRef.current += 1;
|
||||
agentRefreshGenerationRef.current += 1;
|
||||
setAgentHealth(null);
|
||||
setSystemInfo(null);
|
||||
setInstalledApps([]);
|
||||
setInstalledAppsReady(false);
|
||||
setActiveTask(null);
|
||||
}
|
||||
setSettings(nextSettings);
|
||||
setDraftSettings(nextSettings);
|
||||
saveSettings(nextSettings);
|
||||
setEndpointDialogOpen(false);
|
||||
notify('info', 'Đã cập nhật endpoint test');
|
||||
}, [draftSettings, notify]);
|
||||
}, [busyAction, draftSettings, notify, settings.agentBaseUrl, settings.packageBaseUrl]);
|
||||
|
||||
const copyInstallCommand = useCallback(async () => {
|
||||
if (!canShowAgentCommand) {
|
||||
@@ -651,7 +756,7 @@ function App() {
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-mark">
|
||||
<PackageCheck size={20} aria-hidden="true" />
|
||||
<img className="brand-logo" src={logoPnkx} alt="" />
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
@@ -686,7 +791,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary full" type="button" onClick={openEndpointDialog}>
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
<Settings fontSize={15} aria-hidden="true" />
|
||||
Endpoint settings
|
||||
</button>
|
||||
</div>
|
||||
@@ -706,10 +811,10 @@ function App() {
|
||||
tone={agentStatusTone}
|
||||
/>
|
||||
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
|
||||
<ExternalLink size={17} aria-hidden="true" />
|
||||
<ExternalLink fontSize={17} aria-hidden="true" />
|
||||
</a>
|
||||
<button className="btn btn-secondary" type="button" onClick={refreshAll}>
|
||||
<RefreshCcw size={15} aria-hidden="true" />
|
||||
<RefreshCcw fontSize={15} aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -728,13 +833,13 @@ function App() {
|
||||
|
||||
{canUseAgentEndpoint && !agentHealth && (
|
||||
<div className="offline-banner">
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{agentTargetLabel} is offline</strong>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -742,13 +847,13 @@ function App() {
|
||||
|
||||
{canUseAgentEndpoint && agentNeedsUpdate && (
|
||||
<div className="offline-banner agent-update-banner">
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Agent {latestAgentPackage.version} is ready</strong>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -760,7 +865,7 @@ function App() {
|
||||
<label className="filter-field wide">
|
||||
<span>Search</span>
|
||||
<div className="input-with-icon">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<Search fontSize={15} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -842,27 +947,41 @@ function App() {
|
||||
<button
|
||||
className="btn btn-primary compact"
|
||||
type="button"
|
||||
disabled={!agentHealth || rowTaskBusy || installBusy}
|
||||
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || installBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('install', app);
|
||||
}}
|
||||
>
|
||||
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
|
||||
{installBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <Play fontSize={14} aria-hidden="true" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
{canManageApps && app.installed && app.canUpdate && (
|
||||
{canManageApps && app.installed && app.needsAttention && (
|
||||
<button
|
||||
className="btn btn-warning compact"
|
||||
type="button"
|
||||
disabled={!agentHealth || rowTaskBusy || updateBusy}
|
||||
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || updateBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('update', app);
|
||||
}}
|
||||
>
|
||||
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
|
||||
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{canManageApps && app.installed && app.canUpdate && !app.needsAttention && (
|
||||
<button
|
||||
className="btn btn-warning compact"
|
||||
type="button"
|
||||
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || updateBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('update', app);
|
||||
}}
|
||||
>
|
||||
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
@@ -875,7 +994,7 @@ function App() {
|
||||
title={`Open ${app.appName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
<ExternalLink fontSize={14} aria-hidden="true" />
|
||||
Open App
|
||||
</a>
|
||||
)}
|
||||
@@ -884,13 +1003,13 @@ function App() {
|
||||
className="icon-button danger"
|
||||
type="button"
|
||||
title="Remove"
|
||||
disabled={!agentHealth || rowTaskBusy || removeBusy}
|
||||
disabled={!agentHealth || !installedAppsReady || rowTaskBusy || removeBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('remove', app);
|
||||
}}
|
||||
>
|
||||
{removeBusy ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
|
||||
{removeBusy ? <Loader2 className="spin" fontSize={16} aria-hidden="true" /> : <Trash2 fontSize={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -966,7 +1085,7 @@ function AgentStatusButton({ title, detail, tone, onClick }) {
|
||||
return (
|
||||
<button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}>
|
||||
<span className="agent-status-icon">
|
||||
<Icon className={tone === 'info' ? 'spin' : ''} size={16} aria-hidden="true" />
|
||||
<Icon className={tone === 'info' ? 'spin' : ''} fontSize={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="agent-status-copy">
|
||||
<strong>{title}</strong>
|
||||
@@ -1012,7 +1131,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
<p>Changes only apply after you press Apply.</p>
|
||||
</div>
|
||||
<button className="icon-button subtle" type="button" title="Close" onClick={onCancel}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
<X fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1045,7 +1164,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
<Settings fontSize={15} aria-hidden="true" />
|
||||
Apply endpoints
|
||||
</button>
|
||||
</div>
|
||||
@@ -1058,7 +1177,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
function StatusBox({ icon: Icon, title, detail, tone }) {
|
||||
return (
|
||||
<div className={`status-box tone-${tone || 'muted'}`}>
|
||||
<span className="status-icon"><Icon size={16} aria-hidden="true" /></span>
|
||||
<span className="status-icon"><Icon fontSize={16} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{detail}</span>
|
||||
@@ -1070,7 +1189,7 @@ function StatusBox({ icon: Icon, title, detail, tone }) {
|
||||
function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
|
||||
return (
|
||||
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{isWindows ? 'Remote Ubuntu endpoint needed' : 'Local Agent requires Linux'}</strong>
|
||||
<p>
|
||||
@@ -1082,7 +1201,7 @@ function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand
|
||||
</div>
|
||||
{canShowCommand && (
|
||||
<button className="btn btn-secondary" type="button" onClick={onCopyCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
@@ -1102,20 +1221,28 @@ function LocalStatus({ app, task }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (app.localStatus === 'update') {
|
||||
return (
|
||||
<span className="status-inline">
|
||||
<span className="badge badge-warning">Update</span>
|
||||
<small>{app.installed.version}</small>
|
||||
</span>
|
||||
if (app.installed) {
|
||||
const serviceSummary = serviceCheckSummary(
|
||||
app.installed.serviceCheckStatus,
|
||||
app.installed.serviceChecks
|
||||
);
|
||||
}
|
||||
|
||||
if (app.localStatus === 'installed') {
|
||||
const statusLabel = app.needsAttention
|
||||
? 'Attention'
|
||||
: app.localStatus === 'update'
|
||||
? 'Update'
|
||||
: 'Installed';
|
||||
const statusTone = app.needsAttention || app.localStatus === 'update' ? 'warning' : 'success';
|
||||
return (
|
||||
<span className="status-inline">
|
||||
<span className="badge badge-success">Installed</span>
|
||||
<small>{app.installed.version}</small>
|
||||
<span className="status-stack">
|
||||
<span className="status-inline">
|
||||
<span className={`badge badge-${statusTone}`}>{statusLabel}</span>
|
||||
<small>{app.installed.version}</small>
|
||||
</span>
|
||||
{serviceSummary && (
|
||||
<small className={app.installed.serviceCheckStatus === 'unhealthy' ? 'danger-text' : 'success-text'}>
|
||||
{serviceSummary}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1142,11 +1269,11 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
<div className="panel-actions">
|
||||
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
<RefreshCcw fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canClear && (
|
||||
<button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}>
|
||||
<XCircle size={16} aria-hidden="true" />
|
||||
<XCircle fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1179,7 +1306,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
|
||||
<div className="component-list task-components">
|
||||
<div className="component-list-title">
|
||||
<Activity size={15} aria-hidden="true" />
|
||||
<Activity fontSize={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{components.map((component) => (
|
||||
@@ -1189,6 +1316,12 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
<span>{component.currentStep || component.type || '-'}</span>
|
||||
</div>
|
||||
<span className={statusBadgeClass(component.status)}>{component.progress}%</span>
|
||||
{component.type === 'apt' && (
|
||||
<ServiceChecks
|
||||
checks={component.serviceChecks}
|
||||
status={component.serviceCheckStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!components.length && (
|
||||
@@ -1212,6 +1345,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 fontSize={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" fontSize={14} aria-hidden="true" />
|
||||
: <AlertCircle className="service-check-icon unhealthy" fontSize={14} aria-hidden="true" />}
|
||||
<span>
|
||||
<strong>{check.serviceName}</strong>
|
||||
<small>
|
||||
{showSource && (check.packageName || check.componentId)
|
||||
? `${check.packageName || check.componentId} · `
|
||||
: ''}
|
||||
{check.subState || check.activeState || 'unknown'} · {check.unitFileState || 'unknown'}
|
||||
{check.checkedAt ? ` · ${check.stale ? 'last checked' : 'checked'} ${formatTaskTime(check.checkedAt)}` : ''}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<span className={`badge badge-${serviceCheckTone(check)}`}>{serviceCheckLabel(check)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPackage, needsUpdate, onClose, onCopyUpdate, showAgentActions }) {
|
||||
const statusTone = needsUpdate
|
||||
? 'warning'
|
||||
@@ -1240,9 +1413,9 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
||||
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{needsUpdate ? <AlertCircle className="panel-state warning" size={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />}
|
||||
{needsUpdate ? <AlertCircle className="panel-state warning" fontSize={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" fontSize={20} aria-hidden="true" /> : <XCircle className="panel-state danger" fontSize={20} aria-hidden="true" />}
|
||||
<button className="icon-button subtle" type="button" title="Close" onClick={onClose}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
<X fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1282,18 +1455,18 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
||||
</div>
|
||||
</dl>
|
||||
<div className="agent-metrics">
|
||||
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||
<span><Cpu fontSize={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||
<span><HardDrive fontSize={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||
</div>
|
||||
{showAgentActions && (
|
||||
<div className="agent-update-action">
|
||||
<button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
{needsUpdate ? 'Copy update command' : 'Copy Agent command'}
|
||||
</button>
|
||||
{needsUpdate && latestAgentPackage?.downloadUrl && (
|
||||
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Download size={15} aria-hidden="true" />
|
||||
<Download fontSize={15} aria-hidden="true" />
|
||||
Latest .deb
|
||||
</a>
|
||||
)}
|
||||
@@ -1306,6 +1479,8 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
||||
function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
const packages = detail?.packages || [];
|
||||
const components = manifest?.components || [];
|
||||
const installedServiceChecks = app?.installed?.serviceChecks || [];
|
||||
const installedServiceStatus = app?.installed?.serviceCheckStatus || 'not-checked';
|
||||
|
||||
return (
|
||||
<section className="panel app-detail-panel">
|
||||
@@ -1314,7 +1489,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<h2>{app?.appName || 'App detail'}</h2>
|
||||
<p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p>
|
||||
</div>
|
||||
{status.state === 'loading' ? <Loader2 className="spin panel-state" size={20} aria-hidden="true" /> : <Box className="panel-state" size={20} aria-hidden="true" />}
|
||||
{status.state === 'loading' ? <Loader2 className="spin panel-state" fontSize={20} aria-hidden="true" /> : <Box className="panel-state" fontSize={20} aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
{app ? (
|
||||
@@ -1336,7 +1511,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
|
||||
<div className="component-list">
|
||||
<div className="component-list-title">
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
<ShieldCheck fontSize={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{status.state === 'danger' && (
|
||||
@@ -1355,6 +1530,23 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<div className="table-empty compact-empty">{status.message || 'Chưa có component.'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{app.installed && installedServiceStatus !== 'not-checked' && (
|
||||
<div className="service-health-panel">
|
||||
<div className="component-list-title">
|
||||
<Activity fontSize={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>
|
||||
@@ -1368,7 +1560,7 @@ function Toast({ toast }) {
|
||||
const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity;
|
||||
return (
|
||||
<div className={`toast tone-${tone || 'info'}`}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<Icon fontSize={17} aria-hidden="true" />
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -181,7 +181,7 @@ export async function fetchAgentSystemInfo(agentBaseUrl) {
|
||||
}
|
||||
|
||||
export async function fetchInstalledApps(agentBaseUrl) {
|
||||
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 7000 });
|
||||
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 20000 });
|
||||
return Array.isArray(payload) ? payload.map(normalizeInstalledApp) : [];
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ function normalizePackageApp(app) {
|
||||
}
|
||||
|
||||
function normalizeInstalledApp(app) {
|
||||
const serviceChecks = normalizeServiceChecks(app.serviceChecks || app.service_checks);
|
||||
return {
|
||||
appId: String(app.appId || app.app_id || '').trim(),
|
||||
appName: String(app.appName || app.app_name || '').trim(),
|
||||
@@ -263,6 +264,12 @@ function normalizeInstalledApp(app) {
|
||||
status: String(app.status || 'installed').trim(),
|
||||
installedAt: app.installedAt || app.installed_at || '',
|
||||
updatedAt: app.updatedAt || app.updated_at || '',
|
||||
serviceCheckStatus: String(
|
||||
app.serviceCheckStatus
|
||||
|| app.service_check_status
|
||||
|| deriveServiceCheckStatus(serviceChecks)
|
||||
).trim(),
|
||||
serviceChecks,
|
||||
openUrl: normalizeOpenUrl(
|
||||
app.openUrl
|
||||
|| app.open_url
|
||||
@@ -312,6 +319,7 @@ function normalizeLog(log) {
|
||||
}
|
||||
|
||||
function normalizeComponent(component) {
|
||||
const serviceChecks = normalizeServiceChecks(component.serviceChecks || component.service_checks);
|
||||
return {
|
||||
componentId: component.componentId || component.component_id,
|
||||
type: component.type,
|
||||
@@ -319,7 +327,50 @@ function normalizeComponent(component) {
|
||||
progress: Number(component.progress || 0),
|
||||
currentStep: component.currentStep || component.current_step,
|
||||
errorMessage: component.errorMessage || component.error_message,
|
||||
serviceCheckStatus: String(
|
||||
component.serviceCheckStatus
|
||||
|| component.service_check_status
|
||||
|| deriveServiceCheckStatus(serviceChecks)
|
||||
).trim(),
|
||||
serviceChecks,
|
||||
startedAt: component.startedAt || component.started_at,
|
||||
finishedAt: component.finishedAt || component.finished_at
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeServiceChecks(value) {
|
||||
return Array.isArray(value) ? value.map(normalizeServiceCheck).filter((item) => item.serviceName) : [];
|
||||
}
|
||||
|
||||
function normalizeServiceCheck(check) {
|
||||
const activeState = String(check?.activeState || check?.active_state || check?.status || 'unknown').trim();
|
||||
const active = typeof check?.active === 'boolean' ? check.active : activeState === 'active';
|
||||
const readinessStatus = String(check?.readinessStatus || check?.readiness_status || '').trim();
|
||||
const healthy = typeof check?.healthy === 'boolean'
|
||||
? check.healthy
|
||||
: active && (!readinessStatus || readinessStatus === 'ready');
|
||||
|
||||
return {
|
||||
serviceName: String(check?.serviceName || check?.service_name || '').trim(),
|
||||
componentId: String(check?.componentId || check?.component_id || '').trim(),
|
||||
packageName: String(check?.packageName || check?.package_name || '').trim(),
|
||||
loadState: String(check?.loadState || check?.load_state || 'unknown').trim(),
|
||||
activeState,
|
||||
subState: String(check?.subState || check?.sub_state || 'unknown').trim(),
|
||||
unitFileState: String(check?.unitFileState || check?.unit_file_state || 'unknown').trim(),
|
||||
readinessType: String(check?.readinessType || check?.readiness_type || '').trim(),
|
||||
readinessStatus,
|
||||
status: String(check?.status || activeState || 'unknown').trim(),
|
||||
active,
|
||||
enabled: Boolean(check?.enabled),
|
||||
healthy,
|
||||
stale: Boolean(check?.stale),
|
||||
checkedAt: check?.checkedAt || check?.checked_at || '',
|
||||
errorMessage: check?.errorMessage || check?.error_message || ''
|
||||
};
|
||||
}
|
||||
|
||||
function deriveServiceCheckStatus(serviceChecks) {
|
||||
if (!serviceChecks.length) return 'not-checked';
|
||||
return serviceChecks.every((check) => check.healthy) ? 'healthy' : 'unhealthy';
|
||||
}
|
||||
|
||||
@@ -1,28 +1,93 @@
|
||||
:root {
|
||||
--primary: #3755c3;
|
||||
--primary-dim: #2848b7;
|
||||
--primary-container: #dde1ff;
|
||||
--on-primary: #f8f7ff;
|
||||
--background: #f7f9fb;
|
||||
--surface-lowest: #ffffff;
|
||||
--surface-low: #f0f4f7;
|
||||
--surface: #e8eff3;
|
||||
--surface-high: #e1e9ee;
|
||||
--on-surface: #2a3439;
|
||||
--on-surface-variant: #566166;
|
||||
--outline-variant: #a9b4b9;
|
||||
--danger: #b42318;
|
||||
--danger-bg: #fee4e2;
|
||||
--success: #067647;
|
||||
--success-bg: #dcfae6;
|
||||
--warning: #b54708;
|
||||
--warning-bg: #fef0c7;
|
||||
--info: #175cd3;
|
||||
--info-bg: #d1e9ff;
|
||||
--muted-bg: #e2e8f0;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
color-scheme: light;
|
||||
|
||||
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
|
||||
--blue-50: #eaedf5;
|
||||
--blue-100: #c5cce0;
|
||||
--blue-200: #8c9ac4;
|
||||
--blue-300: #6e7ca8;
|
||||
--blue-500: #223771;
|
||||
--blue-700: #1a2a57;
|
||||
--blue-900: #1e2243;
|
||||
--orange-100: #fcd9c4;
|
||||
--orange-300: #f58220;
|
||||
--orange-500: #f26522;
|
||||
--orange-700: #c44e14;
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #f7f8fb;
|
||||
--neutral-100: #eeedf6;
|
||||
--neutral-200: #dddfea;
|
||||
--neutral-300: #c5c8d8;
|
||||
--neutral-400: #9fa3b8;
|
||||
--neutral-450: #8c90a8;
|
||||
--neutral-500: #757a91;
|
||||
--neutral-600: #565b70;
|
||||
--neutral-700: #3c4054;
|
||||
--neutral-800: #272a3a;
|
||||
--neutral-900: #161824;
|
||||
|
||||
--surface-base: var(--neutral-0);
|
||||
--surface-raised: var(--neutral-50);
|
||||
--surface-brand-tint: var(--neutral-100);
|
||||
--text-primary: var(--neutral-900);
|
||||
--text-secondary: var(--neutral-600);
|
||||
--text-tertiary: var(--neutral-500);
|
||||
--border-subtle: var(--neutral-300);
|
||||
--border-strong: var(--neutral-450);
|
||||
--brand-primary: var(--blue-500);
|
||||
--brand-primary-strong: var(--blue-700);
|
||||
--brand-accent: var(--orange-500);
|
||||
--brand-accent-strong: var(--orange-700);
|
||||
|
||||
--semantic-success: #2e7d32;
|
||||
--semantic-success-bg: #e8f5e9;
|
||||
--semantic-warning: #9a5b00;
|
||||
--semantic-warning-icon: #cb6119;
|
||||
--semantic-warning-bg: #fff4db;
|
||||
--semantic-error: #c62828;
|
||||
--semantic-error-bg: #ffebee;
|
||||
--semantic-info: #1565c0;
|
||||
--semantic-info-bg: #e7f1fc;
|
||||
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
|
||||
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
|
||||
|
||||
/* Compatibility aliases used by the existing components. */
|
||||
--primary: var(--brand-primary);
|
||||
--primary-dim: var(--brand-primary-strong);
|
||||
--primary-container: var(--blue-50);
|
||||
--on-primary: var(--neutral-0);
|
||||
--background: var(--surface-raised);
|
||||
--surface-lowest: var(--surface-base);
|
||||
--surface-low: var(--surface-raised);
|
||||
--surface: var(--surface-brand-tint);
|
||||
--surface-high: var(--neutral-200);
|
||||
--on-surface: var(--text-primary);
|
||||
--on-surface-variant: var(--text-secondary);
|
||||
--outline: var(--border-strong);
|
||||
--outline-variant: var(--border-subtle);
|
||||
--danger: var(--semantic-error);
|
||||
--danger-bg: var(--semantic-error-bg);
|
||||
--success: var(--semantic-success);
|
||||
--success-bg: var(--semantic-success-bg);
|
||||
--warning: var(--semantic-warning);
|
||||
--warning-bg: var(--semantic-warning-bg);
|
||||
--info: var(--semantic-info);
|
||||
--info-bg: var(--semantic-info-bg);
|
||||
--muted-bg: var(--neutral-200);
|
||||
--radius: var(--radius-md);
|
||||
--shadow-sm: var(--elevation-1);
|
||||
--shadow-lg: var(--elevation-2);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -40,8 +105,9 @@ body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--on-surface);
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -75,7 +141,7 @@ p {
|
||||
h1,
|
||||
h2,
|
||||
.brand-copy strong {
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -123,11 +189,17 @@ code {
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius);
|
||||
color: var(--on-primary);
|
||||
background: transparent;
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -782,6 +854,10 @@ tbody tr.selected-row {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.action-col {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
@@ -847,6 +923,18 @@ tbody tr.selected-row td.action-col {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
align-items: flex-start;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status-stack > small {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-pager {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
@@ -1053,10 +1141,106 @@ tbody tr.selected-row td.action-col {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-log-list {
|
||||
.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-border: var(--neutral-700);
|
||||
--task-log-text-primary: var(--neutral-100);
|
||||
--task-log-text-secondary: var(--neutral-400);
|
||||
--task-log-text-accent: var(--blue-200);
|
||||
|
||||
border-top: 1px solid var(--task-log-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
@@ -1064,7 +1248,7 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
.task-log-line {
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
border-bottom: 1px solid var(--task-log-border);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 64px 46px minmax(0, 1fr);
|
||||
@@ -1075,9 +1259,16 @@ tbody tr.selected-row td.action-col {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-log-line span,
|
||||
.task-log-line span {
|
||||
color: var(--task-log-text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-log-line strong {
|
||||
color: #64748b;
|
||||
color: var(--task-log-text-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
@@ -1085,13 +1276,17 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
.task-log-line p {
|
||||
color: #172033;
|
||||
color: var(--task-log-text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-log-list .table-empty {
|
||||
color: var(--task-log-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
@@ -1339,3 +1534,288 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Phenikaa-X application theme
|
||||
Keeps the existing component contract while applying the Layer-1 language.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
::selection {
|
||||
background: var(--orange-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--blue-900);
|
||||
border-color: var(--neutral-700);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
|
||||
min-height: 72px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.brand-copy strong,
|
||||
.sidebar .status-box strong,
|
||||
.sidebar .endpoint-summary-row strong {
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.sidebar .nav-label,
|
||||
.sidebar .endpoint-summary-row span,
|
||||
.sidebar .status-box div > span {
|
||||
color: var(--blue-100);
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sidebar .status-box,
|
||||
.sidebar .endpoint-summary {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
border-color: rgba(197, 204, 224, 0.2);
|
||||
}
|
||||
|
||||
.sidebar .status-box:hover,
|
||||
.sidebar .endpoint-summary:hover {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
border-color: rgba(197, 204, 224, 0.35);
|
||||
}
|
||||
|
||||
.sidebar .btn-secondary {
|
||||
background: transparent;
|
||||
border-color: var(--blue-300);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.sidebar .btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--blue-100);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: var(--border-subtle);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.topbar-title span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topbar-title strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.page h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.dialog-panel,
|
||||
.offline-banner,
|
||||
.agent-status-button {
|
||||
border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.dialog-panel {
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.dialog-header,
|
||||
.dialog-actions,
|
||||
.table-wrap thead,
|
||||
.page-filters {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.icon-button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-primary);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-primary-strong);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--brand-accent);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: var(--brand-accent-strong);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.icon-button {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.icon-button:hover,
|
||||
.icon-button.subtle:hover {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.settings-field input,
|
||||
.filter-field input,
|
||||
.filter-field select,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-field input:focus,
|
||||
.filter-field input:focus,
|
||||
.filter-field select:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
|
||||
}
|
||||
|
||||
table {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-brand-tint);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--blue-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
td {
|
||||
border-color: var(--neutral-200);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tbody tr:hover,
|
||||
.selected-row {
|
||||
background: var(--blue-50);
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--semantic-success-bg);
|
||||
color: var(--semantic-success);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--semantic-error-bg);
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--semantic-warning-bg);
|
||||
color: var(--semantic-warning);
|
||||
}
|
||||
|
||||
.badge-info,
|
||||
.badge-primary {
|
||||
background: var(--semantic-info-bg);
|
||||
color: var(--semantic-info);
|
||||
}
|
||||
|
||||
code,
|
||||
.task-log-list {
|
||||
background: var(--blue-900);
|
||||
color: var(--neutral-100);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.sidebar {
|
||||
border-bottom-color: var(--neutral-700);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ WEB_SERVER_PORT=3005
|
||||
IMAGE_TAG=1.0.0
|
||||
DOCKER_NETWORK=robot-installer-net
|
||||
WEB_SERVER_UPLOADS_DIR=./uploads
|
||||
MAX_UPLOAD_BYTES=1073741824
|
||||
AGENT_MAX_UPLOAD_BYTES=1073741824
|
||||
DOCUMENT_MAX_UPLOAD_BYTES=52428800
|
||||
DOCUMENT_MAX_CONTENT_BYTES=2097152
|
||||
DOCUMENT_MAX_CONTENT_CHARS=500000
|
||||
SQLSERVER_HOST=172.20.235.176
|
||||
SQLSERVER_PORT=1433
|
||||
SQLSERVER_DATABASE=RobotInstaller
|
||||
|
||||
@@ -18,7 +18,7 @@ COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p uploads/packages/agent \
|
||||
RUN mkdir -p uploads/packages/agent uploads/documents \
|
||||
&& chown -R node:node uploads \
|
||||
&& chmod +x docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ IF OBJECT_ID(N'dbo.ApplicationPackages', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.PackageVersions', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Applications', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Packages', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Documents', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
@@ -54,6 +55,37 @@ CREATE TABLE dbo.EmailConfirmationTokens
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE TABLE dbo.Packages
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
@@ -164,6 +196,13 @@ CREATE INDEX IX_EmailConfirmationTokens_UserId
|
||||
ON dbo.EmailConfirmationTokens(UserId);
|
||||
GO
|
||||
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
GO
|
||||
|
||||
CREATE UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
|
||||
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
|
||||
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);
|
||||
|
||||
67
web-server/database/05_documents.sql
Normal file
67
web-server/database/05_documents.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
USE [RobotInstaller];
|
||||
GO
|
||||
|
||||
SET ANSI_NULLS ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_Category_UpdatedAt'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_CreatedByUserId'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
END;
|
||||
GO
|
||||
|
||||
PRINT N'RobotInstaller documents schema is ready.';
|
||||
GO
|
||||
@@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f
|
||||
| `dbo.PackageVersions` | Các version của từng package |
|
||||
| `dbo.Applications` | App được đóng gói từ nhiều package |
|
||||
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
|
||||
| `dbo.Documents` | Nội dung tài liệu và metadata file đính kèm |
|
||||
| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng |
|
||||
|
||||
## Ràng buộc quan trọng
|
||||
@@ -65,13 +66,14 @@ sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\05_documents.sql
|
||||
```
|
||||
|
||||
Chạy các lệnh trên từ thư mục `web-server`.
|
||||
|
||||
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
|
||||
|
||||
`04_notifications.sql` là migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại.
|
||||
`04_notifications.sql` và `05_documents.sql` là các migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy các migration còn thiếu; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại. Web server cũng tự bảo đảm bảng `Documents` tồn tại khi chức năng tài liệu được truy cập.
|
||||
|
||||
## Luồng dữ liệu đề xuất
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/uploads/packages/agent
|
||||
mkdir -p /app/uploads/packages/agent /app/uploads/documents
|
||||
chown -R node:node /app/uploads
|
||||
|
||||
exec su-exec node "$@"
|
||||
|
||||
BIN
web-server/image/logo_PNKX.png
Normal file
BIN
web-server/image/logo_PNKX.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
45
web-server/package-lock.json
generated
45
web-server/package-lock.json
generated
@@ -8,12 +8,14 @@
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "^1.1.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"mssql": "^12.5.4",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"notiflix": "^3.2.8"
|
||||
}
|
||||
},
|
||||
@@ -276,6 +278,21 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluentui/svg-icons": {
|
||||
"version": "1.1.333",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.333.tgz",
|
||||
"integrity": "sha512-uSFMDQq25/sVgHvD9KhP+c1rZrHWqIA0OZkofu9Ii3Q5b72DqLgqOreABHbPD67U5XfsFc4iPlZzWEl1849Obg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/montserrat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
|
||||
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@js-joda/core": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",
|
||||
@@ -427,9 +444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -451,9 +468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -1419,9 +1436,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -1453,9 +1470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "^1.1.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"mssql": "^12.5.4",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"notiflix": "^3.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,92 @@
|
||||
:root {
|
||||
--primary: #3755c3;
|
||||
--primary-dim: #2848b7;
|
||||
--primary-container: #dde1ff;
|
||||
--on-primary: #f8f7ff;
|
||||
--background: #f7f9fb;
|
||||
--surface-lowest: #ffffff;
|
||||
--surface-low: #f0f4f7;
|
||||
--surface: #e8eff3;
|
||||
--surface-high: #e1e9ee;
|
||||
--on-surface: #2a3439;
|
||||
--on-surface-variant: #566166;
|
||||
--outline: #717c82;
|
||||
--outline-variant: #a9b4b9;
|
||||
--danger: #b42318;
|
||||
--danger-bg: #fee4e2;
|
||||
--success: #067647;
|
||||
--success-bg: #dcfae6;
|
||||
--warning: #b54708;
|
||||
--warning-bg: #fef0c7;
|
||||
--info: #175cd3;
|
||||
--info-bg: #d1e9ff;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
color-scheme: light;
|
||||
|
||||
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
|
||||
--blue-50: #eaedf5;
|
||||
--blue-100: #c5cce0;
|
||||
--blue-200: #8c9ac4;
|
||||
--blue-300: #6e7ca8;
|
||||
--blue-500: #223771;
|
||||
--blue-700: #1a2a57;
|
||||
--blue-900: #1e2243;
|
||||
--orange-100: #fcd9c4;
|
||||
--orange-300: #f58220;
|
||||
--orange-500: #f26522;
|
||||
--orange-700: #c44e14;
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #f7f8fb;
|
||||
--neutral-100: #eeedf6;
|
||||
--neutral-200: #dddfea;
|
||||
--neutral-300: #c5c8d8;
|
||||
--neutral-400: #9fa3b8;
|
||||
--neutral-450: #8c90a8;
|
||||
--neutral-500: #757a91;
|
||||
--neutral-600: #565b70;
|
||||
--neutral-700: #3c4054;
|
||||
--neutral-800: #272a3a;
|
||||
--neutral-900: #161824;
|
||||
|
||||
--surface-base: var(--neutral-0);
|
||||
--surface-raised: var(--neutral-50);
|
||||
--surface-brand-tint: var(--neutral-100);
|
||||
--text-primary: var(--neutral-900);
|
||||
--text-secondary: var(--neutral-600);
|
||||
--text-tertiary: var(--neutral-500);
|
||||
--border-subtle: var(--neutral-300);
|
||||
--border-strong: var(--neutral-450);
|
||||
--brand-primary: var(--blue-500);
|
||||
--brand-primary-strong: var(--blue-700);
|
||||
--brand-accent: var(--orange-500);
|
||||
--brand-accent-strong: var(--orange-700);
|
||||
|
||||
--semantic-success: #2e7d32;
|
||||
--semantic-success-bg: #e8f5e9;
|
||||
--semantic-warning: #9a5b00;
|
||||
--semantic-warning-icon: #cb6119;
|
||||
--semantic-warning-bg: #fff4db;
|
||||
--semantic-error: #c62828;
|
||||
--semantic-error-bg: #ffebee;
|
||||
--semantic-info: #1565c0;
|
||||
--semantic-info-bg: #e7f1fc;
|
||||
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
|
||||
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
|
||||
|
||||
/* Compatibility aliases used by the existing components. */
|
||||
--primary: var(--brand-primary);
|
||||
--primary-dim: var(--brand-primary-strong);
|
||||
--primary-container: var(--blue-50);
|
||||
--on-primary: var(--neutral-0);
|
||||
--background: var(--surface-raised);
|
||||
--surface-lowest: var(--surface-base);
|
||||
--surface-low: var(--surface-raised);
|
||||
--surface: var(--surface-brand-tint);
|
||||
--surface-high: var(--neutral-200);
|
||||
--on-surface: var(--text-primary);
|
||||
--on-surface-variant: var(--text-secondary);
|
||||
--outline: var(--border-strong);
|
||||
--outline-variant: var(--border-subtle);
|
||||
--danger: var(--semantic-error);
|
||||
--danger-bg: var(--semantic-error-bg);
|
||||
--success: var(--semantic-success);
|
||||
--success-bg: var(--semantic-success-bg);
|
||||
--warning: var(--semantic-warning);
|
||||
--warning-bg: var(--semantic-warning-bg);
|
||||
--info: var(--semantic-info);
|
||||
--info-bg: var(--semantic-info-bg);
|
||||
--radius: var(--radius-md);
|
||||
--shadow-sm: var(--elevation-1);
|
||||
--shadow-lg: var(--elevation-2);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -39,8 +103,9 @@ body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--on-surface);
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -48,7 +113,7 @@ h1,
|
||||
h2,
|
||||
h3,
|
||||
.brand-copy strong {
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -79,17 +144,13 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
direction: ltr;
|
||||
.fluent-icon {
|
||||
background: currentColor;
|
||||
display: inline-flex;
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-size: 1.25rem;
|
||||
font-style: normal;
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
height: 1.25rem;
|
||||
mask: var(--fluent-icon-url) center / contain no-repeat;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
@@ -118,13 +179,19 @@ button:disabled {
|
||||
|
||||
.brand-mark {
|
||||
align-items: center;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius);
|
||||
color: var(--on-primary);
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -304,7 +371,7 @@ button:disabled {
|
||||
|
||||
.notification-panel-header strong {
|
||||
color: #111827;
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -394,8 +461,10 @@ button:disabled {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.notification-item-icon .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
.notification-item-icon .material-symbols-outlined,
|
||||
.notification-item-icon .fluent-icon {
|
||||
height: 19px;
|
||||
width: 19px;
|
||||
}
|
||||
|
||||
.notification-item.tone-success .notification-item-icon {
|
||||
@@ -468,9 +537,11 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-empty .material-symbols-outlined {
|
||||
.notification-empty .material-symbols-outlined,
|
||||
.notification-empty .fluent-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notification-empty strong {
|
||||
@@ -705,7 +776,7 @@ button:disabled {
|
||||
|
||||
.metric-card strong {
|
||||
color: #111827;
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1208,9 +1279,11 @@ tbody tr:hover td.action-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-dropzone-content > .material-symbols-outlined {
|
||||
.file-dropzone-content > .material-symbols-outlined,
|
||||
.file-dropzone-content > .fluent-icon {
|
||||
color: var(--primary);
|
||||
font-size: 34px;
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.file-dropzone-content strong {
|
||||
@@ -1237,7 +1310,8 @@ tbody tr:hover td.action-col {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-preview > .material-symbols-outlined {
|
||||
.file-preview > .material-symbols-outlined,
|
||||
.file-preview > .fluent-icon {
|
||||
align-items: center;
|
||||
background: var(--primary-container);
|
||||
border-radius: var(--radius);
|
||||
@@ -1656,12 +1730,155 @@ tbody tr:hover td.action-col {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.documents-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.document-title-cell {
|
||||
max-width: 360px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.document-title-cell .table-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-file-name {
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-detail-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-meta-panel,
|
||||
.document-reader-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-reader {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.document-copy {
|
||||
color: var(--text-primary);
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.document-preview-block {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.document-section-heading {
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.document-section-heading .material-symbols-outlined {
|
||||
color: var(--brand-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.document-file-preview {
|
||||
background: var(--neutral-50);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 540px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-image-preview {
|
||||
align-self: center;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.document-attachment-state {
|
||||
align-items: center;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.document-attachment-state > .material-symbols-outlined {
|
||||
color: var(--brand-primary);
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.document-attachment-state strong,
|
||||
.document-attachment-state p {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.document-attachment-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.document-remove-option {
|
||||
align-items: flex-start;
|
||||
background: var(--semantic-warning-bg);
|
||||
border: 1px solid #f3d28c;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--semantic-warning);
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.document-remove-option input {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-grid,
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
.users-layout,
|
||||
.document-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1672,9 +1889,14 @@ tbody tr:hover td.action-col {
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
.users-layout,
|
||||
.document-detail-grid {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-reader-panel {
|
||||
min-height: 620px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -1765,6 +1987,16 @@ tbody tr:hover td.action-col {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document-attachment-state {
|
||||
align-items: flex-start;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-attachment-state .btn {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
align-items: flex-end;
|
||||
}
|
||||
@@ -1798,3 +2030,327 @@ tbody tr:hover td.action-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Phenikaa-X application theme
|
||||
Keeps the existing EJS/JavaScript contract while applying Layer-1 tokens.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
::selection {
|
||||
background: var(--orange-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--blue-900);
|
||||
border-color: var(--neutral-700);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
|
||||
min-height: 72px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
color: var(--neutral-0);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.sidebar .nav-label,
|
||||
.sidebar-copyright {
|
||||
color: var(--blue-100);
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
gap: var(--space-xxs);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-left-width: 4px;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
color: var(--blue-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
gap: var(--space-sm);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-left-color: var(--brand-accent);
|
||||
color: var(--neutral-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: var(--border-subtle);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.page-header h1,
|
||||
.auth-heading h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.metric-card,
|
||||
.modal-content,
|
||||
.notification-panel,
|
||||
.empty-state,
|
||||
.auth-panel {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.modal-content,
|
||||
.notification-panel,
|
||||
.auth-panel {
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.modal-header,
|
||||
.modal-actions,
|
||||
.page-filters,
|
||||
thead,
|
||||
td {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.icon-button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-primary);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-primary-strong);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--brand-accent);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: var(--brand-accent-strong);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.icon-button {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.icon-button:hover,
|
||||
.icon-button.subtle:hover {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea,
|
||||
.filter-field input,
|
||||
.filter-field select,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field select:focus,
|
||||
.form-field textarea:focus,
|
||||
.filter-field input:focus,
|
||||
.filter-field select:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-brand-tint);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--blue-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--blue-50);
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--semantic-success-bg);
|
||||
color: var(--semantic-success);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--semantic-error-bg);
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--semantic-warning-bg);
|
||||
color: var(--semantic-warning);
|
||||
}
|
||||
|
||||
.badge-info,
|
||||
.badge-primary {
|
||||
background: var(--semantic-info-bg);
|
||||
color: var(--semantic-info);
|
||||
}
|
||||
|
||||
.empty-state > .fluent-icon {
|
||||
color: var(--brand-primary);
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.error-state > .fluent-icon {
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(30, 34, 67, 0.98), rgba(34, 55, 113, 0.93) 42%, rgba(247, 248, 251, 0.98) 42.1%),
|
||||
var(--surface-raised);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
border-top: 4px solid var(--brand-accent);
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.auth-brand .brand-mark {
|
||||
background: var(--blue-900);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.auth-brand .brand-copy strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-brand .brand-copy span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.auth-confirm-icon {
|
||||
background: var(--blue-50);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.auth-confirm-icon .fluent-icon {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--blue-900);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,67 @@
|
||||
const body = document.body;
|
||||
const menuButton = document.getElementById('mobileMenuBtn');
|
||||
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
||||
const FLUENT_ICON_FILES = Object.freeze({
|
||||
add: 'add_20_regular.svg',
|
||||
add_box: 'box_24_regular.svg',
|
||||
apps: 'apps_24_regular.svg',
|
||||
archive: 'archive_24_regular.svg',
|
||||
attach_file: 'attach_20_regular.svg',
|
||||
campaign: 'megaphone_20_regular.svg',
|
||||
check_circle: 'checkmark_circle_20_filled.svg',
|
||||
close: 'dismiss_20_regular.svg',
|
||||
dashboard: 'grid_24_regular.svg',
|
||||
database_off: 'database_warning_20_regular.svg',
|
||||
delete: 'delete_20_regular.svg',
|
||||
deployed_code: 'cube_24_regular.svg',
|
||||
description: 'document_24_regular.svg',
|
||||
download: 'arrow_download_20_regular.svg',
|
||||
draft: 'document_24_regular.svg',
|
||||
edit: 'edit_20_regular.svg',
|
||||
error: 'error_circle_20_filled.svg',
|
||||
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
|
||||
group: 'people_24_regular.svg',
|
||||
inventory_2: 'box_24_regular.svg',
|
||||
library_books: 'library_24_regular.svg',
|
||||
link_off: 'link_dismiss_20_regular.svg',
|
||||
login: 'arrow_enter_20_regular.svg',
|
||||
logout: 'sign_out_20_regular.svg',
|
||||
mark_email_unread: 'mail_unread_24_regular.svg',
|
||||
memory: 'memory_16_regular.svg',
|
||||
menu: 'navigation_20_regular.svg',
|
||||
notifications: 'alert_20_regular.svg',
|
||||
notifications_none: 'alert_off_20_regular.svg',
|
||||
note_add: 'document_add_24_regular.svg',
|
||||
person_add: 'person_add_20_regular.svg',
|
||||
precision_manufacturing: 'bot_24_filled.svg',
|
||||
save: 'save_20_regular.svg',
|
||||
search_off: 'search_24_regular.svg',
|
||||
send: 'send_20_regular.svg',
|
||||
stars: 'star_20_filled.svg',
|
||||
swap_horiz: 'arrow_swap_20_regular.svg',
|
||||
sync: 'arrow_sync_20_regular.svg',
|
||||
upgrade: 'arrow_up_20_regular.svg',
|
||||
upload: 'arrow_upload_20_regular.svg',
|
||||
upload_file: 'document_arrow_up_24_regular.svg',
|
||||
visibility: 'eye_20_regular.svg',
|
||||
warning: 'warning_20_filled.svg'
|
||||
});
|
||||
|
||||
function applyFluentIcon(element, iconName) {
|
||||
if (!element) return;
|
||||
const fileName = FLUENT_ICON_FILES[iconName] || 'question_20_regular.svg';
|
||||
element.classList.remove('material-symbols-outlined');
|
||||
element.classList.add('fluent-icon');
|
||||
element.style.setProperty('--fluent-icon-url', `url("/vendor/fluent-icons/${fileName}")`);
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
element.textContent = '';
|
||||
}
|
||||
|
||||
function hydrateFluentIcons(root) {
|
||||
root.querySelectorAll('.material-symbols-outlined').forEach((element) => {
|
||||
applyFluentIcon(element, element.textContent.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function initNotiflix() {
|
||||
if (!window.Notiflix) return;
|
||||
@@ -11,8 +72,8 @@
|
||||
position: 'right-top',
|
||||
distance: '16px',
|
||||
timeout: 2600,
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
||||
fontSize: '13px',
|
||||
messageMaxLength: 180,
|
||||
clickToClose: true,
|
||||
@@ -21,35 +82,35 @@
|
||||
useIcon: true,
|
||||
zindex: 5000,
|
||||
success: {
|
||||
background: '#067647',
|
||||
background: '#2e7d32',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
failure: {
|
||||
background: '#b42318',
|
||||
background: '#c62828',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
warning: {
|
||||
background: '#b54708',
|
||||
background: '#9a5b00',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
info: {
|
||||
background: '#3755c3',
|
||||
background: '#223771',
|
||||
textColor: '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
window.Notiflix.Confirm.init({
|
||||
width: '360px',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
titleColor: '#111827',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
||||
titleColor: '#161824',
|
||||
titleFontSize: '16px',
|
||||
messageColor: '#475569',
|
||||
messageColor: '#565b70',
|
||||
messageFontSize: '13px',
|
||||
okButtonBackground: '#3755c3',
|
||||
okButtonBackground: '#223771',
|
||||
okButtonColor: '#ffffff',
|
||||
cancelButtonBackground: '#e2e8f0',
|
||||
cancelButtonColor: '#334155',
|
||||
cancelButtonBackground: '#eeedf6',
|
||||
cancelButtonColor: '#3c4054',
|
||||
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
|
||||
zindex: 5001,
|
||||
cssAnimationStyle: 'zoom'
|
||||
@@ -393,7 +454,8 @@
|
||||
createdAt: row.dataset.userCreatedAt || '',
|
||||
updatedAt: row.dataset.userUpdatedAt || '',
|
||||
packageCount: row.dataset.userPackageCount || '0',
|
||||
applicationCount: row.dataset.userApplicationCount || '0'
|
||||
applicationCount: row.dataset.userApplicationCount || '0',
|
||||
documentCount: row.dataset.userDocumentCount || '0'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -503,7 +565,10 @@
|
||||
setText('[data-user-detail="status"]', user.status);
|
||||
setText('[data-user-detail="createdAt"]', user.createdAt);
|
||||
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
|
||||
setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
|
||||
setText(
|
||||
'[data-user-detail="ownedData"]',
|
||||
`${user.packageCount} packages, ${user.applicationCount} apps, ${user.documentCount} tài liệu`
|
||||
);
|
||||
|
||||
openModal('userDetailModal');
|
||||
}
|
||||
@@ -701,9 +766,7 @@
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'notification-item-icon';
|
||||
const iconGlyph = document.createElement('span');
|
||||
iconGlyph.className = 'material-symbols-outlined';
|
||||
iconGlyph.setAttribute('aria-hidden', 'true');
|
||||
iconGlyph.textContent = getNotificationIcon(notification);
|
||||
applyFluentIcon(iconGlyph, getNotificationIcon(notification));
|
||||
icon.appendChild(iconGlyph);
|
||||
|
||||
const copy = document.createElement('span');
|
||||
@@ -926,6 +989,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
hydrateFluentIcons(document);
|
||||
initNotiflix();
|
||||
initFileDropzones();
|
||||
initRegistrationUniqueChecks();
|
||||
|
||||
@@ -8,6 +8,7 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const { normalizeDocumentFileName } = require('./src/document-file-name');
|
||||
const notificationRepository = require('./src/notification-repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const uploadDir = path.join(__dirname, 'uploads', 'packages');
|
||||
const documentUploadDir = path.join(__dirname, 'uploads', 'documents');
|
||||
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
|
||||
const agentDebianPackageName = 'local-installer-agent';
|
||||
const authCookieName = 'robot_installer_session';
|
||||
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
|
||||
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
|
||||
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
|
||||
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
|
||||
const documentCategories = Object.freeze([
|
||||
{ id: 'introduction', label: 'Giới thiệu' },
|
||||
{ id: 'guide', label: 'Hướng dẫn' },
|
||||
{ id: 'user-guide', label: 'Hướng dẫn sử dụng' },
|
||||
{ id: 'technical', label: 'Tài liệu kỹ thuật' },
|
||||
{ id: 'policy', label: 'Quy trình / chính sách' },
|
||||
{ id: 'other', label: 'Khác' }
|
||||
]);
|
||||
const documentCategoryIds = new Set(documentCategories.map((category) => category.id));
|
||||
const allowedDocumentExtensions = new Set([
|
||||
'.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md',
|
||||
'.png', '.jpg', '.jpeg', '.webp',
|
||||
'.ppt', '.pptx', '.xls', '.xlsx'
|
||||
]);
|
||||
const documentTextExtensions = new Set(['.txt', '.md']);
|
||||
const documentImageExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const documentMaxContentChars = Number(process.env.DOCUMENT_MAX_CONTENT_CHARS || 500000);
|
||||
const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.mkdirSync(documentUploadDir, { recursive: true });
|
||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
||||
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
||||
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
||||
{ id: 'documents', label: 'Tài liệu', href: '/documents', icon: 'library_books' },
|
||||
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
||||
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
||||
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
|
||||
@@ -106,27 +127,68 @@ const agentStorage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const multipartLimits = Object.freeze({
|
||||
fieldNameSize: 64,
|
||||
fieldNestingDepth: 0,
|
||||
fieldSize: 64 * 1024,
|
||||
fields: 16,
|
||||
files: 1,
|
||||
parts: 17
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
destination: documentUploadDir,
|
||||
filename(req, file, callback) {
|
||||
const normalizedName = normalizeDocumentFileName(file.originalname)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
const extension = path.extname(normalizedName).slice(0, 20);
|
||||
const baseName = normalizedName.slice(0, normalizedName.length - extension.length).slice(0, 180) || 'document';
|
||||
const safeName = `${baseName}${extension}`;
|
||||
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
callback(null, `${suffix}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
const agentUpload = multer({
|
||||
storage: agentStorage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fileSize: Number(process.env.AGENT_MAX_UPLOAD_BYTES || process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
const documentUpload = multer({
|
||||
storage: documentStorage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fieldSize: Number(process.env.DOCUMENT_MAX_CONTENT_BYTES || 2 * 1024 * 1024),
|
||||
fileSize: Number(process.env.DOCUMENT_MAX_UPLOAD_BYTES || 50 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '32kb', parameterLimit: 100 }));
|
||||
app.use(express.json({ limit: '32kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/image', express.static(path.join(__dirname, 'image')));
|
||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||
app.use('/vendor/montserrat', express.static(path.join(__dirname, 'node_modules/@fontsource-variable/montserrat')));
|
||||
app.use('/vendor/fluent-icons', express.static(path.join(__dirname, 'node_modules/@fluentui/svg-icons/icons')));
|
||||
app.use(applyPublicApiCors);
|
||||
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||
const arch = normalizeAgentArch(req.query.arch);
|
||||
@@ -214,6 +276,9 @@ function helpers() {
|
||||
if (type === 'docker') return 'badge-info';
|
||||
if (type === 'apt') return 'badge-warning';
|
||||
return 'badge-primary';
|
||||
},
|
||||
documentCategoryLabel(categoryId) {
|
||||
return documentCategories.find((category) => category.id === categoryId)?.label || 'Khác';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1024,6 +1089,128 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDocumentCategory(value) {
|
||||
const category = String(value || '').trim().toLowerCase();
|
||||
return documentCategoryIds.has(category) ? category : '';
|
||||
}
|
||||
|
||||
function getDocumentUploadValidationMessage(file) {
|
||||
if (!file) return '';
|
||||
|
||||
const originalFileName = normalizeDocumentFileName(file.originalname);
|
||||
|
||||
if (originalFileName.length > 260) {
|
||||
return 'Tên file tài liệu không được vượt quá 260 ký tự.';
|
||||
}
|
||||
|
||||
const extension = path.extname(originalFileName).toLowerCase();
|
||||
if (!allowedDocumentExtensions.has(extension)) {
|
||||
return 'Định dạng tài liệu chưa được hỗ trợ. Hãy dùng PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint hoặc Excel.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentArtifact(file) {
|
||||
if (!file) {
|
||||
return {
|
||||
filePath: null,
|
||||
originalFileName: null,
|
||||
mimeType: null,
|
||||
fileSizeBytes: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: `/uploads/documents/${file.filename}`,
|
||||
originalFileName: normalizeDocumentFileName(file.originalname),
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
fileSizeBytes: file.size
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalDocumentFilePath(filePath) {
|
||||
const storedPath = String(filePath || '').trim();
|
||||
const prefix = '/uploads/documents/';
|
||||
|
||||
if (!storedPath.startsWith(prefix)) return null;
|
||||
|
||||
let relativePath;
|
||||
try {
|
||||
relativePath = decodeURIComponent(storedPath.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!relativePath || relativePath.includes('\0')) return null;
|
||||
|
||||
const documentRoot = path.resolve(documentUploadDir);
|
||||
const localPath = path.resolve(documentRoot, relativePath);
|
||||
const pathDelta = path.relative(documentRoot, localPath);
|
||||
|
||||
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async function removeStoredDocumentFile(filePath) {
|
||||
const localPath = getLocalDocumentFilePath(filePath);
|
||||
if (!localPath) return;
|
||||
|
||||
try {
|
||||
await fsp.unlink(localPath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`Cannot remove document file ${localPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentPreviewKind(document) {
|
||||
if (!document?.filePath) return '';
|
||||
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
if (extension === '.pdf') return 'pdf';
|
||||
if (documentImageExtensions.has(extension)) return 'image';
|
||||
if (documentTextExtensions.has(extension)) return 'text';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentResponseMimeType(document) {
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
const knownTypes = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
|
||||
return knownTypes[extension] || document.mimeType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function setDocumentContentDisposition(res, disposition, fileName) {
|
||||
const originalName = String(fileName || 'document')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.slice(0, 260);
|
||||
const asciiName = originalName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\x20-\x7E]/g, '_')
|
||||
.replace(/["\\;]/g, '_') || 'document';
|
||||
const encodedName = encodeURIComponent(originalName)
|
||||
.replace(/['()]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
|
||||
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
||||
|
||||
@@ -1555,7 +1742,7 @@ exit 1
|
||||
});
|
||||
|
||||
app.use(requireAuthenticated);
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
|
||||
app.get('/api/notifications', asyncRoute(async (req, res) => {
|
||||
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
|
||||
@@ -2296,6 +2483,228 @@ app.get('/applications/:id', asyncRoute(async (req, res) => {
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents', asyncRoute(async (req, res) => {
|
||||
const [pageData, documents] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.listDocuments()
|
||||
]);
|
||||
|
||||
res.render(
|
||||
'documents',
|
||||
viewModel(req, 'documents', 'Tài liệu', pageData, { documents, documentCategories })
|
||||
);
|
||||
}));
|
||||
|
||||
app.post('/documents', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !req.file) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Hãy nhập nội dung hoặc đính kèm ít nhất một file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
await repository.createDocument({
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
...artifact,
|
||||
createdByUserId: req.currentUser.id
|
||||
});
|
||||
|
||||
redirectWithNotice(res, '/documents', 'success', 'Đã lưu tài liệu mới.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.get('/documents/:id/file', asyncRoute(async (req, res) => {
|
||||
const document = await repository.getDocumentById(req.params.id);
|
||||
const localPath = document ? getLocalDocumentFilePath(document.filePath) : null;
|
||||
|
||||
if (!document || !localPath) {
|
||||
res.status(404).type('text/plain').send('Không tìm thấy file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(localPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).type('text/plain').send('File tài liệu không còn tồn tại trên máy chủ.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previewKind = getDocumentPreviewKind(document);
|
||||
const shouldDownload = req.query.download === '1' || !previewKind;
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', getDocumentResponseMimeType(document));
|
||||
setDocumentContentDisposition(
|
||||
res,
|
||||
shouldDownload ? 'attachment' : 'inline',
|
||||
document.originalFileName || path.basename(localPath)
|
||||
);
|
||||
res.sendFile(localPath);
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/edit', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
try {
|
||||
const existingDocument = await repository.getDocumentById(documentId);
|
||||
if (!existingDocument) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const removeAttachment = req.body.removeAttachment === '1';
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
const willHaveAttachment = Boolean(req.file) || (Boolean(existingDocument.filePath) && !removeAttachment);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !willHaveAttachment) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tài liệu phải có nội dung hoặc file đính kèm.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
const replaceAttachment = Boolean(req.file) || removeAttachment;
|
||||
const result = await repository.updateDocument({
|
||||
documentId,
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
replaceAttachment,
|
||||
...artifact
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.attachmentChanged
|
||||
&& result.previousFilePath
|
||||
&& result.previousFilePath !== result.currentFilePath
|
||||
) {
|
||||
await removeStoredDocumentFile(result.previousFilePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'success', 'Đã cập nhật tài liệu.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/delete', asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
if (!isUuid(documentId)) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần xóa.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedDocument = await repository.deleteDocument(documentId);
|
||||
if (deletedDocument?.filePath) {
|
||||
await removeStoredDocumentFile(deletedDocument.filePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/documents',
|
||||
deletedDocument ? 'success' : 'warning',
|
||||
deletedDocument ? 'Đã xóa tài liệu và file đính kèm.' : 'Không tìm thấy tài liệu cần xóa.'
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents/:id', asyncRoute(async (req, res) => {
|
||||
const [pageData, document] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.getDocumentById(req.params.id)
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
res.status(404).render('not-found', viewModel(req, 'documents', 'Không tìm thấy', pageData));
|
||||
return;
|
||||
}
|
||||
|
||||
res.render(
|
||||
'document-detail',
|
||||
viewModel(req, 'documents', document.title, pageData, {
|
||||
document,
|
||||
documentCategories,
|
||||
documentPreviewKind: getDocumentPreviewKind(document)
|
||||
})
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
|
||||
const [pageData, users] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
@@ -2434,7 +2843,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'USER_HAS_OWNED_DATA') {
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package hoặc application. Hãy khóa tài khoản nếu cần.');
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package, application hoặc tài liệu. Hãy khóa tài khoản nếu cần.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2448,6 +2857,31 @@ app.get('/builder', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
app.use(async (error, req, res, next) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
const uploadErrorMessages = {
|
||||
LIMIT_FIELD_COUNT: 'Biểu mẫu upload có quá nhiều trường.',
|
||||
LIMIT_FIELD_KEY: 'Tên trường upload quá dài.',
|
||||
LIMIT_FIELD_NESTING: 'Tên trường upload không đúng định dạng.',
|
||||
LIMIT_FIELD_VALUE: 'Nội dung một trường upload quá dài.',
|
||||
LIMIT_FILE_COUNT: 'Chỉ được upload một file mỗi lần.',
|
||||
LIMIT_FILE_SIZE: 'File upload vượt quá dung lượng cho phép.',
|
||||
LIMIT_PART_COUNT: 'Biểu mẫu upload có quá nhiều thành phần.',
|
||||
LIMIT_UNEXPECTED_FILE: 'Trường file upload không hợp lệ.'
|
||||
};
|
||||
const returnPath = req.path.startsWith('/agent/')
|
||||
? '/agent'
|
||||
: (req.path.startsWith('/documents') ? '/documents' : '/packages');
|
||||
|
||||
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
|
||||
redirectWithNotice(
|
||||
res,
|
||||
returnPath,
|
||||
'warning',
|
||||
uploadErrorMessages[error.code] || 'Biểu mẫu upload không hợp lệ.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
if (req.path.startsWith('/api/notifications')) {
|
||||
|
||||
23
web-server/src/document-file-name.js
Normal file
23
web-server/src/document-file-name.js
Normal file
@@ -0,0 +1,23 @@
|
||||
function normalizeDocumentFileName(value) {
|
||||
const fileName = String(value || '');
|
||||
if (!fileName || !/[\u00C2-\u00C5\u00E1\u00E2]/.test(fileName)) return fileName;
|
||||
|
||||
const codePoints = Array.from(fileName, (character) => character.codePointAt(0));
|
||||
if (codePoints.some((codePoint) => codePoint > 0xff)) return fileName;
|
||||
|
||||
const legacyBytes = Buffer.from(codePoints);
|
||||
const decodedName = legacyBytes.toString('utf8');
|
||||
|
||||
if (
|
||||
decodedName.includes('\uFFFD')
|
||||
|| !Buffer.from(decodedName, 'utf8').equals(legacyBytes)
|
||||
) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return decodedName;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDocumentFileName
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const crypto = require('crypto');
|
||||
const { sql, getPool } = require('./db');
|
||||
const { normalizeDocumentFileName } = require('./document-file-name');
|
||||
|
||||
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
||||
const PASSWORD_HASH_ITERATIONS = 120000;
|
||||
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
||||
let emailConfirmationSchemaPromise;
|
||||
let applicationOpenUrlSchemaPromise;
|
||||
let packageTypeSchemaPromise;
|
||||
let documentSchemaPromise;
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -167,7 +169,8 @@ function mapUserRow(row) {
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt),
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ function duplicateApplicationError() {
|
||||
}
|
||||
|
||||
function userHasOwnedDataError() {
|
||||
const error = new Error('User owns packages or applications.');
|
||||
const error = new Error('User owns packages, applications, or documents.');
|
||||
error.code = 'USER_HAS_OWNED_DATA';
|
||||
return error;
|
||||
}
|
||||
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
|
||||
return packageTypeSchemaPromise;
|
||||
}
|
||||
|
||||
async function ensureDocumentSchema() {
|
||||
if (!documentSchemaPromise) {
|
||||
documentSchemaPromise = getPool().then((pool) => pool.request().query(`
|
||||
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_Category_UpdatedAt'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_CreatedByUserId'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
END;
|
||||
`));
|
||||
}
|
||||
|
||||
return documentSchemaPromise;
|
||||
}
|
||||
|
||||
function normalizePackageStatus(isActive) {
|
||||
return isActive ? 'Active' : 'Archived';
|
||||
}
|
||||
@@ -438,6 +504,28 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentRow(row) {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
title: row.Title,
|
||||
category: row.Category,
|
||||
summary: row.Summary || '',
|
||||
content: row.Content || '',
|
||||
hasContent: row.HasContent === undefined ? Boolean(row.Content) : Boolean(row.HasContent),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: normalizeDocumentFileName(row.OriginalFileName || ''),
|
||||
mimeType: row.MimeType || '',
|
||||
fileSizeBytes: Number(row.FileSizeBytes || 0),
|
||||
fileSize: formatFileSize(row.FileSizeBytes),
|
||||
createdByUserId: row.CreatedByUserId ? String(row.CreatedByUserId) : '',
|
||||
createdBy: row.CreatedByUsername || '',
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt || row.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
@@ -494,23 +582,27 @@ async function getUserById(id) {
|
||||
}
|
||||
|
||||
async function getUserOwnershipCounts(userId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('UserId', sql.UniqueIdentifier, userId)
|
||||
.query(`
|
||||
SELECT
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount;
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Documents WHERE CreatedByUserId = @UserId) AS DocumentCount;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
return {
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
@@ -523,7 +615,8 @@ async function listUsers() {
|
||||
u.CreatedAt,
|
||||
u.UpdatedAt,
|
||||
package_count.PackageCount,
|
||||
application_count.ApplicationCount
|
||||
application_count.ApplicationCount,
|
||||
document_count.DocumentCount
|
||||
FROM dbo.Users AS u
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS PackageCount
|
||||
@@ -535,6 +628,11 @@ async function listUsers() {
|
||||
FROM dbo.Applications AS a
|
||||
WHERE a.CreatedByUserId = u.Id
|
||||
) AS application_count
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS DocumentCount
|
||||
FROM dbo.Documents AS d
|
||||
WHERE d.CreatedByUserId = u.Id
|
||||
) AS document_count
|
||||
ORDER BY u.CreatedAt DESC, u.Username ASC;
|
||||
`);
|
||||
|
||||
@@ -865,7 +963,7 @@ async function updateUser(input) {
|
||||
async function deleteUser(userId) {
|
||||
const counts = await getUserOwnershipCounts(userId);
|
||||
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0) {
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0 || counts.documentCount > 0) {
|
||||
throw userHasOwnedDataError();
|
||||
}
|
||||
|
||||
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
|
||||
return result.recordset.length > 0;
|
||||
}
|
||||
|
||||
async function listDocuments() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
CASE WHEN NULLIF(LTRIM(RTRIM(d.Content)), N'') IS NULL THEN 0 ELSE 1 END AS HasContent,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
ORDER BY COALESCE(d.UpdatedAt, d.CreatedAt) DESC, d.Title ASC;
|
||||
`);
|
||||
|
||||
return result.recordset.map(mapDocumentRow);
|
||||
}
|
||||
|
||||
async function getDocumentById(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.NVarChar(100), String(documentId || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1)
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
d.Content,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
WHERE CONVERT(NVARCHAR(36), d.Id) = @Id;
|
||||
`);
|
||||
|
||||
return mapDocumentRow(result.recordset[0]);
|
||||
}
|
||||
|
||||
async function createDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
|
||||
.query(`
|
||||
INSERT dbo.Documents
|
||||
(
|
||||
Title,
|
||||
Category,
|
||||
Summary,
|
||||
Content,
|
||||
FilePath,
|
||||
OriginalFileName,
|
||||
MimeType,
|
||||
FileSizeBytes,
|
||||
CreatedByUserId
|
||||
)
|
||||
OUTPUT inserted.Id
|
||||
VALUES
|
||||
(
|
||||
@Title,
|
||||
@Category,
|
||||
@Summary,
|
||||
@Content,
|
||||
@FilePath,
|
||||
@OriginalFileName,
|
||||
@MimeType,
|
||||
@FileSizeBytes,
|
||||
@CreatedByUserId
|
||||
);
|
||||
`);
|
||||
|
||||
return String(result.recordset[0].Id);
|
||||
}
|
||||
|
||||
async function updateDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const replaceAttachment = Boolean(input.replaceAttachment);
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, input.documentId)
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('ReplaceAttachment', sql.Bit, replaceAttachment ? 1 : 0)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.query(`
|
||||
UPDATE dbo.Documents
|
||||
SET Title = @Title,
|
||||
Category = @Category,
|
||||
Summary = @Summary,
|
||||
Content = @Content,
|
||||
FilePath = CASE WHEN @ReplaceAttachment = 1 THEN @FilePath ELSE FilePath END,
|
||||
OriginalFileName = CASE WHEN @ReplaceAttachment = 1 THEN @OriginalFileName ELSE OriginalFileName END,
|
||||
MimeType = CASE WHEN @ReplaceAttachment = 1 THEN @MimeType ELSE MimeType END,
|
||||
FileSizeBytes = CASE WHEN @ReplaceAttachment = 1 THEN @FileSizeBytes ELSE FileSizeBytes END,
|
||||
UpdatedAt = SYSUTCDATETIME()
|
||||
OUTPUT
|
||||
inserted.Id,
|
||||
deleted.FilePath AS PreviousFilePath,
|
||||
inserted.FilePath AS CurrentFilePath
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
previousFilePath: row.PreviousFilePath || '',
|
||||
currentFilePath: row.CurrentFilePath || '',
|
||||
attachmentChanged: replaceAttachment
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteDocument(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, documentId)
|
||||
.query(`
|
||||
DELETE FROM dbo.Documents
|
||||
OUTPUT
|
||||
deleted.Id,
|
||||
deleted.FilePath,
|
||||
deleted.OriginalFileName
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: row.OriginalFileName || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function getPageData(currentUser) {
|
||||
const [stats, packageRows, applications, activity] = await Promise.all([
|
||||
getStats(),
|
||||
@@ -1635,5 +1901,10 @@ module.exports = {
|
||||
updateApplication,
|
||||
updateApplicationStatus,
|
||||
deleteApplication,
|
||||
removeApplicationPackage
|
||||
removeApplicationPackage,
|
||||
listDocuments,
|
||||
getDocumentById,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument
|
||||
};
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
161
web-server/views/document-detail.ejs
Normal file
161
web-server/views/document-detail.ejs
Normal file
@@ -0,0 +1,161 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page document-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="breadcrumb"><a href="/documents">Tài liệu</a><span>/</span><span><%= helpers.documentCategoryLabel(document.category) %></span></div>
|
||||
<h1><%= document.title %></h1>
|
||||
<p><%= document.summary || 'Tài liệu nội bộ Robot Installer.' %></p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<% if (document.filePath) { %>
|
||||
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
Tải file
|
||||
</a>
|
||||
<% } %>
|
||||
<button class="btn btn-primary" type="button" data-modal-open="editDocumentModal">
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
Chỉnh sửa
|
||||
</button>
|
||||
<form method="post" action="/documents/<%= document.id %>/delete" data-confirm-submit="Xóa tài liệu <%= document.title %> và file đính kèm?">
|
||||
<button class="btn btn-danger" type="submit">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
Xóa
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-detail-grid">
|
||||
<section class="panel document-meta-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Thông tin tài liệu</h2>
|
||||
<p>Thông tin phân loại và tệp lưu trữ.</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div><dt>Nhóm</dt><dd><span class="badge badge-info"><%= helpers.documentCategoryLabel(document.category) %></span></dd></div>
|
||||
<div><dt>Người tạo</dt><dd><%= document.createdBy %></dd></div>
|
||||
<div><dt>Ngày tạo</dt><dd><%= document.createdAt %></dd></div>
|
||||
<div><dt>Cập nhật</dt><dd><%= document.updatedAt %></dd></div>
|
||||
<div><dt>File</dt><dd><%= document.originalFileName || 'Không có' %></dd></div>
|
||||
<div><dt>Dung lượng</dt><dd><%= document.fileSize || '-' %></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel document-reader-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Nội dung</h2>
|
||||
<p>Đọc nội dung và xem trước file được trình duyệt hỗ trợ.</p>
|
||||
</div>
|
||||
<% if (document.filePath) { %>
|
||||
<a class="text-link" href="/documents/<%= document.id %>/file" target="_blank" rel="noopener">Mở file</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="document-reader">
|
||||
<% if (document.content) { %>
|
||||
<article class="document-copy"><%= document.content %></article>
|
||||
<% } %>
|
||||
|
||||
<% if (document.filePath && documentPreviewKind) { %>
|
||||
<section class="document-preview-block">
|
||||
<div class="document-section-heading">
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
<strong>Xem trước: <%= document.originalFileName %></strong>
|
||||
</div>
|
||||
<% if (documentPreviewKind === 'image') { %>
|
||||
<img class="document-image-preview" src="/documents/<%= document.id %>/file" alt="<%= document.title %>">
|
||||
<% } else { %>
|
||||
<iframe
|
||||
class="document-file-preview"
|
||||
src="/documents/<%= document.id %>/file"
|
||||
title="Xem trước <%= document.originalFileName %>"
|
||||
<% if (documentPreviewKind === 'text') { %>sandbox<% } %>
|
||||
></iframe>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } else if (document.filePath) { %>
|
||||
<div class="document-attachment-state">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<div>
|
||||
<strong><%= document.originalFileName %></strong>
|
||||
<p>Trình duyệt không xem trực tiếp định dạng này. Hãy tải file để mở bằng ứng dụng phù hợp.</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">Tải file</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (!document.content && !document.filePath) { %>
|
||||
<div class="table-empty">Tài liệu chưa có nội dung.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="editDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="editDocumentModalTitle">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="editDocumentModalTitle">Chỉnh sửa tài liệu</h3>
|
||||
<p>Cập nhật nội dung hoặc thay thế file đính kèm hiện tại.</p>
|
||||
</div>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" method="post" action="/documents/<%= document.id %>/edit" enctype="multipart/form-data">
|
||||
<div class="form-stack">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Tiêu đề</span>
|
||||
<input type="text" name="title" maxlength="200" required value="<%= document.title %>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select name="category" required>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>" <%= document.category === category.id ? 'selected' : '' %>><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>Mô tả ngắn</span>
|
||||
<textarea name="summary" rows="3" maxlength="1000"><%= document.summary %></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nội dung đọc trực tiếp</span>
|
||||
<textarea name="content" rows="8" maxlength="500000"><%= document.content %></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Thay file đính kèm</span>
|
||||
<input
|
||||
type="file"
|
||||
name="documentFile"
|
||||
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
|
||||
>
|
||||
<small>Để trống nếu muốn giữ file hiện tại. File mới sẽ thay thế file cũ.</small>
|
||||
</label>
|
||||
<% if (document.filePath) { %>
|
||||
<label class="document-remove-option">
|
||||
<input type="checkbox" name="removeAttachment" value="1">
|
||||
<span>Xóa file hiện tại: <strong><%= document.originalFileName %></strong></span>
|
||||
</label>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu thay đổi
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
161
web-server/views/documents.ejs
Normal file
161
web-server/views/documents.ejs
Normal file
@@ -0,0 +1,161 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Tài liệu</h1>
|
||||
<p>Lưu trữ và tra cứu tài liệu giới thiệu, hướng dẫn sử dụng, quy trình và tài liệu kỹ thuật.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" type="button" data-modal-open="createDocumentModal">
|
||||
<span class="material-symbols-outlined">note_add</span>
|
||||
Thêm tài liệu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-filters">
|
||||
<label class="filter-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select data-filter-select data-filter-column="category" data-filter-table="documentsTable">
|
||||
<option value="">Tất cả</option>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>"><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field wide">
|
||||
<span>Tìm kiếm</span>
|
||||
<input type="search" placeholder="Tìm theo tiêu đề, mô tả, tác giả..." data-table-search="documentsTable">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-wrap">
|
||||
<table id="documentsTable" class="data-table documents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tài liệu</th>
|
||||
<th>Nhóm</th>
|
||||
<th>File đính kèm</th>
|
||||
<th>Cập nhật</th>
|
||||
<th>Người tạo</th>
|
||||
<th class="action-col">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (documents.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="6" class="table-empty">Chưa có tài liệu. Bấm Thêm tài liệu để tạo nội dung đầu tiên.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% documents.forEach((item) => { %>
|
||||
<tr
|
||||
data-search="<%= `${item.title} ${item.summary} ${item.createdBy} ${helpers.documentCategoryLabel(item.category)}`.toLowerCase() %>"
|
||||
data-category="<%= item.category %>"
|
||||
>
|
||||
<td class="document-title-cell">
|
||||
<a class="table-title" href="/documents/<%= item.id %>"><%= item.title %></a>
|
||||
<span class="table-subtitle"><%= item.summary || (item.hasContent ? 'Có nội dung đọc trực tiếp' : 'Tài liệu đính kèm') %></span>
|
||||
</td>
|
||||
<td><span class="badge badge-info"><%= helpers.documentCategoryLabel(item.category) %></span></td>
|
||||
<td>
|
||||
<% if (item.filePath) { %>
|
||||
<span class="document-file-name" title="<%= item.originalFileName %>"><%= item.originalFileName %></span>
|
||||
<span class="table-subtitle"><%= item.fileSize %></span>
|
||||
<% } else { %>
|
||||
<span class="table-subtitle">Không có file</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td><%= item.updatedAt %></td>
|
||||
<td><%= item.createdBy %></td>
|
||||
<td class="action-col">
|
||||
<div class="action-group">
|
||||
<a class="icon-button subtle" href="/documents/<%= item.id %>" title="Đọc tài liệu" aria-label="Đọc tài liệu <%= item.title %>">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
</a>
|
||||
<% if (item.filePath) { %>
|
||||
<a class="icon-button subtle" href="/documents/<%= item.id %>/file?download=1" title="Tải file" aria-label="Tải file <%= item.title %>">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
</a>
|
||||
<% } %>
|
||||
<form method="post" action="/documents/<%= item.id %>/delete" data-confirm-submit="Xóa tài liệu <%= item.title %> và file đính kèm?">
|
||||
<button class="icon-button danger" type="submit" title="Xóa tài liệu" aria-label="Xóa tài liệu <%= item.title %>">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="page-pager">
|
||||
<span>Hiển thị <%= documents.length %> tài liệu</span>
|
||||
<div>
|
||||
<button type="button" disabled>Trước</button>
|
||||
<span>Trang 1 / 1</span>
|
||||
<button type="button" disabled>Sau</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="createDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="createDocumentModalTitle">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="createDocumentModalTitle">Thêm tài liệu</h3>
|
||||
<p>Nhập nội dung để đọc trực tiếp, đính kèm file, hoặc sử dụng cả hai.</p>
|
||||
</div>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" method="post" action="/documents" enctype="multipart/form-data">
|
||||
<div class="form-stack">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Tiêu đề</span>
|
||||
<input type="text" name="title" maxlength="200" required placeholder="Ví dụ: Hướng dẫn cài đặt Robot">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select name="category" required>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>"><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>Mô tả ngắn</span>
|
||||
<textarea name="summary" rows="3" maxlength="1000" placeholder="Nội dung chính và đối tượng sử dụng tài liệu này."></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nội dung đọc trực tiếp</span>
|
||||
<textarea name="content" rows="8" maxlength="500000" placeholder="Nhập nội dung hướng dẫn tại đây. Xuống dòng và khoảng trắng sẽ được giữ nguyên khi hiển thị."></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>File đính kèm</span>
|
||||
<input
|
||||
type="file"
|
||||
name="documentFile"
|
||||
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
|
||||
>
|
||||
<small>Hỗ trợ PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint và Excel; tối đa 50 MB mặc định.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu tài liệu
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<small>APT packages only store metadata and do not need an uploaded file. Package code must be an Agent-allowlisted APT name, for example <code>postgresql</code>.</small>
|
||||
<small>APT packages only store metadata and do not need an uploaded file. Enter the exact package name accepted by <code>apt-get install</code>, for example <code>postgresql</code>, <code>nginx</code>, or <code>redis-server</code>. Agents using <code>ALLOWED_APT_PACKAGES=*</code> accept any valid package code.</small>
|
||||
</div>
|
||||
<label class="form-field full">
|
||||
<span>Docker image/tag</span>
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="app-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<aside id="appSidebar" class="sidebar" aria-label="Main navigation">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
data-user-updated-at="<%= user.updatedAt %>"
|
||||
data-user-package-count="<%= user.packageCount %>"
|
||||
data-user-application-count="<%= user.applicationCount %>"
|
||||
data-user-document-count="<%= user.documentCount %>"
|
||||
>
|
||||
<td>
|
||||
<span class="table-title"><%= user.name %></span>
|
||||
@@ -126,6 +127,7 @@
|
||||
<td>
|
||||
<span class="table-subtitle"><%= user.packageCount %> packages</span>
|
||||
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
|
||||
<span class="table-subtitle"><%= user.documentCount %> tài liệu</span>
|
||||
</td>
|
||||
<td>
|
||||
<% if (user.id === currentUser.id) { %>
|
||||
|
||||
Reference in New Issue
Block a user