Compare commits
20 Commits
9b37efc0a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47407cdbca | |||
| f9bec78c82 | |||
| 08b94337ad | |||
| 4b65838f0f | |||
| ce3b3c900a | |||
| c01d9c7e40 | |||
| e7dce2f0e9 | |||
| 9cedf085a3 | |||
| 991d6f5257 | |||
| b0443d5950 | |||
| 13765e58d2 | |||
| 8ceb1bb1df | |||
| e2c4881bb7 | |||
| 14d3a3152a | |||
| a033562c4c | |||
| 9e6f57be35 | |||
| 582960cc32 | |||
| 190d2418da | |||
| 5ade939ff9 | |||
| 184f9e8ede |
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/agent/.venv/Scripts/python.exe",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}/agent"
|
||||
],
|
||||
"python.terminal.activateEnvironment": true
|
||||
}
|
||||
139
BUILD_SYSTEM.md
139
BUILD_SYSTEM.md
@@ -1,139 +0,0 @@
|
||||
# BUILD_SYSTEM.md
|
||||
|
||||
# Linux Package Installer System
|
||||
|
||||
**Server IP:** 172.20.235.176
|
||||
|
||||
Mục tiêu: xây dựng hệ thống cài đặt, quản lý package `.deb` cho ứng dụng web trên Linux với luồng **Web Server → Web Client → Local Installer Agent → Web Service**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Thành phần hệ thống
|
||||
|
||||
1. **Web Server** (`server.package.robot`)
|
||||
- Node.js + Express
|
||||
- Chức năng:
|
||||
- Lưu trữ các file `.deb`
|
||||
- Metadata JSON về package: version, dependency, mô tả
|
||||
- API download package
|
||||
- Admin upload package
|
||||
|
||||
2. **Web Client** (`client.package.robot`)
|
||||
- Frontend: React
|
||||
- Backend: Node.js
|
||||
- Chức năng:
|
||||
- Hiển thị danh sách package
|
||||
- Cho người dùng chọn Install / Modify / Update / Remove
|
||||
- Relay lệnh tới Local Installer Agent
|
||||
- Hiển thị tiến trình / status realtime
|
||||
|
||||
3. **Local Installer Agent**
|
||||
- Python
|
||||
- Chạy ngầm trên Linux client
|
||||
- Chức năng:
|
||||
- Tải package từ Web Server
|
||||
- Kiểm tra checksum / chữ ký
|
||||
- Cài / gỡ / update bằng dpkg/apt
|
||||
- Start / restart các service systemd
|
||||
- Báo tiến trình / status về Web Client
|
||||
|
||||
---
|
||||
|
||||
## 2. Flow hệ thống
|
||||
|
||||
|
||||
Admin / Developer
|
||||
│ Upload .deb và metadata
|
||||
▼
|
||||
Web Server (172.20.235.176)
|
||||
│ Serve .deb + metadata
|
||||
▼
|
||||
Web Client (React + Node.js)
|
||||
│ Hiển thị package list
|
||||
│ User chọn Install/Modify/Update/Remove
|
||||
▼
|
||||
Local Installer Agent (Python)
|
||||
│ Download .deb từ server
|
||||
│ Verify checksum
|
||||
│ Install / Remove / Update
|
||||
│ Start/Restart systemd service
|
||||
▼
|
||||
Web Service / Package Service
|
||||
│ Chạy sẵn
|
||||
▼
|
||||
User mở browser → truy cập service
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. Cấu trúc project
|
||||
|
||||
|
||||
project-root/
|
||||
├── server/
|
||||
│ ├── packages/ # .deb files
|
||||
│ ├── metadata/ # JSON metadata
|
||||
│ └── src/ # Node.js + Express
|
||||
│ ├── routes/
|
||||
│ └── controllers/
|
||||
│
|
||||
├── client/
|
||||
│ ├── frontend/ # React UI
|
||||
│ └── backend/ # Node.js relay tới Agent
|
||||
│
|
||||
├── agent/
|
||||
│ ├── bin/ # Python executable / script
|
||||
│ ├── service/ # systemd service file
|
||||
│ └── lib/ # logic install/update/remove/status
|
||||
│
|
||||
└── packages/ # Build các .deb trước khi upload
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. Metadata JSON mẫu
|
||||
|
||||
```json
|
||||
{
|
||||
"applications": [
|
||||
{
|
||||
"id": "sample-app",
|
||||
"name": "Sample Package",
|
||||
"version": "1.0.0",
|
||||
"packages": [
|
||||
{"id": "sample-core", "required": true, "version": "1.0.0", "file": "sample-core_1.0.0_amd64.deb"},
|
||||
{"id": "sample-web", "required": false, "version": "1.0.0", "file": "sample-web_1.0.0_amd64.deb", "depends": ["sample-core"]},
|
||||
{"id": "sample-api", "required": false, "version": "1.0.0", "file": "sample-api_1.0.0_amd64.deb", "depends": ["sample-core"]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
5. Flow Install / Modify / Update / Remove
|
||||
User mở Web Client
|
||||
Web Client fetch metadata từ Web Server
|
||||
User chọn package muốn Install/Modify/Update/Remove
|
||||
Web Client gửi JSON request tới Local Installer Agent
|
||||
Local Agent:
|
||||
Download package từ Web Server
|
||||
Verify checksum
|
||||
Install / Remove / Update package
|
||||
Start / Restart systemd service
|
||||
Báo tiến trình / status về Web Client
|
||||
Sau khi cài xong, service chạy sẵn
|
||||
User mở browser → truy cập Web Service
|
||||
6. Công nghệ & ngôn ngữ
|
||||
Thành phần Ngôn ngữ / Framework
|
||||
Web Server Node.js + Express
|
||||
Web Client Frontend React
|
||||
Web Client Backend Node.js
|
||||
Local Installer Agent Python
|
||||
Web Service Tùy ứng dụng
|
||||
7. Quy trình triển khai từ đầu
|
||||
Build package .deb cho ứng dụng web, chia thành các package nhỏ nếu cần.
|
||||
Upload package + metadata lên Web Server.
|
||||
Web Client fetch metadata, hiển thị package list.
|
||||
Cài đặt Local Installer Agent lần đầu trên Linux client.
|
||||
User chọn package → Install / Modify / Update / Remove.
|
||||
Local Installer Agent thực hiện cài/gỡ/update ngầm.
|
||||
Web Service chạy sẵn, người dùng truy cập browser.
|
||||

|
||||
78
agent/README.md
Normal file
78
agent/README.md
Normal 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
2
agent/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Local Installer Agent package."""
|
||||
|
||||
2
agent/app/api/__init__.py
Normal file
2
agent/app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routers for the Local Installer Agent."""
|
||||
|
||||
53
agent/app/api/apps.py
Normal file
53
agent/app/api/apps.py
Normal 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
38
agent/app/api/health.py
Normal 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
40
agent/app/api/services.py
Normal 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
74
agent/app/api/tasks.py
Normal 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
103
agent/app/config.py
Normal 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()
|
||||
2
agent/app/core/__init__.py
Normal file
2
agent/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core installers and task orchestration."""
|
||||
|
||||
17
agent/app/core/checksum.py
Normal file
17
agent/app/core/checksum.py
Normal 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()
|
||||
|
||||
86
agent/app/core/command_runner.py
Normal file
86
agent/app/core/command_runner.py
Normal 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
|
||||
190
agent/app/core/docker_installer.py
Normal file
190
agent/app/core/docker_installer.py
Normal 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}")
|
||||
83
agent/app/core/downloader.py
Normal file
83
agent/app/core/downloader.py
Normal 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
103
agent/app/core/installer.py
Normal 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
|
||||
56
agent/app/core/manifest_client.py
Normal file
56
agent/app/core/manifest_client.py
Normal 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
|
||||
31
agent/app/core/manifest_validator.py
Normal file
31
agent/app/core/manifest_validator.py
Normal 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
|
||||
42
agent/app/core/service_manager.py
Normal file
42
agent/app/core/service_manager.py
Normal 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"
|
||||
387
agent/app/core/task_runner.py
Normal file
387
agent/app/core/task_runner.py
Normal 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
37
agent/app/main.py
Normal 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)
|
||||
|
||||
2
agent/app/models/__init__.py
Normal file
2
agent/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Pydantic models for API contracts."""
|
||||
|
||||
268
agent/app/models/schemas.py
Normal file
268
agent/app/models/schemas.py
Normal 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)
|
||||
2
agent/app/storage/__init__.py
Normal file
2
agent/app/storage/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""SQLite storage layer."""
|
||||
|
||||
112
agent/app/storage/database.py
Normal file
112
agent/app/storage/database.py
Normal 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")
|
||||
256
agent/app/storage/repository.py
Normal file
256
agent/app/storage/repository.py
Normal 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=(",", ":"))
|
||||
2
agent/app/utils/__init__.py
Normal file
2
agent/app/utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Utility helpers."""
|
||||
|
||||
137
agent/app/utils/validators.py
Normal file
137
agent/app/utils/validators.py
Normal 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
|
||||
10
agent/packaging/DEBIAN/control
Normal file
10
agent/packaging/DEBIAN/control
Normal 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.
|
||||
57
agent/packaging/DEBIAN/postinst
Normal file
57
agent/packaging/DEBIAN/postinst
Normal 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
|
||||
6
agent/packaging/DEBIAN/postrm
Normal file
6
agent/packaging/DEBIAN/postrm
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
exit 0
|
||||
8
agent/packaging/DEBIAN/prerm
Normal file
8
agent/packaging/DEBIAN/prerm
Normal 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
|
||||
13
agent/packaging/systemd/local-installer-agent.service
Normal file
13
agent/packaging/systemd/local-installer-agent.service
Normal 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
4
agent/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
pydantic>=2,<3
|
||||
httpx>=0.27,<1.0
|
||||
85
agent/scripts/build-deb.sh
Normal file
85
agent/scripts/build-deb.sh
Normal 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
|
||||
23
agent/scripts/install-agent.sh
Normal file
23
agent/scripts/install-agent.sh
Normal 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
BIN
database.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
image-1.png
BIN
image-1.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
11
web-client/.dockerignore
Normal file
11
web-client/.dockerignore
Normal 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
11
web-client/.env.example
Normal 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
29
web-client/Dockerfile
Normal 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
42
web-client/README.md
Normal 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`.
|
||||
22
web-client/docker-compose.yml
Normal file
22
web-client/docker-compose.yml
Normal 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
19
web-client/index.html
Normal 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>
|
||||
63
web-client/nginx.conf.template
Normal file
63
web-client/nginx.conf.template
Normal 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
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
20
web-client/package.json
Normal 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
1377
web-client/src/main.jsx
Normal file
File diff suppressed because it is too large
Load Diff
325
web-client/src/services/api.js
Normal file
325
web-client/src/services/api.js
Normal 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
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
36
web-client/vite.config.js
Normal 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
11
web-server/.dockerignore
Normal 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
30
web-server/.env.example
Normal 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
9
web-server/.gitignore
vendored
Normal 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
31
web-server/Dockerfile
Normal 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"]
|
||||
11
web-server/database/01_create_database.sql
Normal file
11
web-server/database/01_create_database.sql
Normal 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
|
||||
292
web-server/database/02_schema.sql
Normal file
292
web-server/database/02_schema.sql
Normal 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
|
||||
134
web-server/database/03_views.sql
Normal file
134
web-server/database/03_views.sql
Normal 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
|
||||
93
web-server/database/README.md
Normal file
93
web-server/database/README.md
Normal 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` và `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` và `ApplicationPackages`.
|
||||
|
||||
5. Xóa version:
|
||||
- gọi `dbo.DeletePackageVersion @PackageVersionId` để xóa cả liên kết app đang dùng version đó.
|
||||
26
web-server/docker-compose.yml
Normal file
26
web-server/docker-compose.yml
Normal 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
|
||||
7
web-server/docker-entrypoint.sh
Normal file
7
web-server/docker-entrypoint.sh
Normal 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
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
20
web-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1548
web-server/public/css/styles.css
Normal file
1548
web-server/public/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
721
web-server/public/js/app.js
Normal file
721
web-server/public/js/app.js
Normal 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
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
50
web-server/src/db.js
Normal 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
100
web-server/src/mailer.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
137
web-server/src/mock-data.js
Normal 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
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
157
web-server/views/agent.ejs
Normal 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_<version>_<arch>.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') %>
|
||||
123
web-server/views/application-detail.ejs
Normal file
123
web-server/views/application-detail.ejs
Normal 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') %>
|
||||
115
web-server/views/applications.ejs
Normal file
115
web-server/views/applications.ejs
Normal 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
97
web-server/views/auth.ejs
Normal 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>
|
||||
109
web-server/views/builder.ejs
Normal file
109
web-server/views/builder.ejs
Normal 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') %>
|
||||
54
web-server/views/confirm-email-sent.ejs
Normal file
54
web-server/views/confirm-email-sent.ejs
Normal 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>
|
||||
122
web-server/views/dashboard.ejs
Normal file
122
web-server/views/dashboard.ejs
Normal 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') %>
|
||||
18
web-server/views/error.ejs
Normal file
18
web-server/views/error.ejs
Normal 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') %>
|
||||
15
web-server/views/not-found.ejs
Normal file
15
web-server/views/not-found.ejs
Normal 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') %>
|
||||
103
web-server/views/package-detail.ejs
Normal file
103
web-server/views/package-detail.ejs
Normal 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') %>
|
||||
113
web-server/views/packages.ejs
Normal file
113
web-server/views/packages.ejs
Normal 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') %>
|
||||
67
web-server/views/partials/edit-app-modal.ejs
Normal file
67
web-server/views/partials/edit-app-modal.ejs
Normal 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>
|
||||
78
web-server/views/partials/package-modal.ejs
Normal file
78
web-server/views/partials/package-modal.ejs
Normal 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>
|
||||
61
web-server/views/partials/page-end.ejs
Normal file
61
web-server/views/partials/page-end.ejs
Normal 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>
|
||||
87
web-server/views/partials/page-start.ejs
Normal file
87
web-server/views/partials/page-start.ejs
Normal 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">
|
||||
68
web-server/views/partials/update-package-modal.ejs
Normal file
68
web-server/views/partials/update-package-modal.ejs
Normal 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
275
web-server/views/users.ejs
Normal 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') %>
|
||||
Reference in New Issue
Block a user