Compare commits

..

18 Commits

Author SHA1 Message Date
47407cdbca remove app 2026-06-08 10:54:52 +07:00
f9bec78c82 fix UI client 2026-06-08 09:27:55 +07:00
08b94337ad fix remote ubuntu server 2026-06-05 14:56:03 +07:00
4b65838f0f fix UI 2026-06-04 14:58:54 +07:00
ce3b3c900a UI cảnh báo cho máy không phải linux 2026-06-03 15:29:56 +07:00
c01d9c7e40 change UI set Endpoint 2026-06-03 09:12:51 +07:00
e7dce2f0e9 dowload package 2026-06-02 09:22:24 +07:00
9cedf085a3 date time 2026-05-29 14:15:06 +07:00
991d6f5257 done fix 2026-05-28 14:26:02 +07:00
b0443d5950 fix server - agent 2026-05-27 08:37:47 +07:00
13765e58d2 the last 2026-05-26 16:04:37 +07:00
8ceb1bb1df laster 0.0.2 2026-05-26 15:43:56 +07:00
e2c4881bb7 laster 0.0.1 2026-05-25 15:49:42 +07:00
14d3a3152a push 2026-05-25 09:54:41 +07:00
a033562c4c fix client 2026-05-25 09:14:52 +07:00
9e6f57be35 client 2026-05-22 17:05:39 +07:00
582960cc32 agent 2026-05-22 16:47:51 +07:00
190d2418da web server 2026-05-20 14:10:25 +07:00
86 changed files with 18118 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
node_modules/
dist/
.env
agent/.venv/
agent/build/
web-client/dist/
web-server/uploads/
docs/*

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/agent/.venv/Scripts/python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}/agent"
],
"python.terminal.activateEnvironment": true
}

78
agent/README.md Normal file
View File

@@ -0,0 +1,78 @@
# Local Installer Agent
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`, plus Docker image components from allowed registries when Docker support is enabled.
## Development
```bash
cd agent
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 127.0.0.1 --port 5010
```
## Install script URL
Client machines should keep using the stable installer command:
```bash
curl -fsSL https://robot.package/install-agent.sh | sudo bash
```
The web server resolves `https://robot.package/packages/agent/latest.deb?arch=<dpkg-arch>`
to the newest uploaded `local-installer-agent_<version>_<arch>.deb`, so updating the Agent does not require editing the install command.
Agent packages are uploaded from the web server Admin page at `/agent`. The default storage folder is
`web-server/uploads/packages/agent`, and it can be changed with `AGENT_PACKAGE_DIR`.
## Important API
```text
GET /health
GET /system-info
GET /apps/installed
POST /apps/install
POST /apps/update
POST /apps/remove
GET /tasks/{taskId}
GET /tasks/{taskId}/logs
GET /tasks/{taskId}/components
POST /services/start
POST /services/stop
POST /services/restart
GET /services/{serviceName}/status
```
`POST /apps/install` supports both:
```json
{
"appId": "robot-suite",
"version": "1.0.0"
}
```
and a direct single `.deb` payload:
```json
{
"appId": "robot-web-app",
"appName": "Robot Web App",
"packageName": "robot-web-app",
"serviceName": "robot-web-app.service",
"version": "1.0.0",
"downloadUrl": "https://robot.package/packages/robot-web-app_1.0.0_amd64.deb",
"checksum": "sha256_hash_here"
}
```
For manifest mode, the Agent fetches:
```text
{ROBOT_PACKAGE_BASE_URL}/api/apps/{appId}/versions/{version}/manifest
```
Docker image components require Docker Engine on the client machine. By default `AUTO_INSTALL_DOCKER=true`, so the agent will install the trusted distro package `docker.io` with `apt-get` when a Docker app is installed and Docker is missing. Set `AUTO_INSTALL_DOCKER=false` if Docker must be provisioned by your own fleet policy. The agent validates `image` against `ALLOWED_DOCKER_REGISTRIES`, then runs a managed container using the manifest fields `containerName`, `restartPolicy`, `ports`, `volumes`, and `env`.

2
agent/app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Local Installer Agent package."""

View File

@@ -0,0 +1,2 @@
"""API routers for the Local Installer Agent."""

53
agent/app/api/apps.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.core.task_runner import TaskRunner
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
from app.storage.repository import Repository
router = APIRouter(prefix="/apps", tags=["apps"])
repository = Repository()
task_runner = TaskRunner(repository)
def _task_id(prefix: str) -> str:
return f"task_{prefix}_{uuid.uuid4().hex[:12]}"
@router.get("/installed")
def installed_apps() -> list[dict]:
return repository.list_installed_apps()
@router.post("/install")
def install_app(request: InstallRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
if not request.version:
raise HTTPException(status_code=400, detail="version is required")
task_id = _task_id("install")
repository.create_task(task_id, "install", request.app_id, request.app_name)
background_tasks.add_task(task_runner.run_install, task_id, request, "install")
return {"taskId": task_id, "status": "queued"}
@router.post("/update")
def update_app(request: UpdateRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
if not request.version:
raise HTTPException(status_code=400, detail="version or targetVersion is required")
task_id = _task_id("update")
repository.create_task(task_id, "update", request.app_id, request.app_name)
background_tasks.add_task(task_runner.run_install, task_id, request, "update")
return {"taskId": task_id, "status": "queued"}
@router.post("/remove")
def remove_app(request: RemoveRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
if not request.package_name and not repository.list_installed_components(request.app_id):
raise HTTPException(status_code=400, detail="packageName is required when app is not tracked locally")
task_id = _task_id("remove")
repository.create_task(task_id, "remove", request.app_id, request.app_id)
background_tasks.add_task(task_runner.run_remove, task_id, request)
return {"taskId": task_id, "status": "queued"}

38
agent/app/api/health.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import platform
import shutil
import socket
from fastapi import APIRouter
from app.config import settings
router = APIRouter()
@router.get("/health")
def health() -> dict[str, str]:
return {
"status": "online",
"agentVersion": settings.agent_version,
"hostname": socket.gethostname(),
"os": platform.platform(),
"architecture": platform.machine(),
}
@router.get("/system-info")
def system_info() -> dict[str, str]:
disk = shutil.disk_usage("/")
memory_total = "unknown"
return {
"hostname": socket.gethostname(),
"os": platform.platform(),
"kernel": platform.release(),
"architecture": platform.machine(),
"diskFree": f"{disk.free // (1024 ** 3)}GB",
"memoryTotal": memory_total,
}

40
agent/app/api/services.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.command_runner import CommandRunner
from app.core.service_manager import ServiceManager
from app.models.schemas import ServiceRequest
from app.storage.repository import Repository
router = APIRouter(prefix="/services", tags=["services"])
def _manager() -> ServiceManager:
return ServiceManager(CommandRunner(Repository()))
@router.post("/start")
def start_service(request: ServiceRequest) -> dict[str, str]:
_manager().start_service(request.service_name)
return {"serviceName": request.service_name, "status": "started"}
@router.post("/stop")
def stop_service(request: ServiceRequest) -> dict[str, str]:
_manager().stop_service(request.service_name)
return {"serviceName": request.service_name, "status": "stopped"}
@router.post("/restart")
def restart_service(request: ServiceRequest) -> dict[str, str]:
_manager().restart_service(request.service_name)
return {"serviceName": request.service_name, "status": "restarted"}
@router.get("/{service_name}/status")
def service_status(service_name: str) -> dict[str, object]:
request = ServiceRequest(serviceName=service_name)
return _manager().get_service_status(request.service_name)

74
agent/app/api/tasks.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.storage.repository import Repository
router = APIRouter(prefix="/tasks", tags=["tasks"])
repository = Repository()
def _task_response(row: dict) -> dict:
return {
"taskId": row["id"],
"type": row["type"],
"appId": row["app_id"],
"appName": row["app_name"],
"status": row["status"],
"progress": row["progress"],
"currentStep": row["current_step"],
"currentComponentId": row["current_component_id"],
"errorMessage": row["error_message"],
"createdAt": row["created_at"],
"startedAt": row["started_at"],
"finishedAt": row["finished_at"],
}
@router.get("/{task_id}")
def get_task(task_id: str) -> dict:
task = repository.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return _task_response(task)
@router.get("/{task_id}/logs")
def get_task_logs(task_id: str) -> dict:
if not repository.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found")
return {
"taskId": task_id,
"logs": [
{
"time": item["timestamp"],
"level": item["level"],
"message": item["message"],
}
for item in repository.get_task_logs(task_id)
],
}
@router.get("/{task_id}/components")
def get_task_components(task_id: str) -> dict:
if not repository.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found")
return {
"taskId": task_id,
"components": [
{
"componentId": item["component_id"],
"type": item["type"],
"status": item["status"],
"progress": item["progress"],
"currentStep": item["current_step"],
"errorMessage": item["error_message"],
"startedAt": item["started_at"],
"finishedAt": item["finished_at"],
}
for item in repository.get_task_components(task_id)
],
}

103
agent/app/config.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from urllib.parse import urlparse
def _csv(value: str | None, fallback: list[str]) -> list[str]:
if not value:
return fallback
return [item.strip() for item in value.split(",") if item.strip()]
def _csv_with_defaults(value: str | None, defaults: list[str]) -> list[str]:
items = _csv(value, [])
seen = set(items)
for item in defaults:
if item not in seen:
items.append(item)
seen.add(item)
return items
def _default_allowed_download_hosts(base_url: str) -> list[str]:
parsed = urlparse(base_url)
if parsed.hostname:
return [parsed.hostname]
return ["package.pnkr.cloud"]
@dataclass(frozen=True)
class Settings:
agent_version: str
host: str
port: int
robot_package_base_url: str
allowed_origins: list[str]
allowed_download_hosts: list[str]
allowed_docker_registries: list[str]
cache_dir: Path
app_dir: Path
log_dir: Path
db_path: Path
allow_remove: bool
allow_purge: bool
allow_docker: bool
allow_docker_compose: bool
auto_install_docker: bool
command_timeout_seconds: int
def _bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@lru_cache(maxsize=1)
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"),
host=os.getenv("AGENT_HOST", "0.0.0.0"),
port=int(os.getenv("AGENT_PORT", "5010")),
robot_package_base_url=robot_package_base_url,
allowed_origins=_csv(
os.getenv("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=_csv(
os.getenv("ALLOWED_DOWNLOAD_HOSTS"),
_default_allowed_download_hosts(robot_package_base_url),
),
allowed_docker_registries=_csv_with_defaults(
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
["registry.robot.package", "docker.io"],
),
cache_dir=Path(os.getenv("CACHE_DIR", "/var/cache/local-installer-agent/packages")),
app_dir=Path(os.getenv("APP_DIR", "/opt/robot-apps")),
log_dir=Path(os.getenv("LOG_DIR", "/var/log/local-installer-agent")),
db_path=Path(os.getenv("DB_PATH", "/var/lib/local-installer-agent/agent.db")),
allow_remove=_bool("ALLOW_REMOVE", True),
allow_purge=_bool("ALLOW_PURGE", True),
allow_docker=_bool("ALLOW_DOCKER", True),
allow_docker_compose=_bool("ALLOW_DOCKER_COMPOSE", False),
auto_install_docker=_bool("AUTO_INSTALL_DOCKER", True),
command_timeout_seconds=int(os.getenv("COMMAND_TIMEOUT_SECONDS", "900")),
)
settings = get_settings()

View File

@@ -0,0 +1,2 @@
"""Core installers and task orchestration."""

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import hashlib
from pathlib import Path
def sha256_file(file_path: Path) -> str:
digest = hashlib.sha256()
with file_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_sha256(file_path: Path, expected: str) -> bool:
return sha256_file(file_path).lower() == expected.lower()

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import os
import subprocess
from collections.abc import Mapping
from app.config import settings
from app.storage.repository import Repository
def _tail_output(label: str, output: str, line_limit: int = 6) -> str:
lines = [line.strip() for line in output.splitlines() if line.strip()]
if not lines:
return ""
return f"{label}: {' | '.join(lines[-line_limit:])}"
def _command_output_summary(stdout: str, stderr: str) -> str:
parts = [
part
for part in (
_tail_output("stderr", stderr),
_tail_output("stdout", stdout),
)
if part
]
summary = " ; ".join(parts)
return summary[:1600]
class CommandError(RuntimeError):
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str) -> None:
message = f"Command failed with exit code {returncode}: {' '.join(command)}"
output_summary = _command_output_summary(stdout, stderr)
if output_summary:
message = f"{message}. Last output: {output_summary}"
super().__init__(message)
self.command = command
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class CommandRunner:
def __init__(self, repository: Repository, task_id: str | None = None) -> None:
self.repository = repository
self.task_id = task_id
def run(
self,
command: list[str],
timeout: int | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
if self.task_id:
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
command_env = os.environ.copy()
if env:
command_env.update(env)
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
env=command_env,
stdin=subprocess.DEVNULL,
text=True,
timeout=timeout or settings.command_timeout_seconds,
)
except subprocess.TimeoutExpired as error:
if self.task_id:
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:
for line in result.stdout.splitlines():
self.repository.add_log(self.task_id, "debug", line)
for line in result.stderr.splitlines():
self.repository.add_log(self.task_id, "warning", line)
if result.returncode != 0:
raise CommandError(command, result.returncode, result.stdout, result.stderr)
return result

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import shutil
from app.core.command_runner import CommandError, CommandRunner
DOCKER_REQUIRED_MESSAGE = "Docker runtime is required"
def image_reference(component: dict) -> str:
image = component["image"]
digest = component.get("digest")
tag = component.get("tag")
if "@" in image:
return image
if digest:
return f"{_image_without_tag(image)}@{digest}"
if _image_has_tag(image) or not tag:
return image
return f"{image}:{tag}"
def _image_has_tag(image: str) -> bool:
last_slash = image.rfind("/")
last_colon = image.rfind(":")
return last_colon > last_slash
def _image_without_tag(image: str) -> str:
if not _image_has_tag(image):
return image
return image[: image.rfind(":")]
class DockerInstaller:
def __init__(self, command_runner: CommandRunner) -> None:
self.command_runner = command_runner
def ensure_runtime(self, auto_install: bool = False) -> None:
if not shutil.which("docker"):
if not auto_install:
raise RuntimeError(DOCKER_REQUIRED_MESSAGE)
self.install_runtime()
try:
self._verify_runtime()
return
except CommandError as error:
self._log("warning", self._runtime_error_message(error))
self._start_runtime()
try:
self._verify_runtime()
except CommandError as error:
raise RuntimeError(self._runtime_error_message(error)) from error
def install_runtime(self) -> None:
if not shutil.which("apt-get"):
raise RuntimeError(
f"{DOCKER_REQUIRED_MESSAGE}. Install Docker Engine on this client or disable Docker packages."
)
self._log("info", "Docker runtime not found; installing docker.io")
try:
self.command_runner.run(["apt-get", "update"], timeout=600)
self.command_runner.run(
["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "docker.io"],
timeout=1200,
)
except CommandError as error:
raise RuntimeError(
f"{DOCKER_REQUIRED_MESSAGE}: failed to install docker.io with apt-get: "
f"{self._command_error_detail(error)}"
) from error
self._start_runtime()
def _verify_runtime(self) -> None:
self.command_runner.run(["docker", "version", "--format", "{{.Server.Version}}"])
def _start_runtime(self) -> None:
if shutil.which("systemctl"):
try:
self.command_runner.run(["systemctl", "enable", "--now", "docker"], timeout=120)
return
except CommandError as error:
self._log("warning", f"Could not start Docker with systemctl: {self._command_error_detail(error)}")
if shutil.which("service"):
try:
self.command_runner.run(["service", "docker", "start"], timeout=120)
except CommandError as error:
self._log("warning", f"Could not start Docker service: {self._command_error_detail(error)}")
def _runtime_error_message(self, error: CommandError) -> str:
detail = self._command_error_detail(error)
return f"{DOCKER_REQUIRED_MESSAGE}: {detail}" if detail else DOCKER_REQUIRED_MESSAGE
def _command_error_detail(self, error: CommandError) -> str:
output = (error.stderr or error.stdout or "").strip()
if not output:
return str(error)
return output.splitlines()[-1].strip()
def _log(self, level: str, message: str) -> None:
if self.command_runner.task_id:
self.command_runner.repository.add_log(self.command_runner.task_id, level, message)
def pull_image(self, reference: str) -> None:
self.command_runner.run(["docker", "pull", reference])
def recreate_container(self, app_id: str, component: dict) -> None:
container_name = component["containerName"]
reference = image_reference(component)
self.remove_container(container_name)
command = [
"docker",
"run",
"-d",
"--name",
container_name,
"--restart",
component.get("restartPolicy", "unless-stopped"),
"--label",
f"local-installer-agent.app-id={app_id}",
"--label",
f"local-installer-agent.component-id={component['componentId']}",
]
for name, value in sorted((component.get("env") or {}).items()):
command.extend(["-e", f"{name}={value}"])
for port in component.get("ports") or []:
command.extend(["-p", port])
for volume in component.get("volumes") or []:
command.extend(["-v", volume])
command.append(reference)
self.command_runner.run(command)
def remove_container(self, container_name: str, remove_volumes: bool = False) -> None:
result = self.command_runner.run([
"docker",
"ps",
"-aq",
"--filter",
f"name=^/{container_name}$",
])
if result.stdout.strip():
command = ["docker", "rm", "-f"]
if remove_volumes:
command.append("-v")
command.append(container_name)
self.command_runner.run(command)
def remove_labeled_containers(self, app_id: str, component_id: str, remove_volumes: bool = False) -> None:
result = self.command_runner.run([
"docker",
"ps",
"-aq",
"--filter",
f"label=local-installer-agent.app-id={app_id}",
"--filter",
f"label=local-installer-agent.component-id={component_id}",
])
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if not container_ids:
return
command = ["docker", "rm", "-f"]
if remove_volumes:
command.append("-v")
command.extend(container_ids)
self.command_runner.run(command)
def remove_image(self, reference: str) -> None:
self.command_runner.run(["docker", "image", "rm", reference])
def assert_container_running(self, container_name: str) -> None:
result = self.command_runner.run([
"docker",
"inspect",
"-f",
"{{.State.Running}}",
container_name,
])
if result.stdout.strip().lower() != "true":
raise RuntimeError(f"Docker container is not running: {container_name}")

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from email.message import Message
import re
from pathlib import Path
from urllib.parse import unquote, urlparse
import httpx
from app.config import settings
from app.storage.repository import Repository
from app.utils.validators import validate_url_host
SAFE_FILE_RE = re.compile(r"[^a-zA-Z0-9._+-]+")
def _sanitize_file_name(value: str, fallback: str = "package.deb") -> str:
name = SAFE_FILE_RE.sub("-", value).strip("-.")
return name or fallback
def _safe_url_file_name(url: str) -> str:
parsed = urlparse(url)
name = Path(unquote(parsed.path)).name or "package.deb"
if name == "download":
parts = [part for part in parsed.path.split("/") if part]
name = "-".join(parts[-3:]) if len(parts) >= 3 else name
return _sanitize_file_name(name)
def _content_disposition_file_name(value: str) -> str:
if not value:
return ""
message = Message()
message["content-disposition"] = value
return _sanitize_file_name(message.get_filename() or "", fallback="")
def _response_file_name(url: str, response: httpx.Response) -> str:
name = _content_disposition_file_name(response.headers.get("content-disposition", ""))
if not name:
name = _safe_url_file_name(str(response.url) or url)
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type == "application/vnd.debian.binary-package" and not name.lower().endswith(".deb"):
name = f"{name}.deb"
return name
class Downloader:
def __init__(self, repository: Repository, task_id: str) -> None:
self.repository = repository
self.task_id = task_id
def download(self, url: str) -> Path:
validate_url_host(url, settings.allowed_download_hosts)
settings.cache_dir.mkdir(parents=True, exist_ok=True)
self.repository.add_log(self.task_id, "info", f"Downloading {url}")
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response:
response.raise_for_status()
self._validate_response(url, response)
destination = settings.cache_dir / _response_file_name(url, response)
with destination.open("wb") as handle:
for chunk in response.iter_bytes():
handle.write(chunk)
self.repository.add_log(self.task_id, "info", f"Downloaded to {destination}")
return destination
def _validate_response(self, requested_url: str, response: httpx.Response) -> None:
final_url = str(response.url)
validate_url_host(final_url, settings.allowed_download_hosts)
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type in {"text/html", "text/plain"}:
raise ValueError(
"download did not return a package file "
f"(requested {requested_url}, final {final_url}, content-type {content_type or 'unknown'})"
)

103
agent/app/core/installer.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from pathlib import Path
from app.core.command_runner import CommandRunner
APT_DPKG_OPTIONS = [
"-o",
"Dpkg::Use-Pty=0",
"-o",
"Dpkg::Options::=--force-confdef",
"-o",
"Dpkg::Options::=--force-confold",
]
APT_NONINTERACTIVE_ENV = {
"DEBIAN_FRONTEND": "noninteractive",
"DEBCONF_NONINTERACTIVE_SEEN": "true",
"APT_LISTCHANGES_FRONTEND": "none",
}
def _parse_deb_control_output(output: str) -> dict[str, str]:
metadata: dict[str, str] = {}
for line in output.splitlines():
key, separator, value = line.partition(":")
if not separator:
continue
normalized_key = key.strip().lower()
if normalized_key in {"package", "version", "architecture"}:
metadata[normalized_key] = value.strip()
return metadata
class DebInstaller:
def __init__(self, command_runner: CommandRunner) -> None:
self.command_runner = command_runner
def get_deb_metadata(self, file_path: Path) -> dict[str, str]:
result = self.command_runner.run([
"dpkg-deb",
"-f",
str(file_path),
"Package",
"Version",
"Architecture",
])
metadata = _parse_deb_control_output(result.stdout)
missing_fields = [
field
for field in ("package", "version", "architecture")
if not metadata.get(field)
]
if missing_fields:
raise ValueError(
"Downloaded .deb is missing metadata fields: "
f"{', '.join(missing_fields)}"
)
return metadata
def install_deb(self, file_path: Path) -> None:
self.command_runner.run(
[
"apt-get",
*APT_DPKG_OPTIONS,
"install",
"--yes",
str(file_path),
],
env=APT_NONINTERACTIVE_ENV,
)
def remove_package(self, package_name: str, purge: bool = False) -> None:
action = "purge" if purge else "remove"
self.command_runner.run(
["apt-get", *APT_DPKG_OPTIONS, action, "--yes", package_name],
env=APT_NONINTERACTIVE_ENV,
)
def cleanup_after_remove(self, purge: bool = False) -> None:
autoremove_command = ["apt-get", *APT_DPKG_OPTIONS, "autoremove", "--yes"]
if purge:
autoremove_command.append("--purge")
self.command_runner.run(autoremove_command, env=APT_NONINTERACTIVE_ENV)
self.command_runner.run(["apt-get", "clean"], env=APT_NONINTERACTIVE_ENV)
def get_package_version(self, package_name: str) -> str | None:
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
version = result.stdout.strip()
return version or None
def check_package_installed(self, package_name: str) -> bool:
try:
self.get_package_version(package_name)
return True
except Exception:
return False

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from urllib.parse import quote
import httpx
from app.config import settings
class ManifestClient:
def fetch_manifest(self, app_id: str, version: str) -> dict:
app_id_part = quote(app_id, safe="")
version_part = quote(version, safe="")
url = f"{settings.robot_package_base_url}/api/apps/{app_id_part}/versions/{version_part}/manifest"
response = httpx.get(url, follow_redirects=True, timeout=30)
if response.is_error:
raise RuntimeError(_format_manifest_error(response))
return response.json()
def _format_manifest_error(response: httpx.Response) -> str:
base_message = f"Manifest request failed with HTTP {response.status_code}: {response.url}"
try:
payload = response.json()
except ValueError:
detail = response.text.strip()
return f"{base_message}. {detail}" if detail else base_message
if not isinstance(payload, dict):
return base_message
message_parts = []
error = str(payload.get("error") or "").strip()
detail = str(payload.get("detail") or "").strip()
if error:
message_parts.append(error)
if detail:
message_parts.append(detail)
missing_files = payload.get("missingPackageFiles")
if isinstance(missing_files, list) and missing_files:
descriptions = []
for item in missing_files:
if not isinstance(item, dict):
continue
package_name = str(item.get("packageName") or item.get("componentId") or "package").strip()
version = str(item.get("version") or "").strip()
descriptions.append(f"{package_name} {version}".strip())
if descriptions:
message_parts.append(f"Missing package files: {', '.join(descriptions)}")
return f"{base_message}. {' '.join(message_parts)}" if message_parts else base_message

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from app.config import settings
from app.models.schemas import AppManifest, DebComponent, DockerComponent
from app.utils.validators import validate_docker_registry, validate_url_host
class ManifestValidator:
def validate(self, payload: dict) -> dict:
manifest = AppManifest.model_validate(payload).model_dump(by_alias=True)
components = []
for raw_component in manifest["components"]:
component_type = raw_component.get("type")
if component_type == "deb":
component = DebComponent.model_validate(raw_component).model_dump(by_alias=True)
validate_url_host(component["downloadUrl"], settings.allowed_download_hosts)
components.append(component)
elif component_type == "docker":
if not settings.allow_docker:
raise ValueError("Docker components are not enabled on this Agent")
component = DockerComponent.model_validate(raw_component).model_dump(by_alias=True)
validate_docker_registry(component["image"], settings.allowed_docker_registries)
components.append(component)
elif component_type == "docker_compose":
if not settings.allow_docker_compose:
raise ValueError("Docker Compose components are not enabled on this Agent")
raise ValueError("Docker Compose components are not implemented on this Agent")
else:
raise ValueError(f"Unsupported component type: {component_type}")
manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10))
return manifest

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from app.core.command_runner import CommandRunner
class ServiceManager:
def __init__(self, command_runner: CommandRunner) -> None:
self.command_runner = command_runner
def enable_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "enable", service_name])
def disable_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "disable", service_name])
def start_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "start", service_name])
def stop_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "stop", service_name])
def restart_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "restart", service_name])
def reset_failed(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "reset-failed", 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"
return {
"serviceName": service_name,
"active": active,
"enabled": enabled,
"status": "running" if active else "stopped",
}
def _query(self, command: list[str]) -> str:
try:
return self.command_runner.run(command).stdout.strip()
except Exception:
return "unknown"

View File

@@ -0,0 +1,387 @@
from __future__ import annotations
import hashlib
import os
import traceback
from typing import Any
from app.config import settings
from app.core.checksum import sha256_file
from app.core.command_runner import CommandRunner
from app.core.downloader import Downloader
from app.core.docker_installer import DockerInstaller, image_reference
from app.core.installer import DebInstaller
from app.core.manifest_client import ManifestClient
from app.core.manifest_validator import ManifestValidator
from app.core.service_manager import ServiceManager
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
from app.storage.repository import Repository, utc_now
class TaskRunner:
def __init__(self, repository: Repository) -> None:
self.repository = repository
self.manifest_client = ManifestClient()
self.manifest_validator = ManifestValidator()
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
try:
self._mark_started(task_id, f"starting {task_type}")
self._require_root_if_available()
manifest = self._resolve_manifest(request)
self.repository.add_log(task_id, "info", f"Installing {manifest['appId']} {manifest['version']}")
self._install_manifest(task_id, manifest)
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"),
)
self.repository.update_task(
task_id,
status="success",
progress=100,
current_step="completed",
finished_at=utc_now(),
)
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
except Exception as error:
self._fail_task(task_id, error)
def run_remove(self, task_id: str, request: RemoveRequest) -> None:
try:
if not settings.allow_remove:
raise ValueError("Remove is disabled on this Agent")
self._mark_started(task_id, "starting remove")
self._require_root_if_available()
effective_purge = request.purge and settings.allow_purge
if request.purge and not effective_purge:
self.repository.add_log(
task_id,
"warning",
"Purge cleanup was requested but ALLOW_PURGE is disabled; falling back to remove cleanup",
)
components = self.repository.list_installed_components(request.app_id)
if not components and request.package_name:
components = [
{
"component_id": request.package_name,
"type": "deb",
"install_order": 10,
"package_name": request.package_name,
"service_name": request.service_name,
}
]
if not components:
raise ValueError("No installed components found for this app")
command_runner = CommandRunner(self.repository, task_id)
installer = DebInstaller(command_runner)
services = ServiceManager(command_runner)
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
total = len(ordered)
removed_deb_package = False
for index, component in enumerate(ordered, start=1):
progress = int((index - 1) / total * 80) + 10
component_id = component["component_id"]
self.repository.update_task(
task_id,
progress=progress,
current_step=f"removing {component_id}",
current_component_id=component_id,
)
service_name = component.get("service_name")
if service_name:
self.repository.add_log(task_id, "info", f"Stopping service {service_name}")
self._best_effort(task_id, f"stop service {service_name}", lambda: services.stop_service(service_name))
self._best_effort(task_id, f"disable service {service_name}", lambda: services.disable_service(service_name))
package_name = component.get("package_name")
if component["type"] == "deb" and package_name:
self.repository.add_log(task_id, "info", f"Removing package {package_name}")
installer.remove_package(package_name, purge=effective_purge)
self._clean_cached_package_files(task_id, package_name, component_id)
if service_name:
self._best_effort(task_id, f"reset failed state for {service_name}", lambda: services.reset_failed(service_name))
removed_deb_package = True
elif component["type"] == "docker":
container_name = component.get("container_name") or component_id
self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}")
docker_installer = DockerInstaller(command_runner)
docker_installer.ensure_runtime(auto_install=settings.auto_install_docker)
docker_installer.remove_labeled_containers(
request.app_id,
component_id,
remove_volumes=effective_purge,
)
docker_installer.remove_container(container_name, remove_volumes=effective_purge)
image = component.get("docker_image") or component.get("image")
if effective_purge and image:
self._best_effort(task_id, f"remove Docker image {image}", lambda: docker_installer.remove_image(image))
else:
raise ValueError(f"Unsupported installed component type: {component['type']}")
if removed_deb_package:
self.repository.update_task(task_id, progress=92, current_step="cleaning package leftovers")
self._best_effort(
task_id,
"clean unused packages and apt cache",
lambda: installer.cleanup_after_remove(purge=effective_purge),
)
self.repository.delete_installed_app(request.app_id)
self.repository.update_task(
task_id,
status="success",
progress=100,
current_step="completed",
finished_at=utc_now(),
)
except Exception as error:
self._fail_task(task_id, error)
def _resolve_manifest(self, request: InstallRequest | UpdateRequest) -> dict[str, Any]:
if request.download_url:
digest = request.sha256 or request.checksum
return self.manifest_validator.validate(
{
"schemaVersion": "1.0",
"appId": request.app_id,
"appName": request.app_name or request.app_id,
"version": request.version,
"architecture": "amd64",
"components": [
{
"componentId": request.package_name,
"type": "deb",
"installOrder": 10,
"required": True,
"packageName": request.package_name,
"version": request.version,
"downloadUrl": request.download_url,
"sha256": digest,
"serviceName": request.service_name,
}
],
}
)
payload = self.manifest_client.fetch_manifest(request.app_id, request.version)
return self.manifest_validator.validate(payload)
def _install_manifest(self, task_id: str, manifest: dict[str, Any]) -> None:
components = manifest["components"]
for component in components:
self.repository.create_task_component(
task_id,
manifest["appId"],
component["componentId"],
component["type"],
component.get("installOrder", 10),
)
total = len(components)
if total == 0:
raise ValueError("Manifest has no installable components")
for index, component in enumerate(components, start=1):
base_progress = int((index - 1) / total * 80) + 10
component_id = component["componentId"]
self.repository.update_task(
task_id,
progress=base_progress,
current_step=f"installing {component_id}",
current_component_id=component_id,
)
self.repository.update_task_component(
task_id,
component_id,
status="running",
progress=5,
current_step="preparing",
started_at=utc_now(),
)
if component["type"] == "deb":
self._install_deb_component(task_id, manifest["appId"], component)
elif component["type"] == "docker":
self._install_docker_component(task_id, manifest["appId"], component)
else:
raise ValueError(f"Unsupported component type: {component['type']}")
self.repository.update_task_component(
task_id,
component_id,
status="success",
progress=100,
current_step="completed",
finished_at=utc_now(),
)
def _install_deb_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
component_id = component["componentId"]
downloader = Downloader(self.repository, task_id)
command_runner = CommandRunner(self.repository, task_id)
installer = DebInstaller(command_runner)
services = ServiceManager(command_runner)
self.repository.update_task_component(task_id, component_id, progress=10, current_step="downloading")
package_path = downloader.download(component["downloadUrl"])
self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum")
actual_sha256 = sha256_file(package_path)
expected_sha256 = component["sha256"].lower()
if actual_sha256.lower() != expected_sha256:
raise ValueError(
f"Checksum mismatch for {component_id}: expected {expected_sha256}, got {actual_sha256}"
)
self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}")
self.repository.update_task_component(task_id, component_id, progress=50, current_step="validating package metadata")
deb_metadata = installer.get_deb_metadata(package_path)
expected_package_name = component["packageName"]
actual_package_name = deb_metadata["package"]
if actual_package_name != expected_package_name:
raise ValueError(
f"Package metadata mismatch for {component_id}: manifest packageName is "
f"{expected_package_name}, but .deb Package is {actual_package_name}. "
f"Create or update the package in the web server with Package code {actual_package_name}."
)
expected_version = component.get("version") or ""
actual_version = deb_metadata["version"]
if expected_version and actual_version != expected_version:
raise ValueError(
f"Package metadata mismatch for {component_id}: manifest version is "
f"{expected_version}, but .deb Version is {actual_version}."
)
self.repository.add_log(
task_id,
"info",
f"Package metadata verified for {actual_package_name} {actual_version}",
)
self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package")
installer.install_deb(package_path)
self.repository.update_task_component(task_id, component_id, progress=75, current_step="verifying package")
installed_version = installer.get_package_version(component["packageName"])
self.repository.add_log(
task_id,
"info",
f"Package {component['packageName']} installed with version {installed_version}",
)
service_name = component.get("serviceName")
if service_name:
self.repository.update_task_component(task_id, component_id, progress=90, current_step="starting service")
services.enable_service(service_name)
services.start_service(service_name)
self.repository.upsert_installed_component(app_id, component)
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
component_id = component["componentId"]
container_name = component["containerName"]
reference = image_reference(component)
command_runner = CommandRunner(self.repository, task_id)
installer = DockerInstaller(command_runner)
self.repository.update_task_component(task_id, component_id, progress=15, current_step="checking Docker runtime")
installer.ensure_runtime(auto_install=settings.auto_install_docker)
self.repository.update_task_component(task_id, component_id, progress=35, current_step="pulling image")
self.repository.add_log(task_id, "info", f"Pulling Docker image {reference}")
installer.pull_image(reference)
self.repository.update_task_component(task_id, component_id, progress=70, current_step="recreating container")
self.repository.add_log(task_id, "info", f"Recreating Docker container {container_name}")
installer.recreate_container(app_id, component)
self.repository.update_task_component(task_id, component_id, progress=90, current_step="verifying container")
installer.assert_container_running(container_name)
self.repository.add_log(task_id, "info", f"Docker container {container_name} is running")
installed_component = dict(component)
installed_component["image"] = reference
self.repository.upsert_installed_component(app_id, installed_component)
def _mark_started(self, task_id: str, step: str) -> None:
self.repository.update_task(
task_id,
status="running",
progress=5,
current_step=step,
started_at=utc_now(),
)
self.repository.add_log(task_id, "info", step)
def _fail_task(self, task_id: str, error: Exception) -> None:
task = self.repository.get_task(task_id)
component_id = task.get("current_component_id") if task else None
finished_at = utc_now()
if component_id:
self.repository.update_task_component(
task_id,
component_id,
status="failed",
current_step="failed",
error_message=str(error),
finished_at=finished_at,
)
self.repository.update_task(
task_id,
status="failed",
current_step="failed",
error_message=str(error),
finished_at=finished_at,
)
self.repository.add_log(task_id, "error", str(error))
self.repository.add_log(task_id, "debug", traceback.format_exc())
def _require_root_if_available(self) -> None:
geteuid = getattr(os, "geteuid", None)
if callable(geteuid) and geteuid() != 0:
raise PermissionError("Agent must run as root to call apt and systemctl")
def _best_effort(self, task_id: str, action: str, callback: Any) -> None:
try:
callback()
except Exception as error:
self.repository.add_log(task_id, "warning", f"Could not {action}: {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():
return
patterns: list[str] = []
seen_patterns: set[str] = set()
for identifier in identifiers:
name = (identifier or "").strip()
if not name:
continue
for pattern in (f"{name}.deb", f"{name}_*.deb"):
if pattern not in seen_patterns:
patterns.append(pattern)
seen_patterns.add(pattern)
removed_files: set[str] = set()
for pattern in patterns:
for file_path in cache_dir.glob(pattern):
if not file_path.is_file():
continue
try:
file_path.unlink()
removed_files.add(str(file_path))
except Exception as error:
self.repository.add_log(task_id, "warning", f"Could not remove cached package {file_path}: {error}")
for file_path in sorted(removed_files):
self.repository.add_log(task_id, "info", f"Removed cached package {file_path}")

37
agent/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import apps, health, services, tasks
from app.config import settings
from app.storage.database import initialize_database
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(
title="Local Installer Agent",
version=settings.agent_version,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(apps.router)
app.include_router(tasks.router)
app.include_router(services.router)

View File

@@ -0,0 +1,2 @@
"""Pydantic models for API contracts."""

268
agent/app/models/schemas.py Normal file
View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.utils.validators import (
validate_app_id,
validate_container_name,
validate_docker_digest,
validate_docker_image,
validate_docker_tag,
validate_env_name,
validate_package_name,
validate_port_mapping,
validate_restart_policy,
validate_service_name,
validate_sha256,
validate_volume_mapping,
validate_version,
)
TaskType = Literal["install", "update", "remove"]
TaskStatus = Literal["queued", "running", "success", "failed", "cancelled"]
ComponentType = Literal["deb", "docker", "docker_compose"]
class CamelModel(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class HealthResponse(CamelModel):
status: str
agent_version: str = Field(alias="agentVersion")
hostname: str
os: str
architecture: str
class SystemInfoResponse(CamelModel):
hostname: str
os: str
kernel: str
architecture: str
disk_free: str = Field(alias="diskFree")
memory_total: str = Field(alias="memoryTotal")
class InstallRequest(CamelModel):
app_id: str = Field(alias="appId")
version: str | None = None
app_name: str | None = Field(default=None, alias="appName")
package_name: str | None = Field(default=None, alias="packageName")
service_name: str | None = Field(default=None, alias="serviceName")
download_url: str | None = Field(default=None, alias="downloadUrl")
checksum: str | None = None
sha256: str | None = None
@field_validator("app_id")
@classmethod
def _app_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("version")
@classmethod
def _version(cls, value: str | None) -> str | None:
return validate_version(value) if value else None
@field_validator("package_name")
@classmethod
def _package_name(cls, value: str | None) -> str | None:
return validate_package_name(value) if value else None
@field_validator("service_name")
@classmethod
def _service_name(cls, value: str | None) -> str | None:
return validate_service_name(value)
@model_validator(mode="after")
def _direct_package_fields(self) -> "InstallRequest":
digest = self.sha256 or self.checksum
if digest:
normalized = validate_sha256(digest)
self.sha256 = normalized
self.checksum = normalized
has_direct_package = any([self.download_url, self.package_name, self.checksum, self.sha256])
if has_direct_package and not all([self.download_url, self.package_name, self.checksum or self.sha256]):
raise ValueError("direct install requires packageName, downloadUrl, and checksum/sha256")
return self
class UpdateRequest(InstallRequest):
current_version: str | None = Field(default=None, alias="currentVersion")
target_version: str | None = Field(default=None, alias="targetVersion")
@model_validator(mode="after")
def _normalize_target(self) -> "UpdateRequest":
if self.target_version:
self.target_version = validate_version(self.target_version)
self.version = self.target_version
return self
class RemoveRequest(CamelModel):
app_id: str = Field(alias="appId")
package_name: str | None = Field(default=None, alias="packageName")
service_name: str | None = Field(default=None, alias="serviceName")
purge: bool = False
@field_validator("app_id")
@classmethod
def _app_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("package_name")
@classmethod
def _package_name(cls, value: str | None) -> str | None:
return validate_package_name(value) if value else None
@field_validator("service_name")
@classmethod
def _service_name(cls, value: str | None) -> str | None:
return validate_service_name(value)
class ServiceRequest(CamelModel):
service_name: str = Field(alias="serviceName")
@field_validator("service_name")
@classmethod
def _service_name(cls, value: str) -> str:
return validate_service_name(value) or value
class TaskQueuedResponse(CamelModel):
task_id: str = Field(alias="taskId")
status: str
class DebComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: Literal["deb"] = "deb"
install_order: int = Field(default=10, alias="installOrder")
required: bool = True
package_name: str = Field(alias="packageName")
version: str
download_url: str = Field(alias="downloadUrl")
sha256: str
service_name: str | None = Field(default=None, alias="serviceName")
@field_validator("component_id")
@classmethod
def _component_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("package_name")
@classmethod
def _package_name(cls, value: str) -> str:
return validate_package_name(value)
@field_validator("version")
@classmethod
def _version(cls, value: str) -> str:
return validate_version(value)
@field_validator("sha256")
@classmethod
def _sha256(cls, value: str) -> str:
return validate_sha256(value)
@field_validator("service_name")
@classmethod
def _service_name(cls, value: str | None) -> str | None:
return validate_service_name(value)
class DockerComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: Literal["docker"] = "docker"
install_order: int = Field(default=10, alias="installOrder")
required: bool = True
image: str
tag: str | None = None
digest: str | None = None
container_name: str | None = Field(default=None, alias="containerName")
restart_policy: str = Field(default="unless-stopped", alias="restartPolicy")
ports: list[str] = Field(default_factory=list)
volumes: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
@field_validator("component_id")
@classmethod
def _component_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("image")
@classmethod
def _image(cls, value: str) -> str:
return validate_docker_image(value)
@field_validator("tag")
@classmethod
def _tag(cls, value: str | None) -> str | None:
return validate_docker_tag(value)
@field_validator("digest")
@classmethod
def _digest(cls, value: str | None) -> str | None:
return validate_docker_digest(value)
@field_validator("restart_policy")
@classmethod
def _restart_policy(cls, value: str | None) -> str:
return validate_restart_policy(value)
@field_validator("ports")
@classmethod
def _ports(cls, values: list[str]) -> list[str]:
return [validate_port_mapping(value) for value in values]
@field_validator("volumes")
@classmethod
def _volumes(cls, values: list[str]) -> list[str]:
return [validate_volume_mapping(value) for value in values]
@field_validator("env")
@classmethod
def _env(cls, values: dict[str, Any]) -> dict[str, str]:
normalized: dict[str, str] = {}
for key, value in values.items():
normalized[validate_env_name(str(key))] = str(value)
return normalized
@model_validator(mode="after")
def _container_name(self) -> "DockerComponent":
self.container_name = validate_container_name(self.container_name or self.component_id)
return self
class RawComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: ComponentType
install_order: int = Field(default=10, alias="installOrder")
required: bool = True
payload: dict[str, Any] = Field(default_factory=dict)
class AppManifest(CamelModel):
schema_version: str = Field(default="1.0", alias="schemaVersion")
app_id: str = Field(alias="appId")
app_name: str = Field(alias="appName")
version: str
open_url: str | None = Field(default=None, alias="openUrl")
architecture: str = "amd64"
components: list[dict[str, Any]]
signature: str | None = None
@field_validator("app_id")
@classmethod
def _app_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("version")
@classmethod
def _version(cls, value: str) -> str:
return validate_version(value)

View File

@@ -0,0 +1,2 @@
"""SQLite storage layer."""

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from app.config import settings
SCHEMA = """
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
app_id TEXT,
app_name TEXT,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
current_step TEXT,
current_component_id TEXT,
error_message TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS installed_apps (
app_id TEXT PRIMARY KEY,
app_name TEXT NOT NULL,
version TEXT NOT NULL,
open_url TEXT,
manifest_hash TEXT,
status TEXT NOT NULL,
installed_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS agent_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
def _ensure_column(connection: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {
row["name"]
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
}
if column not in columns:
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def get_connection() -> sqlite3.Connection:
connection = sqlite3.connect(settings.db_path, timeout=30)
connection.row_factory = sqlite3.Row
return connection
def initialize_database() -> None:
db_path = Path(settings.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
settings.cache_dir.mkdir(parents=True, exist_ok=True)
settings.log_dir.mkdir(parents=True, exist_ok=True)
with get_connection() as connection:
connection.executescript(SCHEMA)
_ensure_column(connection, "installed_apps", "open_url", "TEXT")

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from app.storage.database import get_connection
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def row_to_dict(row: Any) -> dict[str, Any] | None:
if row is None:
return None
return dict(row)
class Repository:
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
now = utc_now()
with get_connection() as connection:
connection.execute(
"""
INSERT INTO tasks (id, type, app_id, app_name, status, progress, current_step, created_at)
VALUES (?, ?, ?, ?, 'queued', 0, 'queued', ?)
""",
(task_id, task_type, app_id, app_name, now),
)
connection.execute(
"INSERT INTO task_logs (task_id, timestamp, level, message) VALUES (?, ?, 'info', ?)",
(task_id, now, f"Task {task_id} queued"),
)
def update_task(
self,
task_id: str,
*,
status: str | None = None,
progress: int | None = None,
current_step: str | None = None,
current_component_id: str | None = None,
error_message: str | None = None,
started_at: str | None = None,
finished_at: str | None = None,
) -> None:
fields: list[str] = []
values: list[Any] = []
for key, value in {
"status": status,
"progress": progress,
"current_step": current_step,
"current_component_id": current_component_id,
"error_message": error_message,
"started_at": started_at,
"finished_at": finished_at,
}.items():
if value is not None:
fields.append(f"{key} = ?")
values.append(value)
if not fields:
return
values.append(task_id)
with get_connection() as connection:
connection.execute(f"UPDATE tasks SET {', '.join(fields)} WHERE id = ?", values)
def add_log(self, task_id: str, level: str, message: str) -> None:
with get_connection() as connection:
connection.execute(
"INSERT INTO task_logs (task_id, timestamp, level, message) VALUES (?, ?, ?, ?)",
(task_id, utc_now(), level, message),
)
def get_task(self, task_id: str) -> dict[str, Any] | None:
with get_connection() as connection:
row = connection.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
return row_to_dict(row)
def get_task_logs(self, task_id: str) -> list[dict[str, Any]]:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT timestamp, level, message
FROM task_logs
WHERE task_id = ?
ORDER BY id ASC
""",
(task_id,),
).fetchall()
return [dict(row) for row in rows]
def create_task_component(
self,
task_id: str,
app_id: str,
component_id: str,
component_type: str,
install_order: int,
) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO task_components (
task_id, app_id, component_id, type, install_order, status, progress, current_step
)
VALUES (?, ?, ?, ?, ?, 'queued', 0, 'queued')
""",
(task_id, app_id, component_id, component_type, install_order),
)
def update_task_component(
self,
task_id: str,
component_id: str,
*,
status: str | None = None,
progress: int | None = None,
current_step: str | None = None,
error_message: str | None = None,
started_at: str | None = None,
finished_at: str | None = None,
) -> None:
fields: list[str] = []
values: list[Any] = []
for key, value in {
"status": status,
"progress": progress,
"current_step": current_step,
"error_message": error_message,
"started_at": started_at,
"finished_at": finished_at,
}.items():
if value is not None:
fields.append(f"{key} = ?")
values.append(value)
if not fields:
return
values.extend([task_id, component_id])
with get_connection() as connection:
connection.execute(
f"UPDATE task_components SET {', '.join(fields)} WHERE task_id = ? AND component_id = ?",
values,
)
def get_task_components(self, task_id: str) -> list[dict[str, Any]]:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT component_id, type, status, progress, current_step, error_message, 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]
def list_installed_apps(self) -> list[dict[str, Any]]:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at
FROM installed_apps
ORDER BY app_name ASC
"""
).fetchall()
return [dict(row) for row in rows]
def upsert_installed_app(
self,
app_id: str,
app_name: str,
version: str,
manifest_hash: str | None,
open_url: str | None = None,
status: str = "installed",
) -> None:
now = utc_now()
with get_connection() as connection:
connection.execute(
"""
INSERT INTO installed_apps (app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(app_id) DO UPDATE SET
app_name = excluded.app_name,
version = excluded.version,
open_url = excluded.open_url,
manifest_hash = excluded.manifest_hash,
status = excluded.status,
updated_at = excluded.updated_at
""",
(app_id, app_name, version, open_url, manifest_hash, status, now, now),
)
def delete_installed_app(self, app_id: str) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM installed_components WHERE app_id = ?", (app_id,))
connection.execute("DELETE FROM installed_apps WHERE app_id = ?", (app_id,))
def upsert_installed_component(self, app_id: str, component: dict[str, Any]) -> None:
now = utc_now()
with get_connection() as connection:
connection.execute(
"""
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,
installed_at, updated_at
)
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(app_id, component_id) DO UPDATE SET
type = excluded.type,
install_order = excluded.install_order,
status = excluded.status,
package_name = excluded.package_name,
package_version = excluded.package_version,
service_name = excluded.service_name,
docker_image = excluded.docker_image,
docker_digest = excluded.docker_digest,
container_name = excluded.container_name,
compose_project_name = excluded.compose_project_name,
updated_at = excluded.updated_at
""",
(
app_id,
component["componentId"],
component["type"],
component.get("installOrder", 10),
component.get("packageName"),
component.get("version"),
component.get("serviceName"),
component.get("image"),
component.get("digest"),
component.get("containerName"),
component.get("projectName"),
now,
now,
),
)
def list_installed_components(self, app_id: str) -> list[dict[str, Any]]:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT *
FROM installed_components
WHERE app_id = ?
ORDER BY install_order ASC, id ASC
""",
(app_id,),
).fetchall()
return [dict(row) for row in rows]
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))

View File

@@ -0,0 +1,2 @@
"""Utility helpers."""

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import re
from urllib.parse import urlparse
APP_ID_RE = re.compile(r"^[a-zA-Z0-9._+-]+$")
PACKAGE_NAME_RE = re.compile(r"^[a-zA-Z0-9._+-]+$")
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9._@+-]+\.service$")
VERSION_RE = re.compile(r"^[a-zA-Z0-9._:+~=-]+$")
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
DOCKER_DIGEST_RE = re.compile(r"^sha256:[0-9a-fA-F]{64}$")
DOCKER_REF_RE = re.compile(r"^[a-z0-9][a-z0-9._:/@+-]{0,254}$")
DOCKER_TAG_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$")
CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
ENV_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
PORT_MAPPING_RE = re.compile(r"^[a-zA-Z0-9_.:-]+(?:/(?:tcp|udp|sctp))?$")
VOLUME_MAPPING_RE = re.compile(r"^[^\s:]+:[^\s:]+(?::(?:ro|rw))?$")
def validate_app_id(value: str) -> str:
if not value or not APP_ID_RE.fullmatch(value):
raise ValueError("appId contains invalid characters")
return value
def validate_package_name(value: str) -> str:
if not value or not PACKAGE_NAME_RE.fullmatch(value):
raise ValueError("packageName contains invalid characters")
return value
def validate_service_name(value: str | None) -> str | None:
if value is None or value == "":
return None
if not SERVICE_NAME_RE.fullmatch(value):
raise ValueError("serviceName must be a systemd .service name")
return value
def validate_version(value: str) -> str:
if not value or not VERSION_RE.fullmatch(value):
raise ValueError("version contains invalid characters")
return value
def validate_sha256(value: str) -> str:
if not value or not SHA256_RE.fullmatch(value):
raise ValueError("sha256/checksum must be a 64 character hex digest")
return value.lower()
def validate_url_host(url: str, allowed_hosts: list[str]) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("download URL must use http or https")
if not parsed.hostname:
raise ValueError("download URL is missing a host")
if parsed.hostname not in set(allowed_hosts):
raise ValueError(f"download host is not allowed: {parsed.hostname}")
return url
def validate_docker_image(value: str) -> str:
image = value.strip()
if not image or not DOCKER_REF_RE.fullmatch(image):
raise ValueError("docker image contains invalid characters")
if "//" in image or image.endswith(("/", ":", "@")):
raise ValueError("docker image reference is malformed")
return image
def validate_docker_tag(value: str | None) -> str | None:
if value is None or value == "":
return None
tag = value.strip()
if not DOCKER_TAG_RE.fullmatch(tag):
raise ValueError("docker tag contains invalid characters")
return tag
def validate_docker_digest(value: str | None) -> str | None:
if value is None or value == "":
return None
digest = value.strip()
if not DOCKER_DIGEST_RE.fullmatch(digest):
raise ValueError("docker digest must be sha256:<64 hex characters>")
return digest.lower()
def validate_docker_registry(image: str, allowed_registries: list[str]) -> str:
reference = image.split("@", 1)[0]
parts = reference.split("/", 1)
first_part = parts[0]
has_explicit_registry = len(parts) > 1 and (
"." in first_part or ":" in first_part or first_part == "localhost"
)
registry = first_part if has_explicit_registry else "docker.io"
allowed = {item.strip() for item in allowed_registries if item.strip()}
if "*" not in allowed and registry not in allowed:
raise ValueError(f"docker registry is not allowed: {registry}")
return image
def validate_container_name(value: str) -> str:
name = value.strip()
if not name or not CONTAINER_NAME_RE.fullmatch(name):
raise ValueError("containerName contains invalid characters")
return name
def validate_restart_policy(value: str | None) -> str:
policy = (value or "unless-stopped").strip()
if policy not in {"no", "always", "unless-stopped", "on-failure"}:
raise ValueError("restartPolicy must be one of: no, always, unless-stopped, on-failure")
return policy
def validate_port_mapping(value: str) -> str:
mapping = value.strip()
if not mapping or not PORT_MAPPING_RE.fullmatch(mapping):
raise ValueError("docker port mapping contains invalid characters")
return mapping
def validate_volume_mapping(value: str) -> str:
mapping = value.strip()
if not mapping or not VOLUME_MAPPING_RE.fullmatch(mapping):
raise ValueError("docker volume mapping must be hostPath:containerPath[:ro|rw]")
return mapping
def validate_env_name(value: str) -> str:
name = value.strip()
if not ENV_NAME_RE.fullmatch(name):
raise ValueError("docker env names may contain only letters, numbers, and underscore")
return name

View File

@@ -0,0 +1,10 @@
Package: local-installer-agent
Version: 1.0.0
Section: utils
Priority: optional
Architecture: amd64
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 apps
from robot.package on the user's Linux machine.

View File

@@ -0,0 +1,57 @@
#!/bin/bash
set -e
mkdir -p /var/lib/local-installer-agent
mkdir -p /var/log/local-installer-agent
mkdir -p /var/cache/local-installer-agent/packages
mkdir -p /etc/local-installer-agent
AGENT_ENV="/etc/local-installer-agent/agent.env"
touch "$AGENT_ENV"
set_agent_env() {
KEY="$1"
VALUE="$2"
if grep -q "^$KEY=" "$AGENT_ENV"; then
sed -i "s|^$KEY=.*|$KEY=$VALUE|" "$AGENT_ENV"
else
echo "$KEY=$VALUE" >> "$AGENT_ENV"
fi
}
append_csv_env() {
KEY="$1"
VALUE="$2"
CURRENT="$(grep "^$KEY=" "$AGENT_ENV" | tail -n 1 | cut -d= -f2- || true)"
if [ -z "$CURRENT" ]; then
set_agent_env "$KEY" "$VALUE"
return
fi
case ",$CURRENT," in
*",$VALUE,"*) ;;
*) set_agent_env "$KEY" "$CURRENT,$VALUE" ;;
esac
}
set_agent_env ALLOW_DOCKER true
set_agent_env ALLOW_PURGE true
set_agent_env AUTO_INSTALL_DOCKER true
append_csv_env ALLOWED_DOCKER_REGISTRIES docker.io
cd /opt/local-installer-agent
if [ ! -d "venv" ]; then
python3 -m venv venv
fi
./venv/bin/pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
systemctl daemon-reload
systemctl enable local-installer-agent
systemctl restart local-installer-agent
exit 0

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
systemctl daemon-reload
exit 0

View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
if systemctl is-active --quiet local-installer-agent; then
systemctl stop local-installer-agent
fi
exit 0

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Local Installer Agent
After=network.target
[Service]
WorkingDirectory=/opt/local-installer-agent
ExecStart=/opt/local-installer-agent/venv/bin/python -m uvicorn app.main:app --host ${AGENT_HOST} --port ${AGENT_PORT}
Restart=always
User=root
EnvironmentFile=/etc/local-installer-agent/agent.env
[Install]
WantedBy=multi-user.target

4
agent/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
pydantic>=2,<3
httpx>=0.27,<1.0

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${VERSION:-1.0.0}"
ARCH="${ARCH:-amd64}"
AGENT_HOST="${AGENT_HOST:-0.0.0.0}"
AGENT_PORT="${AGENT_PORT:-5010}"
DEB_COMPRESSION="${DEB_COMPRESSION:-gzip}"
PKG_NAME="local-installer-agent"
BUILD_ROOT="${BUILD_ROOT:-build}"
BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}"
OUTPUT_PACKAGE="${BUILD_DIR}.deb"
if [[ ! "$VERSION" =~ ^[a-zA-Z0-9][a-zA-Z0-9._:+~=-]*$ ]]; then
echo "Invalid VERSION: ${VERSION}" >&2
exit 1
fi
if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
echo "Invalid ARCH: ${ARCH}" >&2
exit 1
fi
rm -rf "${BUILD_ROOT}"
mkdir -p "${BUILD_DIR}/opt/local-installer-agent"
mkdir -p "${BUILD_DIR}/etc/local-installer-agent"
mkdir -p "${BUILD_DIR}/etc/systemd/system"
mkdir -p "${BUILD_DIR}/DEBIAN"
cp -r app "${BUILD_DIR}/opt/local-installer-agent/"
cp requirements.txt "${BUILD_DIR}/opt/local-installer-agent/"
find "${BUILD_DIR}/opt/local-installer-agent/app" \
\( -type d -name "__pycache__" -o -type f \( -name "*.pyc" -o -name "*.pyo" \) \) \
-exec rm -rf {} +
cp packaging/systemd/local-installer-agent.service \
"${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
cp packaging/DEBIAN/control "${BUILD_DIR}/DEBIAN/control"
cp packaging/DEBIAN/postinst "${BUILD_DIR}/DEBIAN/postinst"
cp packaging/DEBIAN/prerm "${BUILD_DIR}/DEBIAN/prerm"
cp packaging/DEBIAN/postrm "${BUILD_DIR}/DEBIAN/postrm"
sed -i \
-e "s/^Version:.*/Version: ${VERSION}/" \
-e "s/^Architecture:.*/Architecture: ${ARCH}/" \
"${BUILD_DIR}/DEBIAN/control"
chmod 755 "${BUILD_DIR}/DEBIAN/postinst"
chmod 755 "${BUILD_DIR}/DEBIAN/prerm"
chmod 755 "${BUILD_DIR}/DEBIAN/postrm"
chmod 755 "${BUILD_DIR}/DEBIAN"
cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" <<EOF
AGENT_VERSION=${VERSION}
AGENT_HOST=${AGENT_HOST}
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_DOCKER_REGISTRIES=registry.robot.package,docker.io
CACHE_DIR=/var/cache/local-installer-agent/packages
APP_DIR=/opt/robot-apps
LOG_DIR=/var/log/local-installer-agent
DB_PATH=/var/lib/local-installer-agent/agent.db
ALLOW_REMOVE=true
ALLOW_PURGE=true
ALLOW_DOCKER=true
ALLOW_DOCKER_COMPOSE=false
AUTO_INSTALL_DOCKER=true
EOF
dpkg-deb -Z"${DEB_COMPRESSION}" --root-owner-group --build "${BUILD_DIR}"
echo "Built package:"
echo "${OUTPUT_PACKAGE}"
if [ -n "${PUBLISH_DIR:-}" ]; then
mkdir -p "${PUBLISH_DIR}"
cp "${OUTPUT_PACKAGE}" "${PUBLISH_DIR}/"
echo "Published package:"
echo "${PUBLISH_DIR}/$(basename "${OUTPUT_PACKAGE}")"
fi

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
AGENT_BASE_URL="${AGENT_BASE_URL:-https://robot.package}"
ARCH="${ARCH:-$(dpkg --print-architecture)}"
AGENT_URL="${AGENT_URL:-${AGENT_BASE_URL%/}/packages/agent/latest.deb?arch=${ARCH}}"
TMP_DEB="/tmp/local-installer-agent.deb"
echo "Downloading Local Installer Agent..."
curl -fL "$AGENT_URL" -o "$TMP_DEB"
echo "Installing Local Installer Agent..."
apt install -y "$TMP_DEB"
echo "Starting Local Installer Agent..."
systemctl enable local-installer-agent
systemctl restart local-installer-agent
echo "Checking Agent..."
curl -fsSL http://127.0.0.1:5010/health
echo ""
echo "Local Installer Agent installed successfully."

BIN
database.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

11
web-client/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.env
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
Dockerfile
.dockerignore

11
web-client/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Leave empty in local dev to use Vite proxy: /api -> PACKAGE_PROXY_TARGET.
WEB_CLIENT_IMAGE_REPOSITORY=toiiiiday/robot-installer-web-client
WEB_CLIENT_CONTAINER_NAME=robot-installer-web-client
WEB_CLIENT_PORT=8080
IMAGE_TAG=1.0.0
DOCKER_NETWORK=robot-installer-net
PACKAGE_PROXY_TARGET=http://robot-installer-web-server:3000
VITE_PACKAGE_BASE_URL=https://package.pnkr.cloud
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
VITE_APP_OPEN_URL=http://127.0.0.1

29
web-client/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_PACKAGE_BASE_URL=
ARG VITE_AGENT_BASE_URL=http://127.0.0.1:5010
ARG VITE_APP_OPEN_URL=http://127.0.0.1
RUN VITE_PACKAGE_BASE_URL="${VITE_PACKAGE_BASE_URL}" \
VITE_AGENT_BASE_URL="${VITE_AGENT_BASE_URL}" \
VITE_APP_OPEN_URL="${VITE_APP_OPEN_URL}" \
npm run build
FROM nginx:1.27-alpine AS runtime
ENV PACKAGE_PROXY_TARGET=http://web-server:3000
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

42
web-client/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Robot Installer Web Client
Web Client public cho user cài, cập nhật và gỡ app thông qua Local Installer Agent.
## Chạy local
```bash
npm install
npm run dev
```
Mặc định khi chạy dev, client gọi package server qua Vite proxy:
```text
robot.package API: http://localhost:5173/api -> http://localhost:3000/api
Local Agent: http://127.0.0.1:5010
```
Có thể đổi trong UI hoặc qua `.env`:
```env
VITE_PACKAGE_BASE_URL=
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
VITE_APP_OPEN_URL=http://127.0.0.1
PACKAGE_PROXY_TARGET=http://localhost:3000
```
Khi deploy `robot.installer` thật, đặt `VITE_PACKAGE_BASE_URL=https://robot.package` để browser gọi thẳng package server.
## Test thật
1. Chạy `web-server` tại `http://localhost:3000`.
2. Chạy hoặc cài Local Installer Agent tại `http://127.0.0.1:5010`.
3. Khi test local, Agent nên có:
```env
ROBOT_PACKAGE_BASE_URL=http://localhost:3000
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:4173
ALLOWED_DOWNLOAD_HOSTS=localhost,127.0.0.1
```
4. Mở Web Client, bấm `Retry`, chọn app đã `Released`, rồi bấm `Install`.

View File

@@ -0,0 +1,22 @@
services:
web-client:
image: ${WEB_CLIENT_IMAGE_REPOSITORY:-robot-installer-web-client}:${IMAGE_TAG:-local}
build:
context: .
args:
VITE_PACKAGE_BASE_URL: ${VITE_PACKAGE_BASE_URL:-}
VITE_AGENT_BASE_URL: ${VITE_AGENT_BASE_URL:-http://127.0.0.1:5010}
VITE_APP_OPEN_URL: ${VITE_APP_OPEN_URL:-http://127.0.0.1}
container_name: ${WEB_CLIENT_CONTAINER_NAME:-robot-installer-web-client}
environment:
PACKAGE_PROXY_TARGET: ${PACKAGE_PROXY_TARGET:-http://robot-installer-web-server:3000}
ports:
- "${WEB_CLIENT_PORT:-8080}:80"
networks:
- robot-installer
restart: unless-stopped
networks:
robot-installer:
name: ${DOCKER_NETWORK:-robot-installer-net}
external: true

19
web-client/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<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>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 1024m;
location = /api {
proxy_pass ${PACKAGE_PROXY_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ^~ /api/ {
proxy_pass ${PACKAGE_PROXY_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location = /install-agent.sh {
proxy_pass ${PACKAGE_PROXY_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ^~ /uploads/ {
proxy_pass ${PACKAGE_PROXY_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ^~ /packages/ {
proxy_pass ${PACKAGE_PROXY_TARGET};
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}

1716
web-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
web-client/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "robot-installer-web-client",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Public web client for installing Robot applications through the Local Installer Agent.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@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": {}
}

1377
web-client/src/main.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
export const DEFAULT_PACKAGE_BASE_URL = normalizeUrl(
import.meta.env.VITE_PACKAGE_BASE_URL || window.location.origin
);
export const DEFAULT_AGENT_BASE_URL = normalizeUrl(
import.meta.env.VITE_AGENT_BASE_URL || 'http://127.0.0.1:5010'
);
export const DEFAULT_APP_OPEN_URL = normalizeUrl(
import.meta.env.VITE_APP_OPEN_URL || 'http://127.0.0.1'
);
export function normalizeUrl(value) {
const text = String(value || '').trim();
return text.replace(/\/+$/, '');
}
export function joinUrl(baseUrl, path) {
const normalizedBaseUrl = normalizeUrl(baseUrl);
const normalizedPath = String(path || '').startsWith('/') ? path : `/${path || ''}`;
return `${normalizedBaseUrl}${normalizedPath}`;
}
function normalizeOpenUrl(value) {
const text = normalizeUrl(value);
if (!text) return '';
try {
const parsed = new URL(text);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? text : '';
} catch {
return '';
}
}
export function getAppOpenUrl(app) {
return normalizeOpenUrl(
app?.openUrl
|| app?.open_url
|| app?.webUrl
|| app?.web_url
|| app?.homepageUrl
|| app?.homepage_url
|| app?.homepage
) || normalizeOpenUrl(DEFAULT_APP_OPEN_URL);
}
async function requestJson(baseUrl, path, options = {}) {
const {
timeoutMs = 8000,
body,
headers,
...fetchOptions
} = options;
const url = joinUrl(baseUrl, path);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...fetchOptions,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (!response.ok) {
throw new Error(`${response.status} ${formatErrorDetail(payload || response.statusText)}`);
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Request timeout: ${url}`);
}
if (error instanceof TypeError) {
throw new Error(`Cannot fetch ${url}. Check endpoint reachability and CORS for this Web Client origin.`);
}
throw error;
} finally {
window.clearTimeout(timeout);
}
}
function formatErrorDetail(detail) {
if (Array.isArray(detail)) {
return detail.map(formatErrorDetail).filter(Boolean).join('; ');
}
if (detail && typeof detail === 'object') {
const location = Array.isArray(detail.loc) ? detail.loc.join('.') : '';
const messageParts = [
detail.msg,
detail.message,
detail.error,
detail.detail
]
.map((item) => String(item || '').trim())
.filter(Boolean);
if (Array.isArray(detail.missingPackageFiles) && detail.missingPackageFiles.length > 0) {
const missingFiles = detail.missingPackageFiles
.map(formatMissingPackageFile)
.filter(Boolean)
.join('; ');
if (missingFiles) {
messageParts.push(`Missing package files: ${missingFiles}`);
}
}
const message = [...new Set(messageParts)].join('. ');
if (message) {
return location ? `${location}: ${message}` : String(message);
}
try {
return JSON.stringify(detail);
} catch {
return String(detail);
}
}
return String(detail || 'Request failed');
}
function formatMissingPackageFile(item) {
if (!item || typeof item !== 'object') return String(item || '').trim();
const packageName = String(item.packageName || item.componentId || 'package').trim();
const version = String(item.version || '').trim();
const downloadUrl = String(item.downloadUrl || '').trim();
const label = [packageName, version].filter(Boolean).join(' ');
return downloadUrl ? `${label} (${downloadUrl})` : label;
}
export async function fetchPackageApps(packageBaseUrl) {
const payload = await requestJson(packageBaseUrl, '/api/apps', { timeoutMs: 10000 });
return Array.isArray(payload?.apps) ? payload.apps.map(normalizePackageApp) : [];
}
export async function fetchLatestAgentPackage(packageBaseUrl, arch = 'amd64') {
const query = arch ? `?arch=${encodeURIComponent(arch)}` : '';
return normalizeLatestAgentPackage(
await requestJson(packageBaseUrl, `/api/agent/latest${query}`, { timeoutMs: 7000 })
);
}
export async function fetchApplicationDetail(packageBaseUrl, appId) {
return requestJson(packageBaseUrl, `/api/apps/${encodeURIComponent(appId)}`, { timeoutMs: 10000 });
}
export async function fetchApplicationManifest(packageBaseUrl, appId, version) {
return requestJson(
packageBaseUrl,
`/api/apps/${encodeURIComponent(appId)}/versions/${encodeURIComponent(version)}/manifest`,
{ timeoutMs: 10000 }
);
}
export async function fetchAgentHealth(agentBaseUrl) {
return requestJson(agentBaseUrl, '/health', { timeoutMs: 2800 });
}
export async function fetchAgentSystemInfo(agentBaseUrl) {
return requestJson(agentBaseUrl, '/system-info', { timeoutMs: 5000 });
}
export async function fetchInstalledApps(agentBaseUrl) {
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 7000 });
return Array.isArray(payload) ? payload.map(normalizeInstalledApp) : [];
}
export async function queueInstall(agentBaseUrl, app) {
return requestJson(agentBaseUrl, '/apps/install', {
method: 'POST',
timeoutMs: 10000,
body: {
appId: app.appId,
appName: app.appName,
version: app.version
}
});
}
export async function queueUpdate(agentBaseUrl, app, installedApp) {
return requestJson(agentBaseUrl, '/apps/update', {
method: 'POST',
timeoutMs: 10000,
body: {
appId: app.appId,
appName: app.appName,
currentVersion: installedApp?.version || '',
targetVersion: app.version
}
});
}
export async function queueRemove(agentBaseUrl, app) {
return requestJson(agentBaseUrl, '/apps/remove', {
method: 'POST',
timeoutMs: 10000,
body: {
appId: app.appId,
purge: true
}
});
}
export async function fetchTaskStatus(agentBaseUrl, taskId) {
return normalizeTask(await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}`, { timeoutMs: 7000 }));
}
export async function fetchTaskLogs(agentBaseUrl, taskId) {
const payload = await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}/logs`, { timeoutMs: 7000 });
return Array.isArray(payload?.logs) ? payload.logs.map(normalizeLog) : [];
}
export async function fetchTaskComponents(agentBaseUrl, taskId) {
const payload = await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}/components`, { timeoutMs: 7000 });
return Array.isArray(payload?.components) ? payload.components.map(normalizeComponent) : [];
}
function normalizePackageApp(app) {
return {
appId: String(app.appId || app.app_id || app.id || '').trim(),
appCode: String(app.appCode || app.app_code || app.code || app.appId || app.app_id || '').trim(),
appName: String(app.appName || app.app_name || app.name || '').trim(),
version: String(app.version || '').trim(),
status: String(app.status || 'Released').trim(),
packageCount: Number(app.packageCount || app.package_count || 0),
openUrl: normalizeOpenUrl(
app.openUrl
|| app.open_url
|| app.webUrl
|| app.web_url
|| app.homepageUrl
|| app.homepage_url
|| app.homepage
)
};
}
function normalizeInstalledApp(app) {
return {
appId: String(app.appId || app.app_id || '').trim(),
appName: String(app.appName || app.app_name || '').trim(),
version: String(app.installedVersion || app.version || app.package_version || '').trim(),
status: String(app.status || 'installed').trim(),
installedAt: app.installedAt || app.installed_at || '',
updatedAt: app.updatedAt || app.updated_at || '',
openUrl: normalizeOpenUrl(
app.openUrl
|| app.open_url
|| app.webUrl
|| app.web_url
|| app.homepageUrl
|| app.homepage_url
|| app.homepage
)
};
}
function normalizeLatestAgentPackage(agentPackage) {
return {
version: String(agentPackage?.version || '').trim(),
arch: String(agentPackage?.arch || '').trim(),
fileName: String(agentPackage?.fileName || agentPackage?.file_name || '').trim(),
sizeLabel: String(agentPackage?.sizeLabel || agentPackage?.size_label || '').trim(),
downloadUrl: String(agentPackage?.downloadUrl || agentPackage?.download_url || '').trim(),
installCommand: String(agentPackage?.installCommand || agentPackage?.install_command || '').trim()
};
}
function normalizeTask(task) {
return {
taskId: task.taskId || task.task_id,
type: task.type,
appId: task.appId || task.app_id,
appName: task.appName || task.app_name,
status: task.status,
progress: Number(task.progress || 0),
currentStep: task.currentStep || task.current_step,
currentComponentId: task.currentComponentId || task.current_component_id,
errorMessage: task.errorMessage || task.error_message,
createdAt: task.createdAt || task.created_at,
startedAt: task.startedAt || task.started_at,
finishedAt: task.finishedAt || task.finished_at
};
}
function normalizeLog(log) {
return {
time: log.time || log.timestamp || '',
level: log.level || 'info',
message: log.message || ''
};
}
function normalizeComponent(component) {
return {
componentId: component.componentId || component.component_id,
type: component.type,
status: component.status,
progress: Number(component.progress || 0),
currentStep: component.currentStep || component.current_step,
errorMessage: component.errorMessage || component.error_message,
startedAt: component.startedAt || component.started_at,
finishedAt: component.finishedAt || component.finished_at
};
}

1341
web-client/src/styles.css Normal file

File diff suppressed because it is too large Load Diff

36
web-client/vite.config.js Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const packageProxyTarget = process.env.PACKAGE_PROXY_TARGET
|| process.env.VITE_PACKAGE_BASE_URL
|| 'http://localhost:3000';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: false,
proxy: {
'/api': {
target: packageProxyTarget,
changeOrigin: true
},
'/install-agent.sh': {
target: packageProxyTarget,
changeOrigin: true
},
'/uploads': {
target: packageProxyTarget,
changeOrigin: true
},
'/packages': {
target: packageProxyTarget,
changeOrigin: true
}
}
},
preview: {
port: 4173,
strictPort: false
}
});

11
web-server/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
uploads
.env
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
Dockerfile
.dockerignore

30
web-server/.env.example Normal file
View File

@@ -0,0 +1,30 @@
PORT=3000
WEB_SERVER_IMAGE_REPOSITORY=toiiiiday/robot-installer-web-server
WEB_SERVER_CONTAINER_NAME=robot-installer-web-server
WEB_SERVER_PORT=3005
IMAGE_TAG=1.0.0
DOCKER_NETWORK=robot-installer-net
WEB_SERVER_UPLOADS_DIR=./uploads
SQLSERVER_HOST=172.20.235.176
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=RobotInstaller
SQLSERVER_USER=sa
SQLSERVER_PASSWORD=change_me
SQLSERVER_ENCRYPT=false
SQLSERVER_TRUST_SERVER_CERTIFICATE=true
SQLSERVER_USE_UTC=true
AUTH_SECRET=change_this_to_a_long_random_value
SESSION_MAX_AGE_MS=28800000
SESSION_COOKIE_SECURE=true
EMAIL_CONFIRMATION_EXPIRES_MS=86400000
APP_BASE_URL=https://package.pnkr.cloud
APP_SHOW_ERROR_DETAILS=false
WEB_CLIENT_ORIGINS=https://app.pnkr.cloud,http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1:3000
# Mail chính dùng để gửi email xác nhận tới các tài khoản đăng ký
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=main_sender_email@gmail.com
SMTP_PASSWORD=main_sender_app_password
MAIL_FROM="Robot Installer <main_sender_email@gmail.com>"

9
web-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.env
uploads/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
.DS_Store

31
web-server/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
RUN apk add --no-cache bzip2 dpkg su-exec xz zstd
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN mkdir -p uploads/packages/agent \
&& chown -R node:node uploads \
&& chmod +x docker-entrypoint.sh
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["npm", "start"]

View File

@@ -0,0 +1,11 @@
IF DB_ID(N'RobotInstaller') IS NULL
BEGIN
CREATE DATABASE [RobotInstaller];
END;
GO
USE [RobotInstaller];
GO
PRINT N'Database RobotInstaller is ready.';
GO

View File

@@ -0,0 +1,292 @@
USE [RobotInstaller];
GO
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
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.EmailConfirmationTokens', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
BEGIN
THROW 50001, 'Schema tables already exist. Review, drop, or migrate existing tables before running 02_schema.sql.', 1;
END;
GO
CREATE TABLE dbo.Users
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Users PRIMARY KEY CLUSTERED
CONSTRAINT DF_Users_Id DEFAULT NEWSEQUENTIALID(),
Username NVARCHAR(100) NOT NULL,
Email NVARCHAR(255) NOT NULL,
PasswordHash NVARCHAR(500) NOT NULL,
FullName NVARCHAR(200) NULL,
Role NVARCHAR(50) NOT NULL
CONSTRAINT DF_Users_Role DEFAULT N'User',
IsActive BIT NOT NULL
CONSTRAINT DF_Users_IsActive DEFAULT 1,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Users_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
CONSTRAINT CK_Users_Role CHECK (Role IN (N'Admin', N'User')),
CONSTRAINT CK_Users_Username_NotBlank CHECK (LEN(LTRIM(RTRIM(Username))) > 0),
CONSTRAINT CK_Users_Email_NotBlank CHECK (LEN(LTRIM(RTRIM(Email))) > 0)
);
GO
CREATE TABLE dbo.EmailConfirmationTokens
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_EmailConfirmationTokens PRIMARY KEY CLUSTERED
CONSTRAINT DF_EmailConfirmationTokens_Id DEFAULT NEWSEQUENTIALID(),
UserId UNIQUEIDENTIFIER NOT NULL,
TokenHash CHAR(64) NOT NULL,
ExpiresAt DATETIME2(3) NOT NULL,
ConfirmedAt DATETIME2(3) NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_EmailConfirmationTokens_CreatedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_EmailConfirmationTokens_User
FOREIGN KEY (UserId) REFERENCES dbo.Users(Id) ON DELETE CASCADE
);
GO
CREATE TABLE dbo.Packages
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Packages PRIMARY KEY CLUSTERED
CONSTRAINT DF_Packages_Id DEFAULT NEWSEQUENTIALID(),
PackageCode NVARCHAR(100) NOT NULL,
PackageName NVARCHAR(200) NOT NULL,
PackageType NVARCHAR(20) NOT NULL,
Description NVARCHAR(MAX) NULL,
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Packages_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
IsActive BIT NOT NULL
CONSTRAINT DF_Packages_IsActive DEFAULT 1,
CONSTRAINT FK_Packages_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Packages_PackageType CHECK (PackageType IN (N'deb', N'docker')),
CONSTRAINT CK_Packages_PackageCode_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageCode))) > 0),
CONSTRAINT CK_Packages_PackageName_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageName))) > 0)
);
GO
CREATE TABLE dbo.PackageVersions
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_PackageVersions PRIMARY KEY CLUSTERED
CONSTRAINT DF_PackageVersions_Id DEFAULT NEWSEQUENTIALID(),
PackageId UNIQUEIDENTIFIER NOT NULL,
Version NVARCHAR(50) NOT NULL,
FilePath NVARCHAR(1000) NULL,
DockerImage NVARCHAR(500) NULL,
FileChecksumSha256 CHAR(64) NULL,
FileSizeBytes BIGINT NULL,
ChangeLog NVARCHAR(MAX) NULL,
ReleaseDate DATETIME2(3) NOT NULL
CONSTRAINT DF_PackageVersions_ReleaseDate DEFAULT SYSUTCDATETIME(),
UploadedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_PackageVersions_UploadedAt DEFAULT SYSUTCDATETIME(),
IsLatest BIT NOT NULL
CONSTRAINT DF_PackageVersions_IsLatest DEFAULT 0,
IsDeprecated BIT NOT NULL
CONSTRAINT DF_PackageVersions_IsDeprecated DEFAULT 0,
CONSTRAINT FK_PackageVersions_Package
FOREIGN KEY (PackageId) REFERENCES dbo.Packages(Id) ON DELETE CASCADE,
CONSTRAINT CK_PackageVersions_Version_NotBlank CHECK (LEN(LTRIM(RTRIM(Version))) > 0),
CONSTRAINT CK_PackageVersions_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
CONSTRAINT CK_PackageVersions_FileChecksumSha256 CHECK (
FileChecksumSha256 IS NULL
OR FileChecksumSha256 NOT LIKE '%[^0-9A-Fa-f]%'
)
);
GO
CREATE TABLE dbo.Applications
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Applications PRIMARY KEY CLUSTERED
CONSTRAINT DF_Applications_Id DEFAULT NEWSEQUENTIALID(),
AppCode NVARCHAR(100) NOT NULL,
AppName NVARCHAR(200) NOT NULL,
AppVersion NVARCHAR(50) NOT NULL
CONSTRAINT DF_Applications_AppVersion DEFAULT N'1.0.0',
Description NVARCHAR(MAX) NULL,
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Applications_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
Status NVARCHAR(50) NOT NULL
CONSTRAINT DF_Applications_Status DEFAULT N'Draft',
Notes NVARCHAR(500) NULL,
OpenUrl NVARCHAR(500) NULL,
CONSTRAINT FK_Applications_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Applications_Status CHECK (Status IN (N'Draft', N'Released', N'Archived')),
CONSTRAINT CK_Applications_AppCode_NotBlank CHECK (LEN(LTRIM(RTRIM(AppCode))) > 0),
CONSTRAINT CK_Applications_AppName_NotBlank CHECK (LEN(LTRIM(RTRIM(AppName))) > 0),
CONSTRAINT CK_Applications_AppVersion_NotBlank CHECK (LEN(LTRIM(RTRIM(AppVersion))) > 0)
);
GO
CREATE TABLE dbo.ApplicationPackages
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_ApplicationPackages PRIMARY KEY CLUSTERED
CONSTRAINT DF_ApplicationPackages_Id DEFAULT NEWSEQUENTIALID(),
ApplicationId UNIQUEIDENTIFIER NOT NULL,
PackageId UNIQUEIDENTIFIER NOT NULL,
SelectedVersionId UNIQUEIDENTIFIER NULL,
AddedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_ApplicationPackages_AddedAt DEFAULT SYSUTCDATETIME(),
Notes NVARCHAR(500) NULL,
CONSTRAINT FK_ApplicationPackages_Application
FOREIGN KEY (ApplicationId) REFERENCES dbo.Applications(Id) ON DELETE CASCADE,
CONSTRAINT FK_ApplicationPackages_Package
FOREIGN KEY (PackageId) REFERENCES dbo.Packages(Id) ON DELETE CASCADE
);
GO
CREATE UNIQUE INDEX UX_Users_Username ON dbo.Users(Username);
CREATE UNIQUE INDEX UX_Users_Email ON dbo.Users(Email);
GO
CREATE UNIQUE INDEX UX_EmailConfirmationTokens_TokenHash
ON dbo.EmailConfirmationTokens(TokenHash);
CREATE INDEX IX_EmailConfirmationTokens_UserId
ON dbo.EmailConfirmationTokens(UserId);
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);
GO
CREATE UNIQUE INDEX UX_PackageVersions_PackageId_Version
ON dbo.PackageVersions(PackageId, Version);
CREATE UNIQUE INDEX UX_PackageVersions_Id_PackageId
ON dbo.PackageVersions(Id, PackageId);
CREATE UNIQUE INDEX UX_PackageVersions_OneLatestPerPackage
ON dbo.PackageVersions(PackageId)
WHERE IsLatest = 1;
CREATE INDEX IX_PackageVersions_PackageId_ReleaseDate
ON dbo.PackageVersions(PackageId, ReleaseDate DESC);
GO
CREATE UNIQUE INDEX UX_Applications_AppCode
ON dbo.Applications(AppCode);
CREATE INDEX IX_Applications_CreatedByUserId ON dbo.Applications(CreatedByUserId);
CREATE INDEX IX_Applications_Status ON dbo.Applications(Status);
GO
CREATE UNIQUE INDEX UX_ApplicationPackages_ApplicationId_PackageId
ON dbo.ApplicationPackages(ApplicationId, PackageId);
CREATE INDEX IX_ApplicationPackages_PackageId
ON dbo.ApplicationPackages(PackageId);
CREATE INDEX IX_ApplicationPackages_SelectedVersionId
ON dbo.ApplicationPackages(SelectedVersionId);
GO
ALTER TABLE dbo.ApplicationPackages
ADD CONSTRAINT FK_ApplicationPackages_SelectedVersionBelongsToPackage
FOREIGN KEY (SelectedVersionId, PackageId)
REFERENCES dbo.PackageVersions(Id, PackageId);
GO
CREATE OR ALTER PROCEDURE dbo.SetLatestPackageVersion
@PackageVersionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PackageId UNIQUEIDENTIFIER;
SELECT @PackageId = PackageId
FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @PackageId IS NULL
BEGIN
THROW 50002, 'Package version does not exist.', 1;
END;
BEGIN TRANSACTION;
UPDATE dbo.PackageVersions
SET IsLatest = 0
WHERE PackageId = @PackageId;
UPDATE dbo.PackageVersions
SET IsLatest = 1,
IsDeprecated = 0
WHERE Id = @PackageVersionId;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.DeletePackageVersion
@PackageVersionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PackageId UNIQUEIDENTIFIER;
DECLARE @WasLatest BIT;
SELECT
@PackageId = PackageId,
@WasLatest = IsLatest
FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @PackageId IS NULL
BEGIN
THROW 50003, 'Package version does not exist.', 1;
END;
BEGIN TRANSACTION;
DELETE FROM dbo.ApplicationPackages
WHERE SelectedVersionId = @PackageVersionId;
DELETE FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @WasLatest = 1
BEGIN
DECLARE @NextLatestId UNIQUEIDENTIFIER;
SELECT TOP (1) @NextLatestId = Id
FROM dbo.PackageVersions
WHERE PackageId = @PackageId
AND IsDeprecated = 0
ORDER BY ReleaseDate DESC, UploadedAt DESC;
IF @NextLatestId IS NOT NULL
BEGIN
EXEC dbo.SetLatestPackageVersion @NextLatestId;
END;
END;
COMMIT TRANSACTION;
END;
GO
PRINT N'RobotInstaller schema was created successfully.';
GO

View File

@@ -0,0 +1,134 @@
USE [RobotInstaller];
GO
CREATE OR ALTER VIEW dbo.vw_PackageList
AS
SELECT
p.Id,
p.PackageCode,
p.PackageName,
p.PackageType,
p.Description,
p.IsActive,
p.CreatedAt,
p.UpdatedAt,
p.CreatedByUserId,
u.Username AS CreatedByUsername,
latest.Id AS LatestVersionId,
latest.Version AS LatestVersion,
latest.ReleaseDate AS LatestReleaseDate,
latest.FilePath AS LatestFilePath,
latest.DockerImage AS LatestDockerImage,
version_count.VersionCount
FROM dbo.Packages AS p
INNER JOIN dbo.Users AS u
ON u.Id = p.CreatedByUserId
OUTER APPLY
(
SELECT TOP (1)
pv.Id,
pv.Version,
pv.ReleaseDate,
pv.FilePath,
pv.DockerImage
FROM dbo.PackageVersions AS pv
WHERE pv.PackageId = p.Id
ORDER BY pv.IsLatest DESC, pv.ReleaseDate DESC, pv.UploadedAt DESC
) AS latest
OUTER APPLY
(
SELECT COUNT_BIG(*) AS VersionCount
FROM dbo.PackageVersions AS pv
WHERE pv.PackageId = p.Id
) AS version_count;
GO
CREATE OR ALTER VIEW dbo.vw_PackageVersionList
AS
SELECT
pv.Id,
pv.PackageId,
p.PackageCode,
p.PackageName,
p.PackageType,
pv.Version,
pv.FilePath,
pv.DockerImage,
pv.FileChecksumSha256,
pv.FileSizeBytes,
pv.ChangeLog,
pv.ReleaseDate,
pv.UploadedAt,
pv.IsLatest,
pv.IsDeprecated
FROM dbo.PackageVersions AS pv
INNER JOIN dbo.Packages AS p
ON p.Id = pv.PackageId;
GO
CREATE OR ALTER VIEW dbo.vw_ApplicationList
AS
SELECT
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username AS CreatedByUsername,
COUNT_BIG(ap.Id) AS PackageCount
FROM dbo.Applications AS a
INNER JOIN dbo.Users AS u
ON u.Id = a.CreatedByUserId
LEFT JOIN dbo.ApplicationPackages AS ap
ON ap.ApplicationId = a.Id
GROUP BY
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username;
GO
CREATE OR ALTER VIEW dbo.vw_ApplicationPackageDetails
AS
SELECT
ap.Id,
ap.ApplicationId,
a.AppCode,
a.AppName,
a.AppVersion,
a.OpenUrl AS AppOpenUrl,
ap.PackageId,
p.PackageCode,
p.PackageName,
p.PackageType,
ap.SelectedVersionId,
pv.Version AS SelectedVersion,
pv.FilePath,
pv.DockerImage,
ap.AddedAt,
ap.Notes
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a
ON a.Id = ap.ApplicationId
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS pv
ON pv.Id = ap.SelectedVersionId;
GO
PRINT N'RobotInstaller views were created successfully.';
GO

View File

@@ -0,0 +1,93 @@
# RobotInstaller database
Thiết kế này bám theo `database.md` và sơ đồ database hiện có, sau đó bổ sung vài cột cần cho web server:
- `PackageType`: phân biệt package `.deb``docker`.
- `AppVersion`: version hiện tại của app đóng gói.
- metadata artifact: `DockerImage`, `FileChecksumSha256`, `FileSizeBytes`, `UploadedAt`.
- `Role`, `IsActive` cho tài khoản đăng nhập web.
## Database
Tên database được chọn: `RobotInstaller`
Server:
```powershell
172.20.235.176
```
Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo file `.env` từ `web-server/.env.example` rồi điền mật khẩu thật.
## Cấu trúc chính
| Bảng | Vai trò |
| --- | --- |
| `dbo.Users` | Người dùng web server |
| `dbo.Packages` | Danh mục package |
| `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ể |
## Ràng buộc quan trọng
- `Users.Username`, `Users.Email` là duy nhất.
- `Packages.PackageCode` là duy nhất.
- `Applications.AppCode` là duy nhất.
- Mỗi package không được trùng `Version`.
- Mỗi app chỉ chứa một dòng cho mỗi package.
- Mỗi package chỉ có tối đa một `IsLatest = 1`.
- `ApplicationPackages.SelectedVersionId` bắt buộc thuộc đúng `PackageId` trên cùng dòng.
## View cho API
| View | Dùng cho màn hình |
| --- | --- |
| `dbo.vw_PackageList` | Danh sách package kèm latest version |
| `dbo.vw_PackageVersionList` | Chi tiết version của package |
| `dbo.vw_ApplicationList` | Danh sách app kèm số package |
| `dbo.vw_ApplicationPackageDetails` | Chi tiết package/version trong app |
## Stored procedure
| Procedure | Mục đích |
| --- | --- |
| `dbo.SetLatestPackageVersion` | Đặt một version là latest và tự clear latest cũ |
| `dbo.DeletePackageVersion` | Xóa version và các liên kết app đang dùng version đó |
## Triển khai bằng sqlcmd
```powershell
$env:SQLCMDPASSWORD = '<mat-khau-sa>'
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
```
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.
## Luồng dữ liệu đề xuất
1. Upload package mới:
- nếu package chưa tồn tại, insert vào `Packages`;
- insert version vào `PackageVersions`;
- gọi `dbo.SetLatestPackageVersion @PackageVersionId`.
2. Update package:
- insert thêm một dòng mới vào `PackageVersions`;
- gọi `dbo.SetLatestPackageVersion @PackageVersionId`.
3. Tạo app:
- insert vào `Applications`;
- insert các package được chọn vào `ApplicationPackages`;
- nếu user chọn version cụ thể, điền `SelectedVersionId`.
4. Xóa package:
- xóa dòng trong `Packages`;
- database tự cascade sang `PackageVersions``ApplicationPackages`.
5. Xóa version:
- gọi `dbo.DeletePackageVersion @PackageVersionId` để xóa cả liên kết app đang dùng version đó.

View File

@@ -0,0 +1,26 @@
services:
web-server:
image: ${WEB_SERVER_IMAGE_REPOSITORY:-robot-installer-web-server}:${IMAGE_TAG:-local}
build:
context: .
container_name: ${WEB_SERVER_CONTAINER_NAME:-robot-installer-web-server}
env_file:
- ./.env
environment:
NODE_ENV: production
PORT: 3000
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1:3000}
ports:
- "${WEB_SERVER_PORT:-3000}:3000"
volumes:
- ${WEB_SERVER_UPLOADS_DIR:-./uploads}:/app/uploads
networks:
- robot-installer
restart: unless-stopped
networks:
robot-installer:
name: ${DOCKER_NETWORK:-robot-installer-net}
external: true

View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -e
mkdir -p /app/uploads/packages/agent
chown -R node:node /app/uploads
exec su-exec node "$@"

1948
web-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
web-server/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "robot-installer-web-server",
"version": "1.0.0",
"private": true,
"description": "Robot Installer package management web server UI",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
"mssql": "^12.5.4",
"multer": "^2.1.1",
"nodemailer": "^8.0.7",
"notiflix": "^3.2.8"
}
}

File diff suppressed because it is too large Load Diff

721
web-server/public/js/app.js Normal file
View File

@@ -0,0 +1,721 @@
(function () {
const body = document.body;
const menuButton = document.getElementById('mobileMenuBtn');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
function initNotiflix() {
if (!window.Notiflix) return;
window.Notiflix.Notify.init({
width: '320px',
position: 'right-top',
distance: '16px',
timeout: 2600,
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
fontSize: '13px',
messageMaxLength: 180,
clickToClose: true,
pauseOnHover: true,
cssAnimationStyle: 'from-right',
useIcon: true,
zindex: 5000,
success: {
background: '#067647',
textColor: '#ffffff'
},
failure: {
background: '#b42318',
textColor: '#ffffff'
},
warning: {
background: '#b54708',
textColor: '#ffffff'
},
info: {
background: '#3755c3',
textColor: '#ffffff'
}
});
window.Notiflix.Confirm.init({
width: '360px',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
titleColor: '#111827',
titleFontSize: '16px',
messageColor: '#475569',
messageFontSize: '13px',
okButtonBackground: '#3755c3',
okButtonColor: '#ffffff',
cancelButtonBackground: '#e2e8f0',
cancelButtonColor: '#334155',
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
zindex: 5001,
cssAnimationStyle: 'zoom'
});
}
function notify(type, message) {
if (!message) return;
if (!window.Notiflix) {
console.info(message);
return;
}
const Notify = window.Notiflix.Notify;
if (type === 'success') {
Notify.success(message);
return;
}
if (type === 'failure') {
Notify.failure(message);
return;
}
if (type === 'warning') {
Notify.warning(message);
return;
}
Notify.info(message);
}
function confirmAction(message, onConfirm) {
if (!window.Notiflix) {
if (window.confirm(message || 'Xác nhận thao tác?') && typeof onConfirm === 'function') {
onConfirm();
}
return;
}
window.Notiflix.Confirm.show(
'Xác nhận thao tác',
message,
'Xác nhận',
'Hủy',
() => {
if (typeof onConfirm === 'function') {
onConfirm();
return;
}
notify('success', 'Đã xác nhận thao tác');
},
() => {
notify('info', 'Đã hủy thao tác');
}
);
}
function setMobileNav(open) {
body.classList.toggle('mobile-nav-open', open);
if (menuButton) {
menuButton.setAttribute('aria-expanded', open ? 'true' : 'false');
}
}
function openModal(id) {
const modal = document.getElementById(id);
if (!modal) return;
modal.classList.add('open');
const focusTarget = modal.querySelector('input, select, textarea, button');
if (focusTarget) {
window.setTimeout(() => focusTarget.focus(), 60);
}
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove('open');
}
function applyTableFilters(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const searchInput = document.querySelector(`[data-table-search="${tableId}"]`);
const query = searchInput ? searchInput.value.trim().toLowerCase() : '';
const selects = document.querySelectorAll(`[data-filter-select][data-filter-table="${tableId}"]`);
table.querySelectorAll('tbody tr').forEach((row) => {
const matchesSearch = !query || (row.dataset.search || '').includes(query);
let matchesSelects = true;
selects.forEach((select) => {
const column = select.dataset.filterColumn;
const value = select.value;
if (value && row.dataset[column] !== value) {
matchesSelects = false;
}
});
row.hidden = !(matchesSearch && matchesSelects);
});
}
function formatFileSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, index);
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function isAllowedPackageFile(file) {
if (!file) return false;
const name = file.name.toLowerCase();
const allowedExtensions = ['.deb', '.tar', '.tar.gz', '.tgz', '.zip', '.gz'];
return allowedExtensions.some((extension) => name.endsWith(extension));
}
function renderSelectedFile(zone, file) {
const preview = zone.querySelector('[data-file-preview]');
const fileName = zone.querySelector('[data-file-name]');
const fileMeta = zone.querySelector('[data-file-meta]');
if (!preview || !fileName || !fileMeta) return;
if (!file) {
preview.hidden = true;
fileName.textContent = 'Chưa chọn file';
fileMeta.textContent = '';
zone.classList.remove('has-file');
return;
}
fileName.textContent = file.name;
fileMeta.textContent = `${formatFileSize(file.size)}${file.type || 'package file'}`;
preview.hidden = false;
zone.classList.add('has-file');
notify('success', `Đã chọn file: ${file.name}`);
}
function setInputFiles(input, files) {
try {
input.files = files;
} catch (error) {
console.info('Browser does not allow assigning dropped files to input.files.', error);
}
}
function handlePackageFiles(zone, files) {
const input = zone.querySelector('[data-file-input]');
const file = files && files[0];
if (!input || !file) return;
if (!isAllowedPackageFile(file)) {
notify('warning', 'File chưa đúng định dạng. Vui lòng chọn .deb, .tar, .tgz, .zip hoặc .gz.');
return;
}
setInputFiles(input, files);
renderSelectedFile(zone, file);
}
function initFileDropzones() {
document.querySelectorAll('[data-file-dropzone]').forEach((zone) => {
const input = zone.querySelector('[data-file-input]');
const browseButton = zone.querySelector('[data-file-browse]');
const clearButton = zone.querySelector('[data-file-clear]');
if (!input) return;
if (browseButton) {
browseButton.addEventListener('click', () => input.click());
}
if (clearButton) {
clearButton.addEventListener('click', () => {
input.value = '';
renderSelectedFile(zone, null);
notify('info', 'Đã bỏ file đã chọn');
});
}
input.addEventListener('change', () => {
handlePackageFiles(zone, input.files);
});
['dragenter', 'dragover'].forEach((eventName) => {
zone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach((eventName) => {
zone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', (event) => {
handlePackageFiles(zone, event.dataTransfer.files);
});
});
}
function updateRegisterSubmit(form) {
const submitButton = form.querySelector('[data-register-submit]');
if (!submitButton) return;
const uniqueInputs = form.querySelectorAll('[data-unique-check]');
const isBlocked = Array.from(uniqueInputs).some((input) => (
input.dataset.uniqueStatus === 'error' || input.dataset.uniqueStatus === 'checking'
));
submitButton.disabled = isBlocked;
}
function setUniqueState(input, state, message) {
const field = input.closest('.form-field');
const form = input.closest('[data-register-form]');
const feedback = form
? form.querySelector(`[data-unique-feedback="${input.dataset.uniqueCheck}"]`)
: null;
input.dataset.uniqueStatus = state;
if (field) {
field.classList.toggle('has-error', state === 'error');
field.classList.toggle('has-success', state === 'success');
}
if (feedback) {
feedback.textContent = message || '';
feedback.style.display = message ? 'block' : '';
}
input.setCustomValidity(state === 'error' ? message : '');
if (form) {
updateRegisterSubmit(form);
}
}
function checkUniqueInput(input) {
const field = input.dataset.uniqueCheck;
const value = input.value.trim();
if (!field || !value) {
setUniqueState(input, 'idle', '');
return;
}
if (field === 'email' && !input.validity.valid) {
setUniqueState(input, 'idle', '');
return;
}
const requestId = String(Date.now());
input.dataset.uniqueRequestId = requestId;
setUniqueState(input, 'checking', 'Đang kiểm tra...');
fetch(`/register/check?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, {
headers: {
Accept: 'application/json'
}
})
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Cannot check field')))
.then((data) => {
if (input.dataset.uniqueRequestId !== requestId) return;
setUniqueState(
input,
data.available ? 'success' : 'error',
data.message || (data.available ? 'Có thể sử dụng.' : 'Đã tồn tại.')
);
})
.catch((error) => {
console.info('Cannot check registration field:', error);
if (input.dataset.uniqueRequestId === requestId) {
setUniqueState(input, 'idle', '');
}
});
}
function initRegistrationUniqueChecks() {
document.querySelectorAll('[data-register-form]').forEach((form) => {
const timers = new Map();
form.querySelectorAll('[data-unique-check]').forEach((input) => {
input.addEventListener('input', () => {
window.clearTimeout(timers.get(input));
setUniqueState(input, 'idle', '');
timers.set(input, window.setTimeout(() => checkUniqueInput(input), 450));
});
input.addEventListener('blur', () => {
window.clearTimeout(timers.get(input));
checkUniqueInput(input);
});
if (input.value.trim()) {
checkUniqueInput(input);
}
});
form.addEventListener('submit', (event) => {
const blockedInput = form.querySelector('[data-unique-status="error"], [data-unique-status="checking"]');
if (!blockedInput) return;
event.preventDefault();
notify('warning', blockedInput.dataset.uniqueStatus === 'checking'
? 'Đang kiểm tra username/email, vui lòng chờ một chút.'
: 'Vui lòng đổi username/email đang bị trùng.');
blockedInput.focus();
});
});
}
function getUserDataFromRow(row) {
return {
id: row.dataset.userId || '',
name: row.dataset.userName || '',
username: row.dataset.userUsername || '',
email: row.dataset.userEmail || '',
fullName: row.dataset.userFullName || '',
role: row.dataset.userRole || 'User',
status: row.dataset.userStatus || '',
isActive: row.dataset.userActive === 'true',
createdAt: row.dataset.userCreatedAt || '',
updatedAt: row.dataset.userUpdatedAt || '',
packageCount: row.dataset.userPackageCount || '0',
applicationCount: row.dataset.userApplicationCount || '0'
};
}
function parseJsonAttribute(value, fallback) {
try {
return JSON.parse(value || '');
} catch (error) {
return fallback;
}
}
function getAppDataFromTrigger(trigger) {
return {
id: trigger.dataset.appId || '',
code: trigger.dataset.appCode || '',
name: trigger.dataset.appName || '',
version: trigger.dataset.appVersion || '',
status: trigger.dataset.appStatus || 'Draft',
openUrl: trigger.dataset.appOpenUrl || '',
notes: trigger.dataset.appNotes || '',
packages: parseJsonAttribute(trigger.dataset.appPackages, [])
};
}
function findEditAppVersionSelect(form, packageId) {
return Array.from(form.querySelectorAll('[data-edit-app-version]'))
.find((select) => select.dataset.editAppVersion === packageId);
}
function setEditAppPackageEnabled(form, checkbox) {
const select = findEditAppVersionSelect(form, checkbox.dataset.editAppPackage);
if (select) {
select.disabled = !checkbox.checked;
}
}
function setEditAppField(form, field, value) {
const input = form.querySelector(`[data-edit-app-field="${field}"]`);
if (input) {
input.value = value || '';
}
}
function openAppEdit(trigger) {
const app = getAppDataFromTrigger(trigger);
const form = document.getElementById('editAppForm');
if (!form || !app.id) return;
form.action = `/applications/${encodeURIComponent(app.id)}/edit`;
setEditAppField(form, 'appCode', app.code);
setEditAppField(form, 'appName', app.name);
setEditAppField(form, 'appVersion', app.version);
setEditAppField(form, 'status', app.status);
setEditAppField(form, 'openUrl', app.openUrl);
setEditAppField(form, 'notes', app.notes);
form.querySelectorAll('[data-edit-app-package]').forEach((checkbox) => {
checkbox.checked = false;
setEditAppPackageEnabled(form, checkbox);
});
app.packages.forEach((packageItem) => {
const packageId = packageItem.packageId || '';
const checkbox = Array.from(form.querySelectorAll('[data-edit-app-package]'))
.find((input) => input.dataset.editAppPackage === packageId);
const select = findEditAppVersionSelect(form, packageId);
if (checkbox) {
checkbox.checked = true;
}
if (select) {
select.disabled = false;
select.value = packageItem.selectedVersionId || '';
}
});
openModal('editAppModal');
}
function openPackageUpdate(packageId) {
const modalId = 'updatePackageModal';
const modal = document.getElementById(modalId);
const packageSelect = modal ? modal.querySelector('select[name="packageId"]') : null;
if (packageSelect && packageId) {
packageSelect.value = packageId;
}
openModal(modalId);
}
function setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value || '';
}
}
function openUserDetail(row) {
const user = getUserDataFromRow(row);
setText('[data-user-detail="name"]', user.name);
setText('[data-user-detail="username"]', user.username);
setText('[data-user-detail="email"]', user.email);
setText('[data-user-detail="role"]', user.role);
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`);
openModal('userDetailModal');
}
function openUserEdit(row) {
const user = getUserDataFromRow(row);
const form = document.getElementById('editUserForm');
if (!form) return;
form.action = `/users/${encodeURIComponent(user.id)}/edit`;
form.querySelector('[data-edit-user-field="username"]').value = user.username;
form.querySelector('[data-edit-user-field="fullName"]').value = user.fullName;
form.querySelector('[data-edit-user-field="email"]').value = user.email;
form.querySelector('[data-edit-user-field="role"]').value = user.role;
form.querySelector('[data-edit-user-field="isActive"]').checked = user.isActive;
form.querySelector('[data-edit-user-field="newPassword"]').value = '';
form.querySelector('[data-edit-user-field="confirmPassword"]').value = '';
openModal('editUserModal');
}
function validateProfileForm(form, shouldNotify) {
const email = form.querySelector('[data-profile-email]');
const confirmEmail = form.querySelector('[data-profile-confirm-email]');
const newPassword = form.querySelector('[data-profile-new-password]');
const confirmPassword = form.querySelector('[data-profile-confirm-password]');
const emailFeedback = form.querySelector('[data-profile-feedback="email"]');
const passwordFeedback = form.querySelector('[data-profile-feedback="password"]');
const emailMismatch = Boolean(email && confirmEmail
&& email.value.trim().toLowerCase() !== confirmEmail.value.trim().toLowerCase());
const passwordMismatch = Boolean(newPassword && confirmPassword
&& (newPassword.value || confirmPassword.value)
&& newPassword.value !== confirmPassword.value);
if (confirmEmail) {
confirmEmail.setCustomValidity(emailMismatch ? 'Confirm email mới chưa khớp.' : '');
const field = confirmEmail.closest('.form-field');
if (field) {
field.classList.toggle('has-error', emailMismatch);
}
}
if (emailFeedback) {
emailFeedback.textContent = emailMismatch ? 'Confirm email mới chưa khớp.' : '';
emailFeedback.style.display = emailMismatch ? 'block' : '';
}
if (confirmPassword) {
confirmPassword.setCustomValidity(passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '');
const field = confirmPassword.closest('.form-field');
if (field) {
field.classList.toggle('has-error', passwordMismatch);
}
}
if (passwordFeedback) {
passwordFeedback.textContent = passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '';
passwordFeedback.style.display = passwordMismatch ? 'block' : '';
}
if (shouldNotify && emailMismatch) {
notify('warning', 'Confirm email mới chưa khớp.');
} else if (shouldNotify && passwordMismatch) {
notify('warning', 'Xác nhận mật khẩu mới chưa khớp.');
}
return !emailMismatch && !passwordMismatch;
}
function initProfileForms() {
document.querySelectorAll('[data-profile-form]').forEach((form) => {
form.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => validateProfileForm(form, false));
});
form.addEventListener('submit', (event) => {
if (!validateProfileForm(form, true)) {
event.preventDefault();
}
});
});
}
function initEditAppForms() {
document.querySelectorAll('#editAppForm [data-edit-app-package]').forEach((checkbox) => {
const form = checkbox.closest('form');
setEditAppPackageEnabled(form, checkbox);
checkbox.addEventListener('change', () => setEditAppPackageEnabled(form, checkbox));
});
}
initNotiflix();
initFileDropzones();
initRegistrationUniqueChecks();
initProfileForms();
initEditAppForms();
if (body.dataset.notice) {
notify(body.dataset.noticeType || 'info', body.dataset.notice);
const url = new URL(window.location.href);
if (url.searchParams.has('notice') || url.searchParams.has('noticeType')) {
url.searchParams.delete('notice');
url.searchParams.delete('noticeType');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
delete body.dataset.notice;
delete body.dataset.noticeType;
}
if (menuButton) {
menuButton.addEventListener('click', () => {
setMobileNav(!body.classList.contains('mobile-nav-open'));
});
}
if (sidebarBackdrop) {
sidebarBackdrop.addEventListener('click', () => setMobileNav(false));
}
document.querySelectorAll('[data-table-search]').forEach((input) => {
input.addEventListener('input', () => applyTableFilters(input.dataset.tableSearch));
});
document.querySelectorAll('[data-filter-select]').forEach((select) => {
select.addEventListener('change', () => applyTableFilters(select.dataset.filterTable));
});
document.addEventListener('click', (event) => {
const appEditButton = event.target.closest('[data-app-edit]');
if (appEditButton) {
openAppEdit(appEditButton);
return;
}
const packageUpdateButton = event.target.closest('[data-package-update]');
if (packageUpdateButton) {
openPackageUpdate(packageUpdateButton.dataset.packageUpdate);
return;
}
const refreshButton = event.target.closest('[data-refresh-page]');
if (refreshButton) {
window.location.reload();
return;
}
const userViewButton = event.target.closest('[data-user-view]');
if (userViewButton) {
const row = userViewButton.closest('tr');
if (row) {
openUserDetail(row);
}
return;
}
const userEditButton = event.target.closest('[data-user-edit]');
if (userEditButton) {
const row = userEditButton.closest('tr');
if (row) {
openUserEdit(row);
}
return;
}
const modalOpenButton = event.target.closest('[data-modal-open]');
if (modalOpenButton) {
openModal(modalOpenButton.dataset.modalOpen);
return;
}
const modalCloseButton = event.target.closest('[data-modal-close]');
if (modalCloseButton) {
closeModal(modalCloseButton.closest('.modal-backdrop'));
}
const modalBackdrop = event.target.classList.contains('modal-backdrop') ? event.target : null;
if (modalBackdrop) {
closeModal(modalBackdrop);
}
const toastButton = event.target.closest('[data-toast]');
if (toastButton) {
notify(toastButton.dataset.toastType || 'info', toastButton.dataset.toast);
}
const confirmButton = event.target.closest('[data-confirm]');
if (confirmButton) {
confirmAction(confirmButton.dataset.confirm);
}
});
document.addEventListener('submit', (event) => {
const form = event.target.closest('form[data-confirm-submit]');
if (!form || form.dataset.confirmed === 'true') return;
event.preventDefault();
confirmAction(form.dataset.confirmSubmit, () => {
form.dataset.confirmed = 'true';
form.submit();
});
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const openModalNode = document.querySelector('.modal-backdrop.open');
if (openModalNode) {
closeModal(openModalNode);
return;
}
setMobileNav(false);
});
})();

2257
web-server/server.js Normal file

File diff suppressed because it is too large Load Diff

50
web-server/src/db.js Normal file
View File

@@ -0,0 +1,50 @@
const sql = require('mssql');
let poolPromise;
function boolFromEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
return ['1', 'true', 'yes'].includes(String(value).toLowerCase());
}
function getConfig() {
return {
server: process.env.SQLSERVER_HOST || 'localhost',
port: Number(process.env.SQLSERVER_PORT || 1433),
database: process.env.SQLSERVER_DATABASE || 'RobotInstaller',
user: process.env.SQLSERVER_USER,
password: process.env.SQLSERVER_PASSWORD,
options: {
encrypt: boolFromEnv(process.env.SQLSERVER_ENCRYPT, false),
trustServerCertificate: boolFromEnv(process.env.SQLSERVER_TRUST_SERVER_CERTIFICATE, true),
useUTC: boolFromEnv(process.env.SQLSERVER_USE_UTC, true)
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
}
async function getPool() {
if (!poolPromise) {
poolPromise = sql.connect(getConfig());
}
return poolPromise;
}
async function closePool() {
if (!poolPromise) return;
const pool = await poolPromise;
await pool.close();
poolPromise = undefined;
}
module.exports = {
sql,
getPool,
closePool
};

100
web-server/src/mailer.js Normal file
View File

@@ -0,0 +1,100 @@
const nodemailer = require('nodemailer');
let transporter;
function boolFromEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
return ['1', 'true', 'yes'].includes(String(value).toLowerCase());
}
function isConfigured() {
return Boolean(process.env.SMTP_HOST && process.env.SMTP_USER);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function getSenderAddress() {
return process.env.MAIL_FROM || process.env.SMTP_USER;
}
function getTransporter() {
if (!isConfigured()) return null;
if (!transporter) {
const port = Number(process.env.SMTP_PORT || 587);
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: boolFromEnv(process.env.SMTP_SECURE, port === 465),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD || ''
}
});
}
return transporter;
}
async function sendConfirmationEmail({ to, name, confirmUrl }) {
const mailTransporter = getTransporter();
const safeName = escapeHtml(name || to);
const safeConfirmUrl = escapeHtml(confirmUrl);
if (process.env.NODE_ENV !== 'production') {
console.info(`Email confirmation link for ${to}: ${confirmUrl}`);
}
if (!mailTransporter) {
console.warn(`SMTP is not configured. Email confirmation link for ${to}: ${confirmUrl}`);
return { sent: false, reason: 'SMTP_NOT_CONFIGURED' };
}
const result = await mailTransporter.sendMail({
from: getSenderAddress(),
to,
subject: 'Xác nhận tài khoản Robot Installer',
text: [
`Xin chào ${name || to},`,
'',
'Bạn vừa đăng ký tài khoản hoặc cập nhật email Robot Installer.',
`Bấm link sau để xác nhận email và kích hoạt tài khoản: ${confirmUrl}`,
'',
'Nếu bạn không thực hiện đăng ký này, hãy bỏ qua email.'
].join('\n'),
html: `
<div style="font-family: Arial, sans-serif; color: #172033; line-height: 1.5;">
<h2 style="margin: 0 0 12px;">Xác nhận tài khoản Robot Installer</h2>
<p>Xin chào ${safeName},</p>
<p>Bạn vừa đăng ký tài khoản hoặc cập nhật email Robot Installer. Bấm nút bên dưới để xác nhận email và kích hoạt tài khoản.</p>
<p>
<a href="${safeConfirmUrl}" style="background: #3755c3; border-radius: 8px; color: #ffffff; display: inline-block; font-weight: 700; padding: 10px 14px; text-decoration: none;">
Xác nhận email
</a>
</p>
<p style="color: #566166; font-size: 13px;">Nếu nút không mở được, copy link này vào trình duyệt:<br>${safeConfirmUrl}</p>
</div>
`
});
console.info('Confirmation email sent:', {
to,
messageId: result.messageId,
accepted: result.accepted,
rejected: result.rejected
});
return { sent: true };
}
module.exports = {
sendConfirmationEmail
};

137
web-server/src/mock-data.js Normal file
View File

@@ -0,0 +1,137 @@
const packages = [
{
id: 'navigation-stack',
code: 'NAV-STACK',
name: 'Navigation Stack',
type: 'deb',
latestVersion: '2.4.1',
latestReleaseDate: '2026-05-17',
status: 'Active',
owner: 'Dũng Tào',
description: 'Core navigation package for robot route planning and localization.',
artifact: '/packages/navigation-stack_2.4.1_amd64.deb',
versions: [
{ version: '2.4.1', releaseDate: '2026-05-17', uploadedBy: 'Dũng Tào', status: 'Latest', size: '84.2 MB', changeLog: 'Improve recovery flow and map alignment.' },
{ version: '2.4.0', releaseDate: '2026-05-10', uploadedBy: 'Dũng Tào', status: 'Stable', size: '83.8 MB', changeLog: 'Add obstacle avoidance tuning profile.' },
{ version: '2.3.8', releaseDate: '2026-04-28', uploadedBy: 'QA Robot', status: 'Deprecated', size: '81.6 MB', changeLog: 'Legacy build kept for rollback.' }
]
},
{
id: 'fleet-agent',
code: 'FLEET-AGENT',
name: 'Fleet Agent',
type: 'docker',
latestVersion: '1.9.0',
latestReleaseDate: '2026-05-16',
status: 'Active',
owner: 'Minh Anh',
description: 'Docker service that connects robot clients to Fleet Manager.',
artifact: 'registry.local/robot/fleet-agent:1.9.0',
versions: [
{ version: '1.9.0', releaseDate: '2026-05-16', uploadedBy: 'Minh Anh', status: 'Latest', size: '412 MB', changeLog: 'Add heartbeat metrics and websocket retry.' },
{ version: '1.8.2', releaseDate: '2026-05-01', uploadedBy: 'Minh Anh', status: 'Stable', size: '405 MB', changeLog: 'Fix token renewal after sleep.' }
]
},
{
id: 'map-sync',
code: 'MAP-SYNC',
name: 'Map Sync Service',
type: 'docker',
latestVersion: '3.1.2',
latestReleaseDate: '2026-05-12',
status: 'Active',
owner: 'Hải Nam',
description: 'Synchronizes robot map revisions between web server and edge clients.',
artifact: 'registry.local/robot/map-sync:3.1.2',
versions: [
{ version: '3.1.2', releaseDate: '2026-05-12', uploadedBy: 'Hải Nam', status: 'Latest', size: '268 MB', changeLog: 'Compress map snapshot before upload.' },
{ version: '3.0.5', releaseDate: '2026-04-22', uploadedBy: 'Hải Nam', status: 'Stable', size: '251 MB', changeLog: 'Improve checksum validation.' }
]
},
{
id: 'ui-kiosk',
code: 'UI-KIOSK',
name: 'Robot Kiosk UI',
type: 'deb',
latestVersion: '0.8.6',
latestReleaseDate: '2026-05-08',
status: 'Testing',
owner: 'Linh Phạm',
description: 'Touch-screen UI package for robot kiosk mode.',
artifact: '/packages/robot-kiosk-ui_0.8.6_amd64.deb',
versions: [
{ version: '0.8.6', releaseDate: '2026-05-08', uploadedBy: 'Linh Phạm', status: 'Latest', size: '62.4 MB', changeLog: 'Refine operator handoff screens.' },
{ version: '0.8.3', releaseDate: '2026-04-19', uploadedBy: 'Linh Phạm', status: 'Deprecated', size: '59.9 MB', changeLog: 'Initial kiosk dashboard.' }
]
}
];
const applications = [
{
id: 'warehouse-basic',
code: 'APP-WH-BASIC',
name: 'Warehouse Basic',
version: '1.2.0',
status: 'Released',
createdAt: '2026-05-18',
createdBy: 'Dũng Tào',
notes: 'Base package set for warehouse robots.',
packages: [
{ code: 'NAV-STACK', name: 'Navigation Stack', type: 'deb', selectedVersion: '2.4.1' },
{ code: 'FLEET-AGENT', name: 'Fleet Agent', type: 'docker', selectedVersion: '1.9.0' },
{ code: 'MAP-SYNC', name: 'Map Sync Service', type: 'docker', selectedVersion: '3.1.2' }
]
},
{
id: 'kiosk-demo',
code: 'APP-KIOSK-DEMO',
name: 'Kiosk Demo',
version: '0.4.0',
status: 'Draft',
createdAt: '2026-05-16',
createdBy: 'Linh Phạm',
notes: 'Demo bundle for touch kiosk testing.',
packages: [
{ code: 'UI-KIOSK', name: 'Robot Kiosk UI', type: 'deb', selectedVersion: '0.8.6' },
{ code: 'NAV-STACK', name: 'Navigation Stack', type: 'deb', selectedVersion: '2.4.0' }
]
},
{
id: 'fleet-edge',
code: 'APP-FLEET-EDGE',
name: 'Fleet Edge',
version: '2.0.1',
status: 'Released',
createdAt: '2026-05-11',
createdBy: 'Minh Anh',
notes: 'Fleet manager edge runtime.',
packages: [
{ code: 'FLEET-AGENT', name: 'Fleet Agent', type: 'docker', selectedVersion: '1.9.0' },
{ code: 'MAP-SYNC', name: 'Map Sync Service', type: 'docker', selectedVersion: '3.0.5' }
]
}
];
const activity = [
{ title: 'Upload version 2.4.1', detail: 'Navigation Stack được đặt là latest', time: '09:25', icon: 'upload_file' },
{ title: 'Release Warehouse Basic', detail: 'App version 1.2.0 đã sẵn sàng đóng gói', time: 'Hôm qua', icon: 'task_alt' },
{ title: 'Update Fleet Agent', detail: 'Thêm heartbeat metrics cho Docker image', time: '16/05', icon: 'sync' }
];
module.exports = {
currentUser: {
name: 'Dũng Tào',
role: 'Admin',
email: 'admin@robotics.local'
},
packages,
applications,
activity,
stats: {
totalPackages: packages.length,
activePackages: packages.filter((item) => item.status === 'Active').length,
totalVersions: packages.reduce((total, item) => total + item.versions.length, 0),
totalApplications: applications.length,
releasedApplications: applications.filter((item) => item.status === 'Released').length
}
};

1591
web-server/src/repository.js Normal file

File diff suppressed because it is too large Load Diff

157
web-server/views/agent.ejs Normal file
View File

@@ -0,0 +1,157 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Agent</h1>
<p>Quản lý Local Installer Agent package dùng cho máy client Linux.</p>
</div>
<div class="page-actions">
<span class="badge badge-primary">Admin</span>
<span class="badge badge-info"><%= agentPackages.length %> packages</span>
</div>
</div>
<div class="agent-layout">
<section class="panel">
<div class="panel-header">
<div>
<h2>Upload / Update</h2>
<% if (latestAgentPackage) { %>
<p>Latest <%= preferredArch %>: <strong><%= latestAgentPackage.version %></strong></p>
<% } else { %>
<p>Chưa có Agent package cho <%= preferredArch %>.</p>
<% } %>
</div>
</div>
<form class="agent-upload-form" method="post" action="/agent/packages" enctype="multipart/form-data">
<div class="form-grid">
<label class="form-field">
<span>Version</span>
<input type="text" name="version" placeholder="0.1.1" required>
</label>
<label class="form-field">
<span>Architecture</span>
<input type="text" name="arch" value="<%= preferredArch %>" required>
</label>
<div class="form-field full">
<span>Agent .deb</span>
<div class="file-dropzone" data-file-dropzone>
<input class="file-input" type="file" name="agentFile" accept=".deb" required data-file-input>
<div class="file-dropzone-content">
<span class="material-symbols-outlined">upload_file</span>
<strong>Chọn file Agent package</strong>
<small>Server sẽ lưu thành local-installer-agent_&lt;version&gt;_&lt;arch&gt;.deb</small>
<button class="btn btn-secondary" type="button" data-file-browse>
<span class="material-symbols-outlined">attach_file</span>
Chọn file
</button>
</div>
<div class="file-preview" data-file-preview hidden>
<span class="material-symbols-outlined">draft</span>
<div>
<strong data-file-name>Chưa chọn file</strong>
<small data-file-meta></small>
</div>
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" type="submit">
<span class="material-symbols-outlined">upload</span>
Upload / Update
</button>
</div>
</form>
<div class="agent-command-list">
<label class="form-field full">
<span>Install command</span>
<input class="mono" type="text" value="<%= installCommand %>" readonly>
</label>
<label class="form-field full">
<span>Latest package URL</span>
<input class="mono" type="text" value="<%= latestAgentUrl %>" readonly>
</label>
<label class="form-field full">
<span>Storage folder</span>
<input class="mono" type="text" value="<%= agentPackageDir %>" readonly>
</label>
</div>
</section>
<section class="table-panel wide-panel">
<div class="page-filters inline">
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm theo version, arch, filename..." data-table-search="agentPackagesTable">
</label>
</div>
<div class="table-wrap">
<table id="agentPackagesTable" class="data-table agent-table">
<thead>
<tr>
<th>Version</th>
<th>Arch</th>
<th>File</th>
<th>Size</th>
<th>Uploaded</th>
<th>Status</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% if (agentPackages.length === 0) { %>
<tr>
<td colspan="7" class="table-empty">Chưa có Agent package nào.</td>
</tr>
<% } %>
<% agentPackages.forEach((item) => { %>
<tr data-search="<%= `${item.version} ${item.arch} ${item.fileName}`.toLowerCase() %>">
<td><strong><%= item.version %></strong></td>
<td><span class="badge badge-muted"><%= item.arch %></span></td>
<td>
<span class="table-title"><%= item.fileName %></span>
<span class="table-subtitle"><%= item.downloadPath %></span>
</td>
<td><%= item.sizeLabel %></td>
<td><%= item.uploadedAt %></td>
<td>
<% if (item.isLatestForArch) { %>
<span class="badge badge-success">Latest</span>
<% } else { %>
<span class="badge badge-muted">Stored</span>
<% } %>
</td>
<td class="action-col">
<div class="action-group">
<a class="icon-button subtle" href="<%= item.downloadPath %>" title="Download" aria-label="Download <%= item.fileName %>">
<span class="material-symbols-outlined">download</span>
</a>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= agentPackages.length %> of <%= agentPackages.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</div>
</section>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,123 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<div class="breadcrumb"><a href="/applications">Applications</a><span>/</span><span><%= application.code %></span></div>
<h1><%= application.name %></h1>
<p><%= application.notes %></p>
</div>
<div class="page-actions">
<button
class="btn btn-secondary"
type="button"
data-app-edit
data-app-id="<%= application.id %>"
data-app-code="<%= application.code %>"
data-app-name="<%= application.name %>"
data-app-version="<%= application.version %>"
data-app-status="<%= application.status %>"
data-app-open-url="<%= application.openUrl %>"
data-app-notes="<%= application.notes %>"
data-app-packages="<%= JSON.stringify(application.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>"
>
<span class="material-symbols-outlined">edit</span>
Sửa App
</button>
<form method="post" action="/applications/<%= application.id %>/release" data-confirm-submit="Chuyển app <%= application.code %> sang Released?">
<input type="hidden" name="returnTo" value="<%= currentPath %>">
<button class="btn btn-primary" type="submit" <%= application.status === 'Released' ? 'disabled' : '' %>>
<span class="material-symbols-outlined">archive</span>
Đóng gói
</button>
</form>
<form method="post" action="/applications/<%= application.id %>/delete" data-confirm-submit="Xóa app <%= application.code %> khỏi hệ thống?">
<button class="btn btn-danger" type="submit">
<span class="material-symbols-outlined">delete</span>
Xóa
</button>
</form>
</div>
</div>
<div class="detail-grid">
<section class="panel">
<div class="panel-header">
<div>
<h2>Thông tin App</h2>
<p>Thông tin dùng ở danh sách và pipeline đóng gói.</p>
</div>
</div>
<dl class="detail-list">
<div><dt>Code</dt><dd><%= application.code %></dd></div>
<div><dt>Version</dt><dd><strong><%= application.version %></strong></dd></div>
<div><dt>Package count</dt><dd><%= application.packageCount %></dd></div>
<div><dt>Created date</dt><dd><%= application.createdAt %></dd></div>
<div><dt>Created by</dt><dd><%= application.createdBy %></dd></div>
<div><dt>Open URL</dt><dd class="mono"><%= application.openUrl || '-' %></dd></div>
<div><dt>Status</dt><dd><span class="badge <%= helpers.statusClass(application.status) %>"><%= application.status %></span></dd></div>
</dl>
</section>
<section class="panel wide-panel">
<div class="panel-header">
<div>
<h2>Package trong App</h2>
<p>Mỗi package có thể chọn version cụ thể cho app này.</p>
</div>
</div>
<div class="table-wrap compact">
<table>
<thead>
<tr>
<th>Package</th>
<th>Type</th>
<th>Selected version</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% application.packages.forEach((pkg) => { %>
<tr>
<td>
<strong><%= pkg.name %></strong>
<span class="table-subtitle"><%= pkg.code %></span>
</td>
<td><span class="badge <%= helpers.packageTypeClass(pkg.type) %>"><%= helpers.packageTypeLabel(pkg.type) %></span></td>
<td><%= pkg.selectedVersion %></td>
<td class="action-col">
<div class="action-group">
<button
class="icon-button subtle"
type="button"
title="Đổi version"
data-app-edit
data-app-id="<%= application.id %>"
data-app-code="<%= application.code %>"
data-app-name="<%= application.name %>"
data-app-version="<%= application.version %>"
data-app-status="<%= application.status %>"
data-app-open-url="<%= application.openUrl %>"
data-app-notes="<%= application.notes %>"
data-app-packages="<%= JSON.stringify(application.packages.map((item) => ({ packageId: item.packageId, selectedVersionId: item.selectedVersionId }))) %>"
>
<span class="material-symbols-outlined">swap_horiz</span>
</button>
<form method="post" action="/applications/<%= application.id %>/packages/<%= pkg.packageId %>/delete" data-confirm-submit="Gỡ package <%= pkg.code %> khỏi app?">
<button class="icon-button danger" type="submit" title="Gỡ package" aria-label="Gỡ package <%= pkg.name %>">
<span class="material-symbols-outlined">link_off</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/edit-app-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,115 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Applications</h1>
<p>Danh sách app được tạo từ các package đã chọn, kèm version và ghi chú đóng gói.</p>
</div>
<div class="page-actions">
<a class="btn btn-secondary" href="/applications/export.csv">
<span class="material-symbols-outlined">download</span>
Export
</a>
<a class="btn btn-primary" href="/builder">
<span class="material-symbols-outlined">add</span>
Tạo App
</a>
</div>
</div>
<div class="page-filters">
<label class="filter-field">
<span>Status</span>
<select data-filter-select data-filter-column="status" data-filter-table="applicationsTable">
<option value="">Tất cả</option>
<option value="Draft">Draft</option>
<option value="Released">Released</option>
<option value="Archived">Archived</option>
</select>
</label>
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm theo tên app, code, người tạo..." data-table-search="applicationsTable">
</label>
</div>
<section class="table-panel">
<div class="table-wrap">
<table id="applicationsTable" class="data-table">
<thead>
<tr>
<th>Application</th>
<th>Version</th>
<th>Packages</th>
<th>Created date</th>
<th>Created by</th>
<th>Status</th>
<th>Notes</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% if (applications.length === 0) { %>
<tr>
<td colspan="8" class="table-empty">Chưa có app trong database. Tạo app sau khi đã upload package.</td>
</tr>
<% } %>
<% applications.forEach((item) => { %>
<tr data-search="<%= `${item.name} ${item.code} ${item.version} ${item.createdBy} ${item.notes}`.toLowerCase() %>" data-status="<%= item.status %>">
<td>
<a class="table-title" href="/applications/<%= item.id %>"><%= item.name %></a>
<span class="table-subtitle"><%= item.code %></span>
</td>
<td><strong><%= item.version %></strong></td>
<td><%= item.packageCount %></td>
<td><%= item.createdAt %></td>
<td><%= item.createdBy %></td>
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
<td class="notes-cell"><%= item.notes %></td>
<td class="action-col">
<div class="action-group">
<a class="icon-button subtle" href="/applications/<%= item.id %>" title="Xem chi tiết" aria-label="Xem chi tiết <%= item.name %>">
<span class="material-symbols-outlined">visibility</span>
</a>
<button
class="icon-button subtle"
type="button"
title="Sửa app"
data-app-edit
data-app-id="<%= item.id %>"
data-app-code="<%= item.code %>"
data-app-name="<%= item.name %>"
data-app-version="<%= item.version %>"
data-app-status="<%= item.status %>"
data-app-open-url="<%= item.openUrl %>"
data-app-notes="<%= item.notes %>"
data-app-packages="<%= JSON.stringify(item.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>"
>
<span class="material-symbols-outlined">edit</span>
</button>
<form method="post" action="/applications/<%= item.id %>/delete" data-confirm-submit="Xóa app <%= item.code %>? Thao tác này sẽ xóa thông tin đóng gói của app.">
<button class="icon-button danger" type="submit" title="Xóa app" aria-label="Xóa app <%= item.name %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= applications.length %> of <%= applications.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</section>
<%- include('partials/edit-app-modal') %>
<%- include('partials/page-end') %>

97
web-server/views/auth.ejs Normal file
View File

@@ -0,0 +1,97 @@
<!doctype html>
<html lang="vi">
<head>
<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/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</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>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>User Access</span>
</div>
</div>
<% if (mode === 'login') { %>
<div class="auth-heading">
<h1>Đăng nhập</h1>
<p>Truy cập console quản lý package và app.</p>
</div>
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="returnTo" value="<%= returnTo %>">
<label class="form-field">
<span>Username hoặc email</span>
<input type="text" name="identifier" autocomplete="username" required autofocus>
</label>
<label class="form-field">
<span>Mật khẩu</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="btn btn-primary auth-submit" type="submit">
<span class="material-symbols-outlined">login</span>
Đăng nhập
</button>
</form>
<p class="auth-switch">Chưa có tài khoản? <a href="/register">Đăng ký</a></p>
<% } else { %>
<div class="auth-heading">
<h1>Đăng ký</h1>
<p>App sẽ gửi email xác nhận để kích hoạt tài khoản.</p>
</div>
<form class="auth-form" method="post" action="/register" data-register-form>
<div class="form-grid">
<label class="form-field">
<span>Username</span>
<input type="text" name="username" value="<%= values.username || '' %>" autocomplete="username" required autofocus data-unique-check="username">
<small class="field-feedback" data-unique-feedback="username"></small>
</label>
<label class="form-field">
<span>Họ tên</span>
<input type="text" name="fullName" value="<%= values.fullName || '' %>" autocomplete="name">
</label>
</div>
<label class="form-field">
<span>Email</span>
<input type="email" name="email" value="<%= values.email || '' %>" autocomplete="email" required data-unique-check="email">
<small class="field-feedback" data-unique-feedback="email"></small>
</label>
<div class="form-grid">
<label class="form-field">
<span>Mật khẩu</span>
<input type="password" name="password" autocomplete="new-password" minlength="8" required>
</label>
<label class="form-field">
<span>Xác nhận mật khẩu</span>
<input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required>
</label>
</div>
<button class="btn btn-primary auth-submit" type="submit" data-register-submit>
<span class="material-symbols-outlined">person_add</span>
Tạo tài khoản
</button>
</form>
<p class="auth-switch">Đã có tài khoản? <a href="/login">Đăng nhập</a></p>
<% } %>
</section>
</main>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,109 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Đóng gói App</h1>
<p>Tạo app bằng cách chọn package `.deb` hoặc Docker và gán version cụ thể.</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary" type="submit" form="builderForm" name="status" value="Draft">
<span class="material-symbols-outlined">draft</span>
Lưu nháp
</button>
<button class="btn btn-primary" type="submit" form="builderForm">
<span class="material-symbols-outlined">save</span>
Tạo App
</button>
</div>
</div>
<div class="builder-layout">
<section class="panel">
<div class="panel-header">
<div>
<h2>Thông tin App</h2>
</div>
</div>
<form id="builderForm" class="form-stack" action="/applications" method="post">
<label class="form-field">
<span>App code</span>
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
</label>
<label class="form-field">
<span>App version</span>
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
</label>
<label class="form-field full">
<span>App name</span>
<input type="text" name="appName" required>
</label>
<label class="form-field full">
<span>Open URL</span>
<input type="text" name="openUrl" placeholder="http://127.0.0.1:5000">
</label>
<label class="form-field full">
<span>Notes</span>
<textarea name="notes"></textarea>
</label>
</form>
</section>
<section class="table-panel builder-table">
<div class="panel-header">
<div>
<h2>Chọn package</h2>
<p>Có thể dùng chung `.deb` và Docker trong một app.</p>
</div>
</div>
<div class="page-filters inline">
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm package..." data-table-search="builderPackagesTable">
</label>
</div>
<div class="table-wrap">
<table id="builderPackagesTable" class="data-table">
<thead>
<tr>
<th>Use</th>
<th>Package</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% if (packages.length === 0) { %>
<tr>
<td colspan="5" class="table-empty">Chưa có package để đóng gói. Hãy upload package trước.</td>
</tr>
<% } %>
<% packages.forEach((item, index) => { %>
<tr data-search="<%= `${item.name} ${item.code} ${item.latestVersion}`.toLowerCase() %>">
<td>
<input class="checkbox" form="builderForm" type="checkbox" name="packageIds" value="<%= item.id %>" <%= index < 3 ? 'checked' : '' %> aria-label="Chọn <%= item.name %>">
</td>
<td>
<strong><%= item.name %></strong>
<span class="table-subtitle"><%= item.code %></span>
</td>
<td><span class="badge <%= helpers.packageTypeClass(item.type) %>"><%= helpers.packageTypeLabel(item.type) %></span></td>
<td>
<select class="mini-select" form="builderForm" name="version_<%= item.id %>" aria-label="Version của <%= item.name %>">
<% item.versions.forEach((version) => { %>
<option value="<%= version.id %>"><%= version.version %></option>
<% }) %>
</select>
</td>
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="vi">
<head>
<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/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</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>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>Email Confirmation</span>
</div>
</div>
<div class="auth-confirm-icon">
<span class="material-symbols-outlined">mark_email_unread</span>
</div>
<div class="auth-heading">
<h1>Kiểm tra email</h1>
<p>Chúng tôi đã gửi link xác nhận tới email đăng ký. Tài khoản chỉ được kích hoạt sau khi bạn bấm link confirm.</p>
</div>
<form class="auth-form" method="post" action="/resend-confirmation">
<label class="form-field">
<span>Email đăng ký</span>
<input type="email" name="email" value="<%= email %>" autocomplete="email" required>
</label>
<button class="btn btn-secondary auth-submit" type="submit">
<span class="material-symbols-outlined">forward_to_inbox</span>
Gửi lại email xác nhận
</button>
</form>
<p class="auth-switch">Đã xác nhận? <a href="/login">Đăng nhập</a></p>
</section>
</main>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,122 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Tổng quan</h1>
<p>Theo dõi nhanh package, version mới nhất và các app đang được đóng gói.</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary" type="button" data-modal-open="uploadPackageModal">
<span class="material-symbols-outlined">upload_file</span>
Upload package
</button>
<a class="btn btn-primary" href="/builder">
<span class="material-symbols-outlined">add_box</span>
Tạo App
</a>
</div>
</div>
<div class="dashboard-stats">
<article class="metric-card">
<span>Packages</span>
<div>
<strong><%= stats.totalPackages %></strong>
<small><%= stats.activePackages %> active</small>
</div>
</article>
<article class="metric-card">
<span>Versions</span>
<div>
<strong><%= stats.totalVersions %></strong>
<small>latest tracking</small>
</div>
</article>
<article class="metric-card">
<span>Applications</span>
<div>
<strong><%= stats.totalApplications %></strong>
<small><%= stats.releasedApplications %> released</small>
</div>
</article>
<article class="metric-card">
<span>Package types</span>
<div>
<strong>2</strong>
<small>.deb + docker</small>
</div>
</article>
</div>
<div class="dashboard-grid">
<section class="panel">
<div class="panel-header">
<div>
<h2>Package mới cập nhật</h2>
<p>Hiển thị version mới nhất cho từng package.</p>
</div>
<a href="/packages" class="text-link">Xem tất cả</a>
</div>
<div class="table-wrap compact">
<table>
<thead>
<tr>
<th>Package</th>
<th>Type</th>
<th>Latest</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% if (packages.length === 0) { %>
<tr>
<td colspan="4" class="table-empty">Chưa có package nào. Bấm Upload package để thêm dữ liệu đầu tiên.</td>
</tr>
<% } %>
<% packages.slice(0, 4).forEach((item) => { %>
<tr>
<td>
<a class="table-title" href="/packages/<%= item.id %>"><%= item.name %></a>
<span class="table-subtitle"><%= item.code %></span>
</td>
<td><span class="badge <%= helpers.packageTypeClass(item.type) %>"><%= helpers.packageTypeLabel(item.type) %></span></td>
<td><%= item.latestVersion %></td>
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>Hoạt động gần đây</h2>
<p>Các thay đổi chính trong package/app.</p>
</div>
</div>
<div class="activity-list">
<% if (activity.length === 0) { %>
<div class="table-empty">Chưa có hoạt động upload/update package.</div>
<% } %>
<% activity.forEach((item) => { %>
<div class="activity-item">
<div class="activity-icon">
<span class="material-symbols-outlined"><%= item.icon %></span>
</div>
<div>
<strong><%= item.title %></strong>
<span><%= item.detail %></span>
</div>
<time><%= item.time %></time>
</div>
<% }) %>
</div>
</section>
</div>
</section>
<%- include('partials/package-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,18 @@
<%- include('partials/page-start') %>
<section class="page center-page">
<div class="empty-state error-state">
<span class="material-symbols-outlined">database_off</span>
<h1><%= errorTitle || 'Không thể tải dữ liệu' %></h1>
<p><%= errorMessage || 'Web server đang gặp lỗi khi đọc dữ liệu. Kiểm tra log container để xem nguyên nhân chi tiết.' %></p>
<% if (errorDetails) { %>
<code class="error-detail"><%= errorDetails.code %>: <%= errorDetails.message %></code>
<% } %>
<a class="btn btn-primary" href="/">
<span class="material-symbols-outlined">sync</span>
Thử lại
</a>
</div>
</section>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,15 @@
<%- include('partials/page-start') %>
<section class="page center-page">
<div class="empty-state">
<span class="material-symbols-outlined">search_off</span>
<h1>Không tìm thấy dữ liệu</h1>
<p>Trang hoặc bản ghi bạn mở không tồn tại trong dữ liệu hiện tại.</p>
<a class="btn btn-primary" href="/">
<span class="material-symbols-outlined">dashboard</span>
Về tổng quan
</a>
</div>
</section>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,103 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<div class="breadcrumb"><a href="/packages">Packages</a><span>/</span><span><%= packageItem.code %></span></div>
<h1><%= packageItem.name %></h1>
<p><%= packageItem.description %></p>
</div>
<div class="page-actions">
<button class="btn btn-secondary" type="button" data-modal-open="updatePackageModal" data-package-update="<%= packageItem.id %>">
<span class="material-symbols-outlined">upgrade</span>
Update version
</button>
<form method="post" action="/packages/<%= packageItem.id %>/delete" data-confirm-submit="Xóa package <%= packageItem.code %>? Thao tác này sẽ xóa mọi version và liên kết app liên quan.">
<button class="btn btn-danger" type="submit">
<span class="material-symbols-outlined">delete</span>
Xóa package
</button>
</form>
</div>
</div>
<div class="detail-grid">
<section class="panel">
<div class="panel-header">
<div>
<h2>Thông tin package</h2>
<p>Metadata chính dùng cho web server và API.</p>
</div>
</div>
<dl class="detail-list">
<div><dt>Code</dt><dd><%= packageItem.code %></dd></div>
<div><dt>Type</dt><dd><span class="badge <%= helpers.packageTypeClass(packageItem.type) %>"><%= helpers.packageTypeLabel(packageItem.type) %></span></dd></div>
<div><dt>Latest</dt><dd><strong><%= packageItem.latestVersion %></strong></dd></div>
<div><dt>Artifact</dt><dd class="mono"><%= packageItem.artifact %></dd></div>
<div><dt>Owner</dt><dd><%= packageItem.owner %></dd></div>
<div><dt>Status</dt><dd><span class="badge <%= helpers.statusClass(packageItem.status) %>"><%= packageItem.status %></span></dd></div>
</dl>
</section>
<section class="panel wide-panel">
<div class="panel-header">
<div>
<h2>Version history</h2>
<p>Mỗi version có ngày upload, changelog và trạng thái.</p>
</div>
</div>
<div class="table-wrap compact">
<table>
<thead>
<tr>
<th>Version</th>
<th>Release date</th>
<th>Uploaded by</th>
<th>Size</th>
<th>Status</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% packageItem.versions.forEach((version) => { %>
<tr>
<td>
<strong><%= version.version %></strong>
<span class="table-subtitle"><%= version.changeLog %></span>
</td>
<td><%= version.releaseDate %></td>
<td><%= version.uploadedBy %></td>
<td><%= version.size %></td>
<td><span class="badge <%= helpers.statusClass(version.status) %>"><%= version.status %></span></td>
<td class="action-col">
<div class="action-group">
<% if (packageItem.type === 'deb' && version.filePath) { %>
<a class="icon-button subtle" href="/api/package-versions/<%= version.id %>/download" title="Download package" aria-label="Download package version <%= version.version %>">
<span class="material-symbols-outlined">download</span>
</a>
<% } %>
<form method="post" action="/package-versions/<%= version.id %>/latest">
<input type="hidden" name="returnTo" value="<%= currentPath %>">
<button class="icon-button subtle" type="submit" title="Đặt latest" aria-label="Đặt latest <%= version.version %>" <%= version.status === 'Latest' ? 'disabled' : '' %>>
<span class="material-symbols-outlined">stars</span>
</button>
</form>
<form method="post" action="/package-versions/<%= version.id %>/delete" data-confirm-submit="Xóa version <%= version.version %> khỏi package?">
<input type="hidden" name="returnTo" value="<%= currentPath %>">
<button class="icon-button danger" type="submit" title="Xóa version" aria-label="Xóa version <%= version.version %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/update-package-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,113 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Packages</h1>
<p>Quản lý package `.deb`, Docker image, version và trạng thái latest.</p>
</div>
<div class="page-actions">
<a class="btn btn-secondary" href="/packages/export.csv">
<span class="material-symbols-outlined">download</span>
Export
</a>
<button class="btn btn-primary" type="button" data-modal-open="uploadPackageModal">
<span class="material-symbols-outlined">upload_file</span>
Upload package
</button>
</div>
</div>
<div class="page-filters">
<label class="filter-field">
<span>Type</span>
<select data-filter-select data-filter-column="type" data-filter-table="packagesTable">
<option value="">Tất cả</option>
<option value="deb">.deb</option>
<option value="docker">Docker</option>
</select>
</label>
<label class="filter-field">
<span>Status</span>
<select data-filter-select data-filter-column="status" data-filter-table="packagesTable">
<option value="">Tất cả</option>
<option value="Active">Active</option>
<option value="Archived">Archived</option>
</select>
</label>
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm theo tên, code, owner..." data-table-search="packagesTable">
</label>
</div>
<section class="table-panel">
<div class="table-wrap">
<table id="packagesTable" class="data-table">
<thead>
<tr>
<th>Package</th>
<th>Type</th>
<th>Latest version</th>
<th>Release date</th>
<th>Owner</th>
<th>Status</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% if (packages.length === 0) { %>
<tr>
<td colspan="7" class="table-empty">Chưa có package trong database. Bấm Upload package để tạo package đầu tiên.</td>
</tr>
<% } %>
<% packages.forEach((item) => { %>
<tr data-search="<%= `${item.name} ${item.code} ${item.owner} ${item.latestVersion}`.toLowerCase() %>" data-type="<%= item.type %>" data-status="<%= item.status %>">
<td>
<a class="table-title" href="/packages/<%= item.id %>"><%= item.name %></a>
<span class="table-subtitle"><%= item.code %></span>
</td>
<td><span class="badge <%= helpers.packageTypeClass(item.type) %>"><%= helpers.packageTypeLabel(item.type) %></span></td>
<td><strong><%= item.latestVersion %></strong></td>
<td><%= item.latestReleaseDate %></td>
<td><%= item.owner %></td>
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
<td class="action-col">
<div class="action-group">
<a class="icon-button subtle" href="/packages/<%= item.id %>" title="Xem chi tiết" aria-label="Xem chi tiết <%= item.name %>">
<span class="material-symbols-outlined">visibility</span>
</a>
<% if (item.type === 'deb' && item.latestVersionId && item.artifact) { %>
<a class="icon-button subtle" href="/api/package-versions/<%= item.latestVersionId %>/download" title="Download latest package" aria-label="Download latest package <%= item.name %>">
<span class="material-symbols-outlined">download</span>
</a>
<% } %>
<button class="icon-button subtle" type="button" title="Update version" data-modal-open="updatePackageModal" data-package-update="<%= item.id %>">
<span class="material-symbols-outlined">upgrade</span>
</button>
<form method="post" action="/packages/<%= item.id %>/delete" data-confirm-submit="Xóa package <%= item.code %>? Thao tác này sẽ xóa cả version và liên kết app liên quan.">
<button class="icon-button danger" type="submit" title="Xóa package" aria-label="Xóa package <%= item.name %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= packages.length %> of <%= packages.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</section>
<%- include('partials/package-modal') %>
<%- include('partials/update-package-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,67 @@
<div class="modal-backdrop" id="editAppModal" role="dialog" aria-modal="true" aria-labelledby="editAppTitle">
<div class="modal-content wide">
<div class="modal-header">
<h3 id="editAppTitle">Sửa Application</h3>
<button class="icon-button subtle" type="button" aria-label="Đóng modal" data-modal-close>
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="editAppForm" class="modal-form" method="post" action="/applications">
<input type="hidden" name="returnTo" value="<%= currentPath || '/applications' %>">
<div class="form-grid">
<label class="form-field">
<span>App code</span>
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required data-edit-app-field="appCode">
</label>
<label class="form-field">
<span>Version</span>
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required data-edit-app-field="appVersion">
</label>
<label class="form-field">
<span>Status</span>
<select name="status" data-edit-app-field="status">
<option value="Draft">Draft</option>
<option value="Released">Released</option>
<option value="Archived">Archived</option>
</select>
</label>
<label class="form-field full">
<span>App name</span>
<input type="text" name="appName" required data-edit-app-field="appName">
</label>
<label class="form-field full">
<span>Open URL</span>
<input type="text" name="openUrl" placeholder="http://127.0.0.1:5000" data-edit-app-field="openUrl">
</label>
<label class="form-field full">
<span>Notes</span>
<textarea name="notes" data-edit-app-field="notes"></textarea>
</label>
</div>
<div class="modal-mini-table">
<% packages.forEach((item) => { %>
<label>
<input class="checkbox" type="checkbox" name="packageIds" value="<%= item.id %>" data-edit-app-package="<%= item.id %>">
<span>
<strong><%= item.name %></strong>
<small><%= item.code %></small>
</span>
<select class="mini-select" name="version_<%= item.id %>" data-edit-app-version="<%= item.id %>">
<option value="">Latest/default</option>
<% item.versions.forEach((version) => { %>
<option value="<%= version.id %>"><%= version.version %></option>
<% }) %>
</select>
</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
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<div class="modal-backdrop" id="uploadPackageModal" role="dialog" aria-modal="true" aria-labelledby="uploadPackageTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="uploadPackageTitle">Upload package</h3>
<button class="icon-button subtle" type="button" aria-label="Đóng modal" data-modal-close>
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" action="/packages" method="post" enctype="multipart/form-data">
<div class="form-grid">
<label class="form-field">
<span>Package code</span>
<input type="text" name="packageCode" placeholder="NAV-STACK" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
</label>
<label class="form-field">
<span>Package type</span>
<select name="packageType">
<option value="deb">.deb</option>
<option value="docker">Docker</option>
</select>
</label>
<label class="form-field full">
<span>Package name</span>
<input type="text" name="packageName" placeholder="Navigation Stack" required>
</label>
<label class="form-field full">
<span>Description</span>
<input type="text" name="description" placeholder="Mô tả ngắn về package">
</label>
<label class="form-field">
<span>Version</span>
<input type="text" name="version" placeholder="1.0.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
</label>
<label class="form-field">
<span>Release date</span>
<input type="date" name="releaseDate" value="<%= todayDate %>">
</label>
<div class="form-field full">
<span>Package file</span>
<div class="file-dropzone" data-file-dropzone>
<input class="file-input" type="file" name="packageFile" accept=".deb,.tar,.tar.gz,.tgz,.zip,.gz" data-file-input>
<div class="file-dropzone-content">
<span class="material-symbols-outlined">upload_file</span>
<strong>Kéo file vào đây hoặc chọn từ máy</strong>
<small>Hỗ trợ .deb, .tar, .tgz, .zip cho package hoặc Docker image export</small>
<button class="btn btn-secondary" type="button" data-file-browse>
<span class="material-symbols-outlined">attach_file</span>
Chọn file
</button>
</div>
<div class="file-preview" data-file-preview hidden>
<span class="material-symbols-outlined">draft</span>
<div>
<strong data-file-name>Chưa chọn file</strong>
<small data-file-meta></small>
</div>
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
<label class="form-field full">
<span>Docker image/tag</span>
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:1.9.0">
</label>
<label class="form-field full">
<span>Change log</span>
<textarea name="changeLog" placeholder="Ghi chú thay đổi"></textarea>
</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">Upload</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,61 @@
</div>
</main>
<% if (currentUser && currentUser.role === 'User') { %>
<div id="profileModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="profileModalTitle">Thông tin cá nhân</h3>
<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="/profile" data-profile-form>
<input type="hidden" name="returnTo" value="<%= currentPath || '/' %>">
<div class="profile-summary">
<div class="profile-avatar"><%= currentUser.name.charAt(0) %></div>
<div>
<strong><%= currentUser.name %></strong>
<span><%= currentUser.username %></span>
</div>
</div>
<div class="form-stack">
<label class="form-field">
<span>Fullname</span>
<input type="text" name="fullName" value="<%= currentUser.fullName || '' %>" autocomplete="name">
</label>
<label class="form-field">
<span>Email mới</span>
<input type="email" name="email" value="<%= currentUser.email || '' %>" autocomplete="email" required data-profile-email>
</label>
<label class="form-field">
<span>Confirm email mới</span>
<input type="email" name="confirmEmail" value="<%= currentUser.email || '' %>" autocomplete="email" required data-profile-confirm-email>
<small class="field-feedback" data-profile-feedback="email"></small>
</label>
<label class="form-field">
<span>Mật khẩu mới</span>
<input type="password" name="newPassword" minlength="8" autocomplete="new-password" data-profile-new-password>
</label>
<label class="form-field">
<span>Xác nhận mật khẩu mới</span>
<input type="password" name="confirmPassword" minlength="8" autocomplete="new-password" data-profile-confirm-password>
<small class="field-feedback" data-profile-feedback="password"></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
</button>
</div>
</form>
</div>
</div>
<% } %>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="vi">
<head>
<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/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</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>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>Package Console</span>
</div>
</div>
<nav class="nav-section" aria-label="Workspace">
<span class="nav-label">Workspace</span>
<% navItems.forEach((item) => { %>
<a href="<%= item.href %>" class="nav-item <%= active === item.id ? 'active' : '' %>">
<span class="material-symbols-outlined"><%= item.icon %></span>
<span><%= item.label %></span>
</a>
<% }) %>
</nav>
<div class="sidebar-status">
<span class="status-dot"></span>
<div>
<strong>SQL Server</strong>
<span><%= databaseLabel %></span>
</div>
</div>
</aside>
<button id="sidebarBackdrop" type="button" aria-label="Đóng menu"></button>
<main id="appMain" class="main-shell">
<header class="topbar">
<div class="topbar-left">
<button id="mobileMenuBtn" class="icon-button" type="button" aria-label="Mở menu" aria-expanded="false">
<span class="material-symbols-outlined">menu</span>
</button>
</div>
<div class="topbar-actions">
<button class="icon-button" type="button" title="Đồng bộ dữ liệu" data-refresh-page>
<span class="material-symbols-outlined">sync</span>
</button>
<button class="icon-button" type="button" title="Thông báo" data-toast="Chưa có thông báo mới">
<span class="material-symbols-outlined">notifications</span>
</button>
<% if (currentUser.role === 'User') { %>
<button class="profile-chip profile-chip-button" type="button" title="Cập nhật thông tin cá nhân" aria-label="Cập nhật thông tin cá nhân" data-modal-open="profileModal">
<span class="profile-avatar"><%= currentUser.name.charAt(0) %></span>
<span class="profile-meta">
<strong><%= currentUser.name %></strong>
<span><%= currentUser.role %></span>
</span>
</button>
<% } else { %>
<div class="profile-chip">
<span class="profile-avatar"><%= currentUser.name.charAt(0) %></span>
<span class="profile-meta">
<strong><%= currentUser.name %></strong>
<span><%= currentUser.role %></span>
</span>
</div>
<% } %>
<form class="logout-form" method="post" action="/logout">
<button class="icon-button" type="submit" title="Đăng xuất" aria-label="Đăng xuất">
<span class="material-symbols-outlined">logout</span>
</button>
</form>
</div>
</header>
<div id="mainContent" class="main-content">

View File

@@ -0,0 +1,68 @@
<div class="modal-backdrop" id="updatePackageModal" role="dialog" aria-modal="true" aria-labelledby="updatePackageTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="updatePackageTitle">Update package version</h3>
<button class="icon-button subtle" type="button" aria-label="Đóng modal" data-modal-close>
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" action="/package-versions" method="post" enctype="multipart/form-data">
<div class="form-grid">
<label class="form-field full">
<span>Package</span>
<select name="packageId" required>
<% packages.forEach((item) => { %>
<option value="<%= item.id %>" <%= typeof packageItem !== 'undefined' && packageItem.id === item.id ? 'selected' : '' %>><%= item.code %> - <%= item.name %></option>
<% }) %>
</select>
</label>
<label class="form-field">
<span>Version</span>
<input type="text" name="version" placeholder="2.5.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
<small>Nhap version moi de them, hoac version da co de re-upload artifact.</small>
</label>
<label class="form-field">
<span>Release date</span>
<input type="date" name="releaseDate" value="<%= todayDate %>">
</label>
<div class="form-field full">
<span>Package file</span>
<div class="file-dropzone" data-file-dropzone>
<input class="file-input" type="file" name="packageFile" accept=".deb,.tar,.tar.gz,.tgz,.zip,.gz" data-file-input>
<div class="file-dropzone-content">
<span class="material-symbols-outlined">upload_file</span>
<strong>Kéo version mới vào đây hoặc chọn file</strong>
<small>File .deb hoặc archive Docker export đều dùng được</small>
<button class="btn btn-secondary" type="button" data-file-browse>
<span class="material-symbols-outlined">attach_file</span>
Chọn file
</button>
</div>
<div class="file-preview" data-file-preview hidden>
<span class="material-symbols-outlined">draft</span>
<div>
<strong data-file-name>Chưa chọn file</strong>
<small data-file-meta></small>
</div>
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
<label class="form-field full">
<span>Docker image/tag</span>
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:2.0.0">
</label>
<label class="form-field full">
<span>Change log</span>
<textarea name="changeLog" placeholder="Mô tả thay đổi trong version này"></textarea>
</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">Cập nhật</button>
</div>
</form>
</div>
</div>

275
web-server/views/users.ejs Normal file
View File

@@ -0,0 +1,275 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Users</h1>
<p>Quản lý tài khoản đăng nhập, quyền Admin/User và trạng thái hoạt động.</p>
</div>
<div class="page-actions">
<span class="badge badge-info"><%= users.length %> users</span>
</div>
</div>
<div class="users-layout">
<section class="panel">
<div class="panel-header">
<div>
<h2>Tạo user mới</h2>
</div>
</div>
<form class="user-create-form" method="post" action="/users">
<div class="form-stack">
<label class="form-field">
<span>Username</span>
<input type="text" name="username" autocomplete="off" required>
</label>
<label class="form-field">
<span>Họ tên</span>
<input type="text" name="fullName" autocomplete="off">
</label>
<label class="form-field">
<span>Email</span>
<input type="email" name="email" autocomplete="off" required>
</label>
<label class="form-field">
<span>Role</span>
<select name="role">
<option value="User">User</option>
<option value="Admin">Admin</option>
</select>
</label>
<label class="form-field full">
<span>Mật khẩu tạm</span>
<input type="password" name="password" minlength="8" autocomplete="new-password" required>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" type="submit">
<span class="material-symbols-outlined">person_add</span>
Tạo user
</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="page-filters inline">
<label class="filter-field">
<span>Role</span>
<select data-filter-select data-filter-column="role" data-filter-table="usersTable">
<option value="">Tất cả</option>
<option value="Admin">Admin</option>
<option value="User">User</option>
</select>
</label>
<label class="filter-field">
<span>Status</span>
<select data-filter-select data-filter-column="status" data-filter-table="usersTable">
<option value="">Tất cả</option>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
</select>
</label>
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm theo username, email, họ tên..." data-table-search="usersTable">
</label>
</div>
<div class="table-wrap">
<table id="usersTable" class="data-table users-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Owned data</th>
<th>Session</th>
<th class="action-col">Actions</th>
</tr>
</thead>
<tbody>
<% if (users.length === 0) { %>
<tr>
<td colspan="8" class="table-empty">Chưa có user nào.</td>
</tr>
<% } %>
<% users.forEach((user) => { %>
<tr
data-search="<%= `${user.username} ${user.email} ${user.fullName}`.toLowerCase() %>"
data-role="<%= user.role %>"
data-status="<%= user.status %>"
data-user-id="<%= user.id %>"
data-user-name="<%= user.name %>"
data-user-username="<%= user.username %>"
data-user-email="<%= user.email %>"
data-user-full-name="<%= user.fullName %>"
data-user-role="<%= user.role %>"
data-user-status="<%= user.status %>"
data-user-active="<%= user.isActive ? 'true' : 'false' %>"
data-user-created-at="<%= user.createdAt %>"
data-user-updated-at="<%= user.updatedAt %>"
data-user-package-count="<%= user.packageCount %>"
data-user-application-count="<%= user.applicationCount %>"
>
<td>
<span class="table-title"><%= user.name %></span>
<span class="table-subtitle"><%= user.username %></span>
</td>
<td><%= user.email %></td>
<td><span class="badge <%= helpers.statusClass(user.role) %>"><%= user.role %></span></td>
<td><span class="badge <%= helpers.statusClass(user.status) %>"><%= user.status %></span></td>
<td><%= user.createdAt %></td>
<td>
<span class="table-subtitle"><%= user.packageCount %> packages</span>
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
</td>
<td>
<% if (user.id === currentUser.id) { %>
<span class="badge badge-muted">Đang đăng nhập</span>
<% } else { %>
<span class="table-subtitle">-</span>
<% } %>
</td>
<td class="action-col">
<div class="user-actions">
<button class="icon-button subtle" type="button" title="Xem user" aria-label="Xem user <%= user.username %>" data-user-view>
<span class="material-symbols-outlined">visibility</span>
</button>
<button class="icon-button subtle" type="button" title="Sửa user" aria-label="Sửa user <%= user.username %>" data-user-edit <%= user.id === currentUser.id ? 'data-current-user="true"' : '' %>>
<span class="material-symbols-outlined">edit</span>
</button>
<% if (user.id !== currentUser.id) { %>
<form method="post" action="/users/<%= user.id %>/delete" data-confirm-submit="Xóa user <%= user.username %>? Thao tác này không thể hoàn tác.">
<button class="icon-button danger" type="submit" title="Xóa user" aria-label="Xóa user <%= user.username %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
<% } %>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= users.length %> of <%= users.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</div>
</section>
<div id="userDetailModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="userDetailTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="userDetailTitle">Thông tin user</h3>
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="modal-form">
<dl class="detail-list user-detail-list">
<div>
<dt>Họ tên</dt>
<dd data-user-detail="name"></dd>
</div>
<div>
<dt>Username</dt>
<dd data-user-detail="username"></dd>
</div>
<div>
<dt>Email</dt>
<dd data-user-detail="email"></dd>
</div>
<div>
<dt>Mật khẩu</dt>
<dd>Không hiển thị. Có thể đặt lại trong phần Sửa user.</dd>
</div>
<div>
<dt>Role</dt>
<dd data-user-detail="role"></dd>
</div>
<div>
<dt>Status</dt>
<dd data-user-detail="status"></dd>
</div>
<div>
<dt>Created</dt>
<dd data-user-detail="createdAt"></dd>
</div>
<div>
<dt>Updated</dt>
<dd data-user-detail="updatedAt"></dd>
</div>
<div>
<dt>Owned data</dt>
<dd data-user-detail="ownedData"></dd>
</div>
</dl>
</div>
</div>
</div>
<div id="editUserModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="editUserTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="editUserTitle">Sửa user</h3>
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="editUserForm" class="modal-form" method="post" action="/users">
<div class="form-stack">
<label class="form-field">
<span>Username</span>
<input type="text" name="username" required data-edit-user-field="username">
</label>
<label class="form-field">
<span>Họ tên</span>
<input type="text" name="fullName" data-edit-user-field="fullName">
</label>
<label class="form-field">
<span>Email</span>
<input type="email" name="email" required data-edit-user-field="email">
</label>
<label class="form-field">
<span>Role</span>
<select name="role" data-edit-user-field="role">
<option value="User">User</option>
<option value="Admin">Admin</option>
</select>
</label>
<label class="form-field">
<span>Mật khẩu mới</span>
<input type="password" name="newPassword" minlength="8" autocomplete="new-password" data-edit-user-field="newPassword">
</label>
<label class="form-field">
<span>Xác nhận mật khẩu mới</span>
<input type="password" name="confirmPassword" minlength="8" autocomplete="new-password" data-edit-user-field="confirmPassword">
</label>
<label class="inline-checkbox edit-active-toggle">
<input class="checkbox" type="checkbox" name="isActive" data-edit-user-field="isActive">
Active
</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
</button>
</div>
</form>
</div>
</div>
<%- include('partials/page-end') %>