web server

This commit is contained in:
2026-05-20 14:10:25 +07:00
parent 5ade939ff9
commit 190d2418da
30 changed files with 8917 additions and 0 deletions

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

@@ -0,0 +1,20 @@
PORT=3000
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
AUTH_SECRET=change_this_to_a_long_random_value
SESSION_MAX_AGE_MS=28800000
EMAIL_CONFIRMATION_EXPIRES_MS=86400000
APP_BASE_URL=http://localhost:3000
# Mail chính dùng để gửi email xác nhận tới các tài khoản đăng ký
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=main_sender_email@gmail.com
SMTP_PASSWORD=main_sender_app_password
MAIL_FROM="Robot Installer <main_sender_email@gmail.com>"

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

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

View File

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

View File

@@ -0,0 +1,291 @@
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,
CONSTRAINT FK_Applications_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Applications_Status CHECK (Status IN (N'Draft', N'Released', N'Archived')),
CONSTRAINT CK_Applications_AppCode_NotBlank CHECK (LEN(LTRIM(RTRIM(AppCode))) > 0),
CONSTRAINT CK_Applications_AppName_NotBlank CHECK (LEN(LTRIM(RTRIM(AppName))) > 0),
CONSTRAINT CK_Applications_AppVersion_NotBlank CHECK (LEN(LTRIM(RTRIM(AppVersion))) > 0)
);
GO
CREATE TABLE dbo.ApplicationPackages
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_ApplicationPackages PRIMARY KEY CLUSTERED
CONSTRAINT DF_ApplicationPackages_Id DEFAULT NEWSEQUENTIALID(),
ApplicationId UNIQUEIDENTIFIER NOT NULL,
PackageId UNIQUEIDENTIFIER NOT NULL,
SelectedVersionId UNIQUEIDENTIFIER NULL,
AddedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_ApplicationPackages_AddedAt DEFAULT SYSUTCDATETIME(),
Notes NVARCHAR(500) NULL,
CONSTRAINT FK_ApplicationPackages_Application
FOREIGN KEY (ApplicationId) REFERENCES dbo.Applications(Id) ON DELETE CASCADE,
CONSTRAINT FK_ApplicationPackages_Package
FOREIGN KEY (PackageId) REFERENCES dbo.Packages(Id) ON DELETE CASCADE
);
GO
CREATE UNIQUE INDEX UX_Users_Username ON dbo.Users(Username);
CREATE UNIQUE INDEX UX_Users_Email ON dbo.Users(Email);
GO
CREATE UNIQUE INDEX UX_EmailConfirmationTokens_TokenHash
ON dbo.EmailConfirmationTokens(TokenHash);
CREATE INDEX IX_EmailConfirmationTokens_UserId
ON dbo.EmailConfirmationTokens(UserId);
GO
CREATE UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);
GO
CREATE UNIQUE INDEX UX_PackageVersions_PackageId_Version
ON dbo.PackageVersions(PackageId, Version);
CREATE UNIQUE INDEX UX_PackageVersions_Id_PackageId
ON dbo.PackageVersions(Id, PackageId);
CREATE UNIQUE INDEX UX_PackageVersions_OneLatestPerPackage
ON dbo.PackageVersions(PackageId)
WHERE IsLatest = 1;
CREATE INDEX IX_PackageVersions_PackageId_ReleaseDate
ON dbo.PackageVersions(PackageId, ReleaseDate DESC);
GO
CREATE UNIQUE INDEX UX_Applications_AppCode
ON dbo.Applications(AppCode);
CREATE INDEX IX_Applications_CreatedByUserId ON dbo.Applications(CreatedByUserId);
CREATE INDEX IX_Applications_Status ON dbo.Applications(Status);
GO
CREATE UNIQUE INDEX UX_ApplicationPackages_ApplicationId_PackageId
ON dbo.ApplicationPackages(ApplicationId, PackageId);
CREATE INDEX IX_ApplicationPackages_PackageId
ON dbo.ApplicationPackages(PackageId);
CREATE INDEX IX_ApplicationPackages_SelectedVersionId
ON dbo.ApplicationPackages(SelectedVersionId);
GO
ALTER TABLE dbo.ApplicationPackages
ADD CONSTRAINT FK_ApplicationPackages_SelectedVersionBelongsToPackage
FOREIGN KEY (SelectedVersionId, PackageId)
REFERENCES dbo.PackageVersions(Id, PackageId);
GO
CREATE OR ALTER PROCEDURE dbo.SetLatestPackageVersion
@PackageVersionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PackageId UNIQUEIDENTIFIER;
SELECT @PackageId = PackageId
FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @PackageId IS NULL
BEGIN
THROW 50002, 'Package version does not exist.', 1;
END;
BEGIN TRANSACTION;
UPDATE dbo.PackageVersions
SET IsLatest = 0
WHERE PackageId = @PackageId;
UPDATE dbo.PackageVersions
SET IsLatest = 1,
IsDeprecated = 0
WHERE Id = @PackageVersionId;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.DeletePackageVersion
@PackageVersionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PackageId UNIQUEIDENTIFIER;
DECLARE @WasLatest BIT;
SELECT
@PackageId = PackageId,
@WasLatest = IsLatest
FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @PackageId IS NULL
BEGIN
THROW 50003, 'Package version does not exist.', 1;
END;
BEGIN TRANSACTION;
DELETE FROM dbo.ApplicationPackages
WHERE SelectedVersionId = @PackageVersionId;
DELETE FROM dbo.PackageVersions
WHERE Id = @PackageVersionId;
IF @WasLatest = 1
BEGIN
DECLARE @NextLatestId UNIQUEIDENTIFIER;
SELECT TOP (1) @NextLatestId = Id
FROM dbo.PackageVersions
WHERE PackageId = @PackageId
AND IsDeprecated = 0
ORDER BY ReleaseDate DESC, UploadedAt DESC;
IF @NextLatestId IS NOT NULL
BEGIN
EXEC dbo.SetLatestPackageVersion @NextLatestId;
END;
END;
COMMIT TRANSACTION;
END;
GO
PRINT N'RobotInstaller schema was created successfully.';
GO

View File

@@ -0,0 +1,131 @@
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.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.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,
ap.PackageId,
p.PackageCode,
p.PackageName,
p.PackageType,
ap.SelectedVersionId,
pv.Version AS SelectedVersion,
pv.FilePath,
pv.DockerImage,
ap.AddedAt,
ap.Notes
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a
ON a.Id = ap.ApplicationId
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS pv
ON pv.Id = ap.SelectedVersionId;
GO
PRINT N'RobotInstaller views were created successfully.';
GO

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,719 @@
(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',
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, '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);
});
})();

1205
web-server/server.js Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,49 @@
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)
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
}
async function getPool() {
if (!poolPromise) {
poolPromise = sql.connect(getConfig());
}
return poolPromise;
}
async function closePool() {
if (!poolPromise) return;
const pool = await poolPromise;
await pool.close();
poolPromise = undefined;
}
module.exports = {
sql,
getPool,
closePool
};

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
<%- 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-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>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-notes="<%= application.notes %>"
data-app-packages="<%= JSON.stringify(application.packages.map((item) => ({ packageId: item.packageId, selectedVersionId: item.selectedVersionId }))) %>"
>
<span class="material-symbols-outlined">swap_horiz</span>
</button>
<form method="post" action="/applications/<%= application.id %>/packages/<%= pkg.packageId %>/delete" data-confirm-submit="Gỡ package <%= pkg.code %> khỏi app?">
<button class="icon-button danger" type="submit" title="Gỡ package" aria-label="Gỡ package <%= pkg.name %>">
<span class="material-symbols-outlined">link_off</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/edit-app-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,114 @@
<%- 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-notes="<%= item.notes %>"
data-app-packages="<%= JSON.stringify(item.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>"
>
<span class="material-symbols-outlined">edit</span>
</button>
<form method="post" action="/applications/<%= item.id %>/delete" data-confirm-submit="Xóa app <%= item.code %>? Thao tác này sẽ xóa thông tin đóng gói của app.">
<button class="icon-button danger" type="submit" title="Xóa app" aria-label="Xóa app <%= item.name %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= applications.length %> of <%= applications.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</section>
<%- include('partials/edit-app-modal') %>
<%- include('partials/page-end') %>

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

@@ -0,0 +1,97 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
<main class="auth-page">
<section class="auth-panel">
<div class="auth-brand">
<div class="brand-mark">
<span class="material-symbols-outlined">precision_manufacturing</span>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>User Access</span>
</div>
</div>
<% if (mode === 'login') { %>
<div class="auth-heading">
<h1>Đăng nhập</h1>
<p>Truy cập console quản lý package và app.</p>
</div>
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="returnTo" value="<%= returnTo %>">
<label class="form-field">
<span>Username hoặc email</span>
<input type="text" name="identifier" autocomplete="username" required autofocus>
</label>
<label class="form-field">
<span>Mật khẩu</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="btn btn-primary auth-submit" type="submit">
<span class="material-symbols-outlined">login</span>
Đăng nhập
</button>
</form>
<p class="auth-switch">Chưa có tài khoản? <a href="/register">Đăng ký</a></p>
<% } else { %>
<div class="auth-heading">
<h1>Đăng ký</h1>
<p>App sẽ gửi email xác nhận để kích hoạt tài khoản.</p>
</div>
<form class="auth-form" method="post" action="/register" data-register-form>
<div class="form-grid">
<label class="form-field">
<span>Username</span>
<input type="text" name="username" value="<%= values.username || '' %>" autocomplete="username" required autofocus data-unique-check="username">
<small class="field-feedback" data-unique-feedback="username"></small>
</label>
<label class="form-field">
<span>Họ tên</span>
<input type="text" name="fullName" value="<%= values.fullName || '' %>" autocomplete="name">
</label>
</div>
<label class="form-field">
<span>Email</span>
<input type="email" name="email" value="<%= values.email || '' %>" autocomplete="email" required data-unique-check="email">
<small class="field-feedback" data-unique-feedback="email"></small>
</label>
<div class="form-grid">
<label class="form-field">
<span>Mật khẩu</span>
<input type="password" name="password" autocomplete="new-password" minlength="8" required>
</label>
<label class="form-field">
<span>Xác nhận mật khẩu</span>
<input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required>
</label>
</div>
<button class="btn btn-primary auth-submit" type="submit" data-register-submit>
<span class="material-symbols-outlined">person_add</span>
Tạo tài khoản
</button>
</form>
<p class="auth-switch">Đã có tài khoản? <a href="/login">Đăng nhập</a></p>
<% } %>
</section>
</main>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<%- 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" required>
</label>
<label class="form-field">
<span>App version</span>
<input type="text" name="appVersion" required>
</label>
<label class="form-field full">
<span>App name</span>
<input type="text" name="appName" required>
</label>
<label class="form-field full">
<span>Notes</span>
<textarea name="notes"></textarea>
</label>
</form>
</section>
<section class="table-panel builder-table">
<div class="panel-header">
<div>
<h2>Chọn package</h2>
<p>Có thể dùng chung `.deb` và Docker trong một app.</p>
</div>
</div>
<div class="page-filters inline">
<label class="filter-field wide">
<span>Search</span>
<input type="search" placeholder="Tìm package..." data-table-search="builderPackagesTable">
</label>
</div>
<div class="table-wrap">
<table id="builderPackagesTable" class="data-table">
<thead>
<tr>
<th>Use</th>
<th>Package</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% if (packages.length === 0) { %>
<tr>
<td colspan="5" class="table-empty">Chưa có package để đóng gói. Hãy upload package trước.</td>
</tr>
<% } %>
<% packages.forEach((item, index) => { %>
<tr data-search="<%= `${item.name} ${item.code} ${item.latestVersion}`.toLowerCase() %>">
<td>
<input class="checkbox" form="builderForm" type="checkbox" name="packageIds" value="<%= item.id %>" <%= index < 3 ? 'checked' : '' %> aria-label="Chọn <%= item.name %>">
</td>
<td>
<strong><%= item.name %></strong>
<span class="table-subtitle"><%= item.code %></span>
</td>
<td><span class="badge <%= helpers.packageTypeClass(item.type) %>"><%= helpers.packageTypeLabel(item.type) %></span></td>
<td>
<select class="mini-select" form="builderForm" name="version_<%= item.id %>" aria-label="Version của <%= item.name %>">
<% item.versions.forEach((version) => { %>
<option value="<%= version.id %>"><%= version.version %></option>
<% }) %>
</select>
</td>
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
<main class="auth-page">
<section class="auth-panel">
<div class="auth-brand">
<div class="brand-mark">
<span class="material-symbols-outlined">precision_manufacturing</span>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>Email Confirmation</span>
</div>
</div>
<div class="auth-confirm-icon">
<span class="material-symbols-outlined">mark_email_unread</span>
</div>
<div class="auth-heading">
<h1>Kiểm tra email</h1>
<p>Chúng tôi đã gửi link xác nhận tới email đăng ký. Tài khoản chỉ được kích hoạt sau khi bạn bấm link confirm.</p>
</div>
<form class="auth-form" method="post" action="/resend-confirmation">
<label class="form-field">
<span>Email đăng ký</span>
<input type="email" name="email" value="<%= email %>" autocomplete="email" required>
</label>
<button class="btn btn-secondary auth-submit" type="submit">
<span class="material-symbols-outlined">forward_to_inbox</span>
Gửi lại email xác nhận
</button>
</form>
<p class="auth-switch">Đã xác nhận? <a href="/login">Đăng nhập</a></p>
</section>
</main>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
<%- 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">
<form method="post" action="/package-versions/<%= version.id %>/latest">
<input type="hidden" name="returnTo" value="<%= currentPath %>">
<button class="icon-button subtle" type="submit" title="Đặt latest" aria-label="Đặt latest <%= version.version %>" <%= version.status === 'Latest' ? 'disabled' : '' %>>
<span class="material-symbols-outlined">stars</span>
</button>
</form>
<form method="post" action="/package-versions/<%= version.id %>/delete" data-confirm-submit="Xóa version <%= version.version %> khỏi package?">
<input type="hidden" name="returnTo" value="<%= currentPath %>">
<button class="icon-button danger" type="submit" title="Xóa version" aria-label="Xóa version <%= version.version %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</section>
</div>
</section>
<%- include('partials/update-package-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,108 @@
<%- 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>
<button class="icon-button subtle" type="button" title="Update version" data-modal-open="updatePackageModal" data-package-update="<%= item.id %>">
<span class="material-symbols-outlined">upgrade</span>
</button>
<form method="post" action="/packages/<%= item.id %>/delete" data-confirm-submit="Xóa package <%= item.code %>? Thao tác này sẽ xóa cả version và liên kết app liên quan.">
<button class="icon-button danger" type="submit" title="Xóa package" aria-label="Xóa package <%= item.name %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Showing 1-<%= packages.length %> of <%= packages.length %></span>
<div>
<button type="button" disabled>Prev</button>
<span>Page 1 / 1</span>
<button type="button" disabled>Next</button>
</div>
</div>
</section>
</section>
<%- include('partials/package-modal') %>
<%- include('partials/update-package-modal') %>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,63 @@
<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" required data-edit-app-field="appCode">
</label>
<label class="form-field">
<span>Version</span>
<input type="text" name="appVersion" 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>Notes</span>
<textarea name="notes" data-edit-app-field="notes"></textarea>
</label>
</div>
<div class="modal-mini-table">
<% packages.forEach((item) => { %>
<label>
<input class="checkbox" type="checkbox" name="packageIds" value="<%= item.id %>" data-edit-app-package="<%= item.id %>">
<span>
<strong><%= item.name %></strong>
<small><%= item.code %></small>
</span>
<select class="mini-select" name="version_<%= item.id %>" data-edit-app-version="<%= item.id %>">
<option value="">Latest/default</option>
<% item.versions.forEach((version) => { %>
<option value="<%= version.id %>"><%= version.version %></option>
<% }) %>
</select>
</label>
<% }) %>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit">
<span class="material-symbols-outlined">save</span>
Lưu
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<div class="modal-backdrop" id="uploadPackageModal" role="dialog" aria-modal="true" aria-labelledby="uploadPackageTitle">
<div class="modal-content">
<div class="modal-header">
<h3 id="uploadPackageTitle">Upload package</h3>
<button class="icon-button subtle" type="button" aria-label="Đóng modal" data-modal-close>
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" action="/packages" method="post" enctype="multipart/form-data">
<div class="form-grid">
<label class="form-field">
<span>Package code</span>
<input type="text" name="packageCode" placeholder="NAV-STACK" 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" required>
</label>
<label class="form-field">
<span>Release date</span>
<input type="date" name="releaseDate" value="2026-05-19">
</label>
<div class="form-field full">
<span>Package file</span>
<div class="file-dropzone" data-file-dropzone>
<input class="file-input" type="file" name="packageFile" accept=".deb,.tar,.tar.gz,.tgz,.zip,.gz" data-file-input>
<div class="file-dropzone-content">
<span class="material-symbols-outlined">upload_file</span>
<strong>Kéo file vào đây hoặc chọn từ máy</strong>
<small>Hỗ trợ .deb, .tar, .tgz, .zip cho package hoặc Docker image export</small>
<button class="btn btn-secondary" type="button" data-file-browse>
<span class="material-symbols-outlined">attach_file</span>
Chọn file
</button>
</div>
<div class="file-preview" data-file-preview hidden>
<span class="material-symbols-outlined">draft</span>
<div>
<strong data-file-name>Chưa chọn file</strong>
<small data-file-meta></small>
</div>
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
<label class="form-field full">
<span>Docker image/tag</span>
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:1.9.0">
</label>
<label class="form-field full">
<span>Change log</span>
<textarea name="changeLog" placeholder="Ghi chú thay đổi"></textarea>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</form>
</div>
</div>

View File

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

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body class="app-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
<aside id="appSidebar" class="sidebar" aria-label="Main navigation">
<div class="brand-block">
<div class="brand-mark">
<span class="material-symbols-outlined">precision_manufacturing</span>
</div>
<div class="brand-copy">
<strong>Robot Installer</strong>
<span>Package Console</span>
</div>
</div>
<nav class="nav-section" aria-label="Workspace">
<span class="nav-label">Workspace</span>
<% navItems.forEach((item) => { %>
<a href="<%= item.href %>" class="nav-item <%= active === item.id ? 'active' : '' %>">
<span class="material-symbols-outlined"><%= item.icon %></span>
<span><%= item.label %></span>
</a>
<% }) %>
</nav>
<div class="sidebar-status">
<span class="status-dot"></span>
<div>
<strong>SQL Server</strong>
<span>172.20.235.176</span>
</div>
</div>
</aside>
<button id="sidebarBackdrop" type="button" aria-label="Đóng menu"></button>
<main id="appMain" class="main-shell">
<header class="topbar">
<div class="topbar-left">
<button id="mobileMenuBtn" class="icon-button" type="button" aria-label="Mở menu" aria-expanded="false">
<span class="material-symbols-outlined">menu</span>
</button>
</div>
<div class="topbar-actions">
<button class="icon-button" type="button" title="Đồng bộ dữ liệu" data-refresh-page>
<span class="material-symbols-outlined">sync</span>
</button>
<button class="icon-button" type="button" title="Thông báo" data-toast="Chưa có thông báo mới">
<span class="material-symbols-outlined">notifications</span>
</button>
<% if (currentUser.role === 'User') { %>
<button class="profile-chip profile-chip-button" type="button" title="Cập nhật thông tin cá nhân" aria-label="Cập nhật thông tin cá nhân" data-modal-open="profileModal">
<span class="profile-avatar"><%= currentUser.name.charAt(0) %></span>
<span class="profile-meta">
<strong><%= currentUser.name %></strong>
<span><%= currentUser.role %></span>
</span>
</button>
<% } else { %>
<div class="profile-chip">
<span class="profile-avatar"><%= currentUser.name.charAt(0) %></span>
<span class="profile-meta">
<strong><%= currentUser.name %></strong>
<span><%= currentUser.role %></span>
</span>
</div>
<% } %>
<form class="logout-form" method="post" action="/logout">
<button class="icon-button" type="submit" title="Đăng xuất" aria-label="Đăng xuất">
<span class="material-symbols-outlined">logout</span>
</button>
</form>
</div>
</header>
<div id="mainContent" class="main-content">

View File

@@ -0,0 +1,67 @@
<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>New version</span>
<input type="text" name="version" placeholder="2.5.0" required>
</label>
<label class="form-field">
<span>Release date</span>
<input type="date" name="releaseDate" value="2026-05-19">
</label>
<div class="form-field full">
<span>Package file</span>
<div class="file-dropzone" data-file-dropzone>
<input class="file-input" type="file" name="packageFile" accept=".deb,.tar,.tar.gz,.tgz,.zip,.gz" data-file-input>
<div class="file-dropzone-content">
<span class="material-symbols-outlined">upload_file</span>
<strong>Kéo version mới vào đây hoặc chọn file</strong>
<small>File .deb hoặc archive Docker export đều dùng được</small>
<button class="btn btn-secondary" type="button" data-file-browse>
<span class="material-symbols-outlined">attach_file</span>
Chọn file
</button>
</div>
<div class="file-preview" data-file-preview hidden>
<span class="material-symbols-outlined">draft</span>
<div>
<strong data-file-name>Chưa chọn file</strong>
<small data-file-meta></small>
</div>
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
<label class="form-field full">
<span>Docker image/tag</span>
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:2.0.0">
</label>
<label class="form-field full">
<span>Change log</span>
<textarea name="changeLog" placeholder="Mô tả thay đổi trong version này"></textarea>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit">Cập nhật</button>
</div>
</form>
</div>
</div>

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

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