web server
This commit is contained in:
20
web-server/.env.example
Normal file
20
web-server/.env.example
Normal 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
9
web-server/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.env
|
||||
uploads/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
.DS_Store
|
||||
11
web-server/database/01_create_database.sql
Normal file
11
web-server/database/01_create_database.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
IF DB_ID(N'RobotInstaller') IS NULL
|
||||
BEGIN
|
||||
CREATE DATABASE [RobotInstaller];
|
||||
END;
|
||||
GO
|
||||
|
||||
USE [RobotInstaller];
|
||||
GO
|
||||
|
||||
PRINT N'Database RobotInstaller is ready.';
|
||||
GO
|
||||
291
web-server/database/02_schema.sql
Normal file
291
web-server/database/02_schema.sql
Normal 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
|
||||
131
web-server/database/03_views.sql
Normal file
131
web-server/database/03_views.sql
Normal 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
|
||||
93
web-server/database/README.md
Normal file
93
web-server/database/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# RobotInstaller database
|
||||
|
||||
Thiết kế này bám theo `database.md` và sơ đồ database hiện có, sau đó bổ sung vài cột cần cho web server:
|
||||
|
||||
- `PackageType`: phân biệt package `.deb` và `docker`.
|
||||
- `AppVersion`: version hiện tại của app đóng gói.
|
||||
- metadata artifact: `DockerImage`, `FileChecksumSha256`, `FileSizeBytes`, `UploadedAt`.
|
||||
- `Role`, `IsActive` cho tài khoản đăng nhập web.
|
||||
|
||||
## Database
|
||||
|
||||
Tên database được chọn: `RobotInstaller`
|
||||
|
||||
Server:
|
||||
|
||||
```powershell
|
||||
172.20.235.176
|
||||
```
|
||||
|
||||
Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo file `.env` từ `web-server/.env.example` rồi điền mật khẩu thật.
|
||||
|
||||
## Cấu trúc chính
|
||||
|
||||
| Bảng | Vai trò |
|
||||
| --- | --- |
|
||||
| `dbo.Users` | Người dùng web server |
|
||||
| `dbo.Packages` | Danh mục package |
|
||||
| `dbo.PackageVersions` | Các version của từng package |
|
||||
| `dbo.Applications` | App được đóng gói từ nhiều package |
|
||||
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
|
||||
|
||||
## Ràng buộc quan trọng
|
||||
|
||||
- `Users.Username`, `Users.Email` là duy nhất.
|
||||
- `Packages.PackageCode` là duy nhất.
|
||||
- `Applications.AppCode` là duy nhất.
|
||||
- Mỗi package không được trùng `Version`.
|
||||
- Mỗi app chỉ chứa một dòng cho mỗi package.
|
||||
- Mỗi package chỉ có tối đa một `IsLatest = 1`.
|
||||
- `ApplicationPackages.SelectedVersionId` bắt buộc thuộc đúng `PackageId` trên cùng dòng.
|
||||
|
||||
## View cho API
|
||||
|
||||
| View | Dùng cho màn hình |
|
||||
| --- | --- |
|
||||
| `dbo.vw_PackageList` | Danh sách package kèm latest version |
|
||||
| `dbo.vw_PackageVersionList` | Chi tiết version của package |
|
||||
| `dbo.vw_ApplicationList` | Danh sách app kèm số package |
|
||||
| `dbo.vw_ApplicationPackageDetails` | Chi tiết package/version trong app |
|
||||
|
||||
## Stored procedure
|
||||
|
||||
| Procedure | Mục đích |
|
||||
| --- | --- |
|
||||
| `dbo.SetLatestPackageVersion` | Đặt một version là latest và tự clear latest cũ |
|
||||
| `dbo.DeletePackageVersion` | Xóa version và các liên kết app đang dùng version đó |
|
||||
|
||||
## Triển khai bằng sqlcmd
|
||||
|
||||
```powershell
|
||||
$env:SQLCMDPASSWORD = '<mat-khau-sa>'
|
||||
|
||||
sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
|
||||
```
|
||||
|
||||
Chạy các lệnh trên từ thư mục `web-server`.
|
||||
|
||||
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
|
||||
|
||||
## Luồng dữ liệu đề xuất
|
||||
|
||||
1. Upload package mới:
|
||||
- nếu package chưa tồn tại, insert vào `Packages`;
|
||||
- insert version vào `PackageVersions`;
|
||||
- gọi `dbo.SetLatestPackageVersion @PackageVersionId`.
|
||||
|
||||
2. Update package:
|
||||
- insert thêm một dòng mới vào `PackageVersions`;
|
||||
- gọi `dbo.SetLatestPackageVersion @PackageVersionId`.
|
||||
|
||||
3. Tạo app:
|
||||
- insert vào `Applications`;
|
||||
- insert các package được chọn vào `ApplicationPackages`;
|
||||
- nếu user chọn version cụ thể, điền `SelectedVersionId`.
|
||||
|
||||
4. Xóa package:
|
||||
- xóa dòng trong `Packages`;
|
||||
- database tự cascade sang `PackageVersions` và `ApplicationPackages`.
|
||||
|
||||
5. Xóa version:
|
||||
- gọi `dbo.DeletePackageVersion @PackageVersionId` để xóa cả liên kết app đang dùng version đó.
|
||||
1948
web-server/package-lock.json
generated
Normal file
1948
web-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
web-server/package.json
Normal file
20
web-server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "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"
|
||||
}
|
||||
}
|
||||
1489
web-server/public/css/styles.css
Normal file
1489
web-server/public/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
719
web-server/public/js/app.js
Normal file
719
web-server/public/js/app.js
Normal 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
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
49
web-server/src/db.js
Normal 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
100
web-server/src/mailer.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
let transporter;
|
||||
|
||||
function boolFromEnv(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
return ['1', 'true', 'yes'].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(process.env.SMTP_HOST && process.env.SMTP_USER);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getSenderAddress() {
|
||||
return process.env.MAIL_FROM || process.env.SMTP_USER;
|
||||
}
|
||||
|
||||
function getTransporter() {
|
||||
if (!isConfigured()) return null;
|
||||
|
||||
if (!transporter) {
|
||||
const port = Number(process.env.SMTP_PORT || 587);
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port,
|
||||
secure: boolFromEnv(process.env.SMTP_SECURE, port === 465),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
async function sendConfirmationEmail({ to, name, confirmUrl }) {
|
||||
const mailTransporter = getTransporter();
|
||||
const safeName = escapeHtml(name || to);
|
||||
const safeConfirmUrl = escapeHtml(confirmUrl);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`Email confirmation link for ${to}: ${confirmUrl}`);
|
||||
}
|
||||
|
||||
if (!mailTransporter) {
|
||||
console.warn(`SMTP is not configured. Email confirmation link for ${to}: ${confirmUrl}`);
|
||||
return { sent: false, reason: 'SMTP_NOT_CONFIGURED' };
|
||||
}
|
||||
|
||||
const result = await mailTransporter.sendMail({
|
||||
from: getSenderAddress(),
|
||||
to,
|
||||
subject: 'Xác nhận tài khoản Robot Installer',
|
||||
text: [
|
||||
`Xin chào ${name || to},`,
|
||||
'',
|
||||
'Bạn vừa đăng ký tài khoản hoặc cập nhật email Robot Installer.',
|
||||
`Bấm link sau để xác nhận email và kích hoạt tài khoản: ${confirmUrl}`,
|
||||
'',
|
||||
'Nếu bạn không thực hiện đăng ký này, hãy bỏ qua email.'
|
||||
].join('\n'),
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; color: #172033; line-height: 1.5;">
|
||||
<h2 style="margin: 0 0 12px;">Xác nhận tài khoản Robot Installer</h2>
|
||||
<p>Xin chào ${safeName},</p>
|
||||
<p>Bạn vừa đăng ký tài khoản hoặc cập nhật email Robot Installer. Bấm nút bên dưới để xác nhận email và kích hoạt tài khoản.</p>
|
||||
<p>
|
||||
<a href="${safeConfirmUrl}" style="background: #3755c3; border-radius: 8px; color: #ffffff; display: inline-block; font-weight: 700; padding: 10px 14px; text-decoration: none;">
|
||||
Xác nhận email
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #566166; font-size: 13px;">Nếu nút không mở được, copy link này vào trình duyệt:<br>${safeConfirmUrl}</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
console.info('Confirmation email sent:', {
|
||||
to,
|
||||
messageId: result.messageId,
|
||||
accepted: result.accepted,
|
||||
rejected: result.rejected
|
||||
});
|
||||
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendConfirmationEmail
|
||||
};
|
||||
137
web-server/src/mock-data.js
Normal file
137
web-server/src/mock-data.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const packages = [
|
||||
{
|
||||
id: 'navigation-stack',
|
||||
code: 'NAV-STACK',
|
||||
name: 'Navigation Stack',
|
||||
type: 'deb',
|
||||
latestVersion: '2.4.1',
|
||||
latestReleaseDate: '2026-05-17',
|
||||
status: 'Active',
|
||||
owner: 'Dũng Tào',
|
||||
description: 'Core navigation package for robot route planning and localization.',
|
||||
artifact: '/packages/navigation-stack_2.4.1_amd64.deb',
|
||||
versions: [
|
||||
{ version: '2.4.1', releaseDate: '2026-05-17', uploadedBy: 'Dũng Tào', status: 'Latest', size: '84.2 MB', changeLog: 'Improve recovery flow and map alignment.' },
|
||||
{ version: '2.4.0', releaseDate: '2026-05-10', uploadedBy: 'Dũng Tào', status: 'Stable', size: '83.8 MB', changeLog: 'Add obstacle avoidance tuning profile.' },
|
||||
{ version: '2.3.8', releaseDate: '2026-04-28', uploadedBy: 'QA Robot', status: 'Deprecated', size: '81.6 MB', changeLog: 'Legacy build kept for rollback.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fleet-agent',
|
||||
code: 'FLEET-AGENT',
|
||||
name: 'Fleet Agent',
|
||||
type: 'docker',
|
||||
latestVersion: '1.9.0',
|
||||
latestReleaseDate: '2026-05-16',
|
||||
status: 'Active',
|
||||
owner: 'Minh Anh',
|
||||
description: 'Docker service that connects robot clients to Fleet Manager.',
|
||||
artifact: 'registry.local/robot/fleet-agent:1.9.0',
|
||||
versions: [
|
||||
{ version: '1.9.0', releaseDate: '2026-05-16', uploadedBy: 'Minh Anh', status: 'Latest', size: '412 MB', changeLog: 'Add heartbeat metrics and websocket retry.' },
|
||||
{ version: '1.8.2', releaseDate: '2026-05-01', uploadedBy: 'Minh Anh', status: 'Stable', size: '405 MB', changeLog: 'Fix token renewal after sleep.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'map-sync',
|
||||
code: 'MAP-SYNC',
|
||||
name: 'Map Sync Service',
|
||||
type: 'docker',
|
||||
latestVersion: '3.1.2',
|
||||
latestReleaseDate: '2026-05-12',
|
||||
status: 'Active',
|
||||
owner: 'Hải Nam',
|
||||
description: 'Synchronizes robot map revisions between web server and edge clients.',
|
||||
artifact: 'registry.local/robot/map-sync:3.1.2',
|
||||
versions: [
|
||||
{ version: '3.1.2', releaseDate: '2026-05-12', uploadedBy: 'Hải Nam', status: 'Latest', size: '268 MB', changeLog: 'Compress map snapshot before upload.' },
|
||||
{ version: '3.0.5', releaseDate: '2026-04-22', uploadedBy: 'Hải Nam', status: 'Stable', size: '251 MB', changeLog: 'Improve checksum validation.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ui-kiosk',
|
||||
code: 'UI-KIOSK',
|
||||
name: 'Robot Kiosk UI',
|
||||
type: 'deb',
|
||||
latestVersion: '0.8.6',
|
||||
latestReleaseDate: '2026-05-08',
|
||||
status: 'Testing',
|
||||
owner: 'Linh Phạm',
|
||||
description: 'Touch-screen UI package for robot kiosk mode.',
|
||||
artifact: '/packages/robot-kiosk-ui_0.8.6_amd64.deb',
|
||||
versions: [
|
||||
{ version: '0.8.6', releaseDate: '2026-05-08', uploadedBy: 'Linh Phạm', status: 'Latest', size: '62.4 MB', changeLog: 'Refine operator handoff screens.' },
|
||||
{ version: '0.8.3', releaseDate: '2026-04-19', uploadedBy: 'Linh Phạm', status: 'Deprecated', size: '59.9 MB', changeLog: 'Initial kiosk dashboard.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const applications = [
|
||||
{
|
||||
id: 'warehouse-basic',
|
||||
code: 'APP-WH-BASIC',
|
||||
name: 'Warehouse Basic',
|
||||
version: '1.2.0',
|
||||
status: 'Released',
|
||||
createdAt: '2026-05-18',
|
||||
createdBy: 'Dũng Tào',
|
||||
notes: 'Base package set for warehouse robots.',
|
||||
packages: [
|
||||
{ code: 'NAV-STACK', name: 'Navigation Stack', type: 'deb', selectedVersion: '2.4.1' },
|
||||
{ code: 'FLEET-AGENT', name: 'Fleet Agent', type: 'docker', selectedVersion: '1.9.0' },
|
||||
{ code: 'MAP-SYNC', name: 'Map Sync Service', type: 'docker', selectedVersion: '3.1.2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'kiosk-demo',
|
||||
code: 'APP-KIOSK-DEMO',
|
||||
name: 'Kiosk Demo',
|
||||
version: '0.4.0',
|
||||
status: 'Draft',
|
||||
createdAt: '2026-05-16',
|
||||
createdBy: 'Linh Phạm',
|
||||
notes: 'Demo bundle for touch kiosk testing.',
|
||||
packages: [
|
||||
{ code: 'UI-KIOSK', name: 'Robot Kiosk UI', type: 'deb', selectedVersion: '0.8.6' },
|
||||
{ code: 'NAV-STACK', name: 'Navigation Stack', type: 'deb', selectedVersion: '2.4.0' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fleet-edge',
|
||||
code: 'APP-FLEET-EDGE',
|
||||
name: 'Fleet Edge',
|
||||
version: '2.0.1',
|
||||
status: 'Released',
|
||||
createdAt: '2026-05-11',
|
||||
createdBy: 'Minh Anh',
|
||||
notes: 'Fleet manager edge runtime.',
|
||||
packages: [
|
||||
{ code: 'FLEET-AGENT', name: 'Fleet Agent', type: 'docker', selectedVersion: '1.9.0' },
|
||||
{ code: 'MAP-SYNC', name: 'Map Sync Service', type: 'docker', selectedVersion: '3.0.5' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const activity = [
|
||||
{ title: 'Upload version 2.4.1', detail: 'Navigation Stack được đặt là latest', time: '09:25', icon: 'upload_file' },
|
||||
{ title: 'Release Warehouse Basic', detail: 'App version 1.2.0 đã sẵn sàng đóng gói', time: 'Hôm qua', icon: 'task_alt' },
|
||||
{ title: 'Update Fleet Agent', detail: 'Thêm heartbeat metrics cho Docker image', time: '16/05', icon: 'sync' }
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
currentUser: {
|
||||
name: 'Dũng Tào',
|
||||
role: 'Admin',
|
||||
email: 'admin@robotics.local'
|
||||
},
|
||||
packages,
|
||||
applications,
|
||||
activity,
|
||||
stats: {
|
||||
totalPackages: packages.length,
|
||||
activePackages: packages.filter((item) => item.status === 'Active').length,
|
||||
totalVersions: packages.reduce((total, item) => total + item.versions.length, 0),
|
||||
totalApplications: applications.length,
|
||||
releasedApplications: applications.filter((item) => item.status === 'Released').length
|
||||
}
|
||||
};
|
||||
1231
web-server/src/repository.js
Normal file
1231
web-server/src/repository.js
Normal file
File diff suppressed because it is too large
Load Diff
120
web-server/views/application-detail.ejs
Normal file
120
web-server/views/application-detail.ejs
Normal 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') %>
|
||||
114
web-server/views/applications.ejs
Normal file
114
web-server/views/applications.ejs
Normal 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
97
web-server/views/auth.ejs
Normal file
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
<span>User Access</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (mode === 'login') { %>
|
||||
<div class="auth-heading">
|
||||
<h1>Đăng nhập</h1>
|
||||
<p>Truy cập console quản lý package và app.</p>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" method="post" action="/login">
|
||||
<input type="hidden" name="returnTo" value="<%= returnTo %>">
|
||||
<label class="form-field">
|
||||
<span>Username hoặc email</span>
|
||||
<input type="text" name="identifier" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Mật khẩu</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="btn btn-primary auth-submit" type="submit">
|
||||
<span class="material-symbols-outlined">login</span>
|
||||
Đăng nhập
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-switch">Chưa có tài khoản? <a href="/register">Đăng ký</a></p>
|
||||
<% } else { %>
|
||||
<div class="auth-heading">
|
||||
<h1>Đăng ký</h1>
|
||||
<p>App sẽ gửi email xác nhận để kích hoạt tài khoản.</p>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" method="post" action="/register" data-register-form>
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" value="<%= values.username || '' %>" autocomplete="username" required autofocus data-unique-check="username">
|
||||
<small class="field-feedback" data-unique-feedback="username"></small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Họ tên</span>
|
||||
<input type="text" name="fullName" value="<%= values.fullName || '' %>" autocomplete="name">
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" name="email" value="<%= values.email || '' %>" autocomplete="email" required data-unique-check="email">
|
||||
<small class="field-feedback" data-unique-feedback="email"></small>
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Mật khẩu</span>
|
||||
<input type="password" name="password" autocomplete="new-password" minlength="8" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Xác nhận mật khẩu</span>
|
||||
<input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary auth-submit" type="submit" data-register-submit>
|
||||
<span class="material-symbols-outlined">person_add</span>
|
||||
Tạo tài khoản
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-switch">Đã có tài khoản? <a href="/login">Đăng nhập</a></p>
|
||||
<% } %>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
105
web-server/views/builder.ejs
Normal file
105
web-server/views/builder.ejs
Normal 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') %>
|
||||
54
web-server/views/confirm-email-sent.ejs
Normal file
54
web-server/views/confirm-email-sent.ejs
Normal file
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
<span>Email Confirmation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-confirm-icon">
|
||||
<span class="material-symbols-outlined">mark_email_unread</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-heading">
|
||||
<h1>Kiểm tra email</h1>
|
||||
<p>Chúng tôi đã gửi link xác nhận tới email đăng ký. Tài khoản chỉ được kích hoạt sau khi bạn bấm link confirm.</p>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" method="post" action="/resend-confirmation">
|
||||
<label class="form-field">
|
||||
<span>Email đăng ký</span>
|
||||
<input type="email" name="email" value="<%= email %>" autocomplete="email" required>
|
||||
</label>
|
||||
<button class="btn btn-secondary auth-submit" type="submit">
|
||||
<span class="material-symbols-outlined">forward_to_inbox</span>
|
||||
Gửi lại email xác nhận
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-switch">Đã xác nhận? <a href="/login">Đăng nhập</a></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
122
web-server/views/dashboard.ejs
Normal file
122
web-server/views/dashboard.ejs
Normal file
@@ -0,0 +1,122 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Tổng quan</h1>
|
||||
<p>Theo dõi nhanh package, version mới nhất và các app đang được đóng gói.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-open="uploadPackageModal">
|
||||
<span class="material-symbols-outlined">upload_file</span>
|
||||
Upload package
|
||||
</button>
|
||||
<a class="btn btn-primary" href="/builder">
|
||||
<span class="material-symbols-outlined">add_box</span>
|
||||
Tạo App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-stats">
|
||||
<article class="metric-card">
|
||||
<span>Packages</span>
|
||||
<div>
|
||||
<strong><%= stats.totalPackages %></strong>
|
||||
<small><%= stats.activePackages %> active</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Versions</span>
|
||||
<div>
|
||||
<strong><%= stats.totalVersions %></strong>
|
||||
<small>latest tracking</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Applications</span>
|
||||
<div>
|
||||
<strong><%= stats.totalApplications %></strong>
|
||||
<small><%= stats.releasedApplications %> released</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Package types</span>
|
||||
<div>
|
||||
<strong>2</strong>
|
||||
<small>.deb + docker</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Package mới cập nhật</h2>
|
||||
<p>Hiển thị version mới nhất cho từng package.</p>
|
||||
</div>
|
||||
<a href="/packages" class="text-link">Xem tất cả</a>
|
||||
</div>
|
||||
<div class="table-wrap compact">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Type</th>
|
||||
<th>Latest</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (packages.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="4" class="table-empty">Chưa có package nào. Bấm Upload package để thêm dữ liệu đầu tiên.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% packages.slice(0, 4).forEach((item) => { %>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="table-title" href="/packages/<%= item.id %>"><%= item.name %></a>
|
||||
<span class="table-subtitle"><%= item.code %></span>
|
||||
</td>
|
||||
<td><span class="badge <%= helpers.packageTypeClass(item.type) %>"><%= helpers.packageTypeLabel(item.type) %></span></td>
|
||||
<td><%= item.latestVersion %></td>
|
||||
<td><span class="badge <%= helpers.statusClass(item.status) %>"><%= item.status %></span></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Hoạt động gần đây</h2>
|
||||
<p>Các thay đổi chính trong package/app.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-list">
|
||||
<% if (activity.length === 0) { %>
|
||||
<div class="table-empty">Chưa có hoạt động upload/update package.</div>
|
||||
<% } %>
|
||||
<% activity.forEach((item) => { %>
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">
|
||||
<span class="material-symbols-outlined"><%= item.icon %></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong><%= item.title %></strong>
|
||||
<span><%= item.detail %></span>
|
||||
</div>
|
||||
<time><%= item.time %></time>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('partials/package-modal') %>
|
||||
<%- include('partials/page-end') %>
|
||||
15
web-server/views/not-found.ejs
Normal file
15
web-server/views/not-found.ejs
Normal file
@@ -0,0 +1,15 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page center-page">
|
||||
<div class="empty-state">
|
||||
<span class="material-symbols-outlined">search_off</span>
|
||||
<h1>Không tìm thấy dữ liệu</h1>
|
||||
<p>Trang hoặc bản ghi bạn mở không tồn tại trong dữ liệu hiện tại.</p>
|
||||
<a class="btn btn-primary" href="/">
|
||||
<span class="material-symbols-outlined">dashboard</span>
|
||||
Về tổng quan
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
98
web-server/views/package-detail.ejs
Normal file
98
web-server/views/package-detail.ejs
Normal 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') %>
|
||||
108
web-server/views/packages.ejs
Normal file
108
web-server/views/packages.ejs
Normal 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') %>
|
||||
63
web-server/views/partials/edit-app-modal.ejs
Normal file
63
web-server/views/partials/edit-app-modal.ejs
Normal 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>
|
||||
78
web-server/views/partials/package-modal.ejs
Normal file
78
web-server/views/partials/package-modal.ejs
Normal file
@@ -0,0 +1,78 @@
|
||||
<div class="modal-backdrop" id="uploadPackageModal" role="dialog" aria-modal="true" aria-labelledby="uploadPackageTitle">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="uploadPackageTitle">Upload package</h3>
|
||||
<button class="icon-button subtle" type="button" aria-label="Đóng modal" data-modal-close>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" action="/packages" method="post" enctype="multipart/form-data">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Package code</span>
|
||||
<input type="text" name="packageCode" placeholder="NAV-STACK" 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>
|
||||
61
web-server/views/partials/page-end.ejs
Normal file
61
web-server/views/partials/page-end.ejs
Normal file
@@ -0,0 +1,61 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<% if (currentUser && currentUser.role === 'User') { %>
|
||||
<div id="profileModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="profileModalTitle">Thông tin cá nhân</h3>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" method="post" action="/profile" data-profile-form>
|
||||
<input type="hidden" name="returnTo" value="<%= currentPath || '/' %>">
|
||||
<div class="profile-summary">
|
||||
<div class="profile-avatar"><%= currentUser.name.charAt(0) %></div>
|
||||
<div>
|
||||
<strong><%= currentUser.name %></strong>
|
||||
<span><%= currentUser.username %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-stack">
|
||||
<label class="form-field">
|
||||
<span>Fullname</span>
|
||||
<input type="text" name="fullName" value="<%= currentUser.fullName || '' %>" autocomplete="name">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Email mới</span>
|
||||
<input type="email" name="email" value="<%= currentUser.email || '' %>" autocomplete="email" required data-profile-email>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Confirm email mới</span>
|
||||
<input type="email" name="confirmEmail" value="<%= currentUser.email || '' %>" autocomplete="email" required data-profile-confirm-email>
|
||||
<small class="field-feedback" data-profile-feedback="email"></small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Mật khẩu mới</span>
|
||||
<input type="password" name="newPassword" minlength="8" autocomplete="new-password" data-profile-new-password>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Xác nhận mật khẩu mới</span>
|
||||
<input type="password" name="confirmPassword" minlength="8" autocomplete="new-password" data-profile-confirm-password>
|
||||
<small class="field-feedback" data-profile-feedback="password"></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
87
web-server/views/partials/page-start.ejs
Normal file
87
web-server/views/partials/page-start.ejs
Normal file
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
<body class="app-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<aside id="appSidebar" class="sidebar" aria-label="Main navigation">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
<span>Package Console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-section" aria-label="Workspace">
|
||||
<span class="nav-label">Workspace</span>
|
||||
<% navItems.forEach((item) => { %>
|
||||
<a href="<%= item.href %>" class="nav-item <%= active === item.id ? 'active' : '' %>">
|
||||
<span class="material-symbols-outlined"><%= item.icon %></span>
|
||||
<span><%= item.label %></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot"></span>
|
||||
<div>
|
||||
<strong>SQL Server</strong>
|
||||
<span>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">
|
||||
67
web-server/views/partials/update-package-modal.ejs
Normal file
67
web-server/views/partials/update-package-modal.ejs
Normal 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
275
web-server/views/users.ejs
Normal file
@@ -0,0 +1,275 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Quản lý tài khoản đăng nhập, quyền Admin/User và trạng thái hoạt động.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<span class="badge badge-info"><%= users.length %> users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-layout">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Tạo user mới</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form class="user-create-form" method="post" action="/users">
|
||||
<div class="form-stack">
|
||||
<label class="form-field">
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="off" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Họ tên</span>
|
||||
<input type="text" name="fullName" autocomplete="off">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" name="email" autocomplete="off" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Role</span>
|
||||
<select name="role">
|
||||
<option value="User">User</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field full">
|
||||
<span>Mật khẩu tạm</span>
|
||||
<input type="password" name="password" minlength="8" autocomplete="new-password" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">person_add</span>
|
||||
Tạo user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="page-filters inline">
|
||||
<label class="filter-field">
|
||||
<span>Role</span>
|
||||
<select data-filter-select data-filter-column="role" data-filter-table="usersTable">
|
||||
<option value="">Tất cả</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="User">User</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span>Status</span>
|
||||
<select data-filter-select data-filter-column="status" data-filter-table="usersTable">
|
||||
<option value="">Tất cả</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field wide">
|
||||
<span>Search</span>
|
||||
<input type="search" placeholder="Tìm theo username, email, họ tên..." data-table-search="usersTable">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="usersTable" class="data-table users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Owned data</th>
|
||||
<th>Session</th>
|
||||
<th class="action-col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (users.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="8" class="table-empty">Chưa có user nào.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% users.forEach((user) => { %>
|
||||
<tr
|
||||
data-search="<%= `${user.username} ${user.email} ${user.fullName}`.toLowerCase() %>"
|
||||
data-role="<%= user.role %>"
|
||||
data-status="<%= user.status %>"
|
||||
data-user-id="<%= user.id %>"
|
||||
data-user-name="<%= user.name %>"
|
||||
data-user-username="<%= user.username %>"
|
||||
data-user-email="<%= user.email %>"
|
||||
data-user-full-name="<%= user.fullName %>"
|
||||
data-user-role="<%= user.role %>"
|
||||
data-user-status="<%= user.status %>"
|
||||
data-user-active="<%= user.isActive ? 'true' : 'false' %>"
|
||||
data-user-created-at="<%= user.createdAt %>"
|
||||
data-user-updated-at="<%= user.updatedAt %>"
|
||||
data-user-package-count="<%= user.packageCount %>"
|
||||
data-user-application-count="<%= user.applicationCount %>"
|
||||
>
|
||||
<td>
|
||||
<span class="table-title"><%= user.name %></span>
|
||||
<span class="table-subtitle"><%= user.username %></span>
|
||||
</td>
|
||||
<td><%= user.email %></td>
|
||||
<td><span class="badge <%= helpers.statusClass(user.role) %>"><%= user.role %></span></td>
|
||||
<td><span class="badge <%= helpers.statusClass(user.status) %>"><%= user.status %></span></td>
|
||||
<td><%= user.createdAt %></td>
|
||||
<td>
|
||||
<span class="table-subtitle"><%= user.packageCount %> packages</span>
|
||||
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
|
||||
</td>
|
||||
<td>
|
||||
<% if (user.id === currentUser.id) { %>
|
||||
<span class="badge badge-muted">Đang đăng nhập</span>
|
||||
<% } else { %>
|
||||
<span class="table-subtitle">-</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="action-col">
|
||||
<div class="user-actions">
|
||||
<button class="icon-button subtle" type="button" title="Xem user" aria-label="Xem user <%= user.username %>" data-user-view>
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
</button>
|
||||
<button class="icon-button subtle" type="button" title="Sửa user" aria-label="Sửa user <%= user.username %>" data-user-edit <%= user.id === currentUser.id ? 'data-current-user="true"' : '' %>>
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<% if (user.id !== currentUser.id) { %>
|
||||
<form method="post" action="/users/<%= user.id %>/delete" data-confirm-submit="Xóa user <%= user.username %>? Thao tác này không thể hoàn tác.">
|
||||
<button class="icon-button danger" type="submit" title="Xóa user" aria-label="Xóa user <%= user.username %>">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="page-pager">
|
||||
<span>Showing 1-<%= users.length %> of <%= users.length %></span>
|
||||
<div>
|
||||
<button type="button" disabled>Prev</button>
|
||||
<span>Page 1 / 1</span>
|
||||
<button type="button" disabled>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="userDetailModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="userDetailTitle">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="userDetailTitle">Thông tin user</h3>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-form">
|
||||
<dl class="detail-list user-detail-list">
|
||||
<div>
|
||||
<dt>Họ tên</dt>
|
||||
<dd data-user-detail="name"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Username</dt>
|
||||
<dd data-user-detail="username"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Email</dt>
|
||||
<dd data-user-detail="email"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mật khẩu</dt>
|
||||
<dd>Không hiển thị. Có thể đặt lại trong phần Sửa user.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Role</dt>
|
||||
<dd data-user-detail="role"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd data-user-detail="status"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd data-user-detail="createdAt"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd data-user-detail="updatedAt"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Owned data</dt>
|
||||
<dd data-user-detail="ownedData"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editUserModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="editUserTitle">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="editUserTitle">Sửa user</h3>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="editUserForm" class="modal-form" method="post" action="/users">
|
||||
<div class="form-stack">
|
||||
<label class="form-field">
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" required data-edit-user-field="username">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Họ tên</span>
|
||||
<input type="text" name="fullName" data-edit-user-field="fullName">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" name="email" required data-edit-user-field="email">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Role</span>
|
||||
<select name="role" data-edit-user-field="role">
|
||||
<option value="User">User</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Mật khẩu mới</span>
|
||||
<input type="password" name="newPassword" minlength="8" autocomplete="new-password" data-edit-user-field="newPassword">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Xác nhận mật khẩu mới</span>
|
||||
<input type="password" name="confirmPassword" minlength="8" autocomplete="new-password" data-edit-user-field="confirmPassword">
|
||||
</label>
|
||||
<label class="inline-checkbox edit-active-toggle">
|
||||
<input class="checkbox" type="checkbox" name="isActive" data-edit-user-field="isActive">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
Reference in New Issue
Block a user