This commit is contained in:
2026-05-22 16:47:51 +07:00
parent 190d2418da
commit 582960cc32
39 changed files with 2307 additions and 2 deletions

View File

@@ -828,7 +828,8 @@ tbody tr:hover td.action-col {
}
.detail-grid,
.builder-layout {
.builder-layout,
.agent-layout {
display: grid;
flex: 1;
gap: 16px;
@@ -836,6 +837,10 @@ tbody tr:hover td.action-col {
min-height: 0;
}
.agent-layout {
grid-template-columns: 380px minmax(0, 1fr);
}
.wide-panel {
min-width: 0;
}
@@ -890,10 +895,35 @@ tbody tr:hover td.action-col {
}
.builder-layout .panel .form-grid,
.builder-layout .panel .form-stack {
.builder-layout .panel .form-stack,
.agent-upload-form .form-grid {
padding: 16px;
}
.agent-upload-form {
overflow: auto;
}
.agent-upload-form .modal-actions {
padding: 0 16px 16px;
}
.agent-command-list {
border-top: 1px solid #eef2f7;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.agent-command-list input {
font-size: 11px;
}
.agent-table {
min-width: 920px;
}
.checkbox {
accent-color: var(--primary);
height: 16px;
@@ -1360,6 +1390,7 @@ tbody tr:hover td.action-col {
.dashboard-grid,
.detail-grid,
.builder-layout,
.agent-layout,
.users-layout {
grid-template-columns: 1fr;
}
@@ -1370,6 +1401,7 @@ tbody tr:hover td.action-col {
.detail-grid,
.builder-layout,
.agent-layout,
.users-layout {
overflow: auto;
}

View File

@@ -14,17 +14,24 @@ const notiflixVersion = require('notiflix/package.json').version;
const app = express();
const port = Number(process.env.PORT || 3000);
const uploadDir = path.join(__dirname, 'uploads', 'packages');
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
const authCookieName = 'robot_installer_session';
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8);
const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret';
const agentVersionCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base'
});
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(agentPackageDir, { recursive: true });
const navItems = [
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
];
@@ -57,6 +64,21 @@ const storage = multer.diskStorage({
}
});
const agentStorage = multer.diskStorage({
destination: agentPackageDir,
filename(req, file, callback) {
const safeName = file.originalname
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
callback(null, `${suffix}-${safeName}`);
}
});
const upload = multer({
storage,
limits: {
@@ -64,12 +86,46 @@ const upload = multer({
}
});
const agentUpload = multer({
storage: agentStorage,
limits: {
fileSize: Number(process.env.AGENT_MAX_UPLOAD_BYTES || process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
}
});
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
const arch = normalizeAgentArch(req.query.arch);
const latestPackage = await findLatestAgentPackage(arch);
if (latestPackage) {
res.type('application/vnd.debian.binary-package');
res.setHeader('Content-Disposition', `attachment; filename="${latestPackage.fileName}"`);
res.setHeader('X-Agent-Version', latestPackage.version);
res.sendFile(latestPackage.filePath);
return;
}
const latestPackageRecord = await findLatestAgentPackageRecord(arch);
if (latestPackageRecord) {
res.setHeader('X-Agent-Version', latestPackageRecord.version);
res.redirect(latestPackageRecord.filePath);
return;
}
res.status(404).type('text/plain').send(
`No Local Installer Agent package found${arch ? ` for ${arch}` : ''}. ` +
'Upload local-installer-agent_<version>_<arch>.deb to web-server/uploads/packages/agent, ' +
'or upload it as package code local-installer-agent.'
);
}));
app.use('/uploads/packages', express.static(uploadDir));
app.use('/packages/agent', express.static(agentPackageDir));
app.use(loadCurrentUser);
function helpers() {
@@ -281,6 +337,139 @@ function getBaseUrl(req) {
return `${protocol}://${req.get('host')}`;
}
function normalizeAgentArch(value) {
const arch = String(value || '').trim().toLowerCase();
return /^[a-z0-9][a-z0-9._-]*$/.test(arch) ? arch : '';
}
function isValidAgentVersion(value) {
return /^[a-zA-Z0-9][a-zA-Z0-9._+~=-]*$/.test(String(value || '').trim());
}
function compareAgentPackages(first, second) {
const versionCompare = agentVersionCollator.compare(first.version, second.version);
if (versionCompare !== 0) return versionCompare;
return first.mtimeMs - second.mtimeMs;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
const units = ['KB', 'MB', 'GB'];
let size = value / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
function formatLocalDateTime(value) {
return new Intl.DateTimeFormat('vi-VN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(value);
}
function getAgentPackageDownloadPath(fileName) {
return `/packages/agent/${encodeURIComponent(fileName)}`;
}
async function getAgentPackageFromEntry(entry) {
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name);
if (!match) return null;
const [, version, packageArch] = match;
const filePath = path.join(agentPackageDir, entry.name);
const stat = await fsp.stat(filePath);
return {
fileName: entry.name,
filePath,
version,
arch: packageArch,
size: stat.size,
sizeLabel: formatBytes(stat.size),
uploadedAt: formatLocalDateTime(stat.mtime),
mtimeMs: stat.mtimeMs,
downloadPath: getAgentPackageDownloadPath(entry.name)
};
}
async function listAgentPackages(arch = '') {
let dirEntries;
try {
dirEntries = await fsp.readdir(agentPackageDir, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
const packages = (await Promise.all(
dirEntries
.filter((entry) => entry.isFile())
.map(getAgentPackageFromEntry)
))
.filter(Boolean)
.filter((packageItem) => !arch || packageItem.arch.toLowerCase() === arch);
const latestByArch = new Map();
packages
.sort(compareAgentPackages);
packages.forEach((packageItem) => {
latestByArch.set(packageItem.arch.toLowerCase(), packageItem.fileName);
});
return packages
.sort((first, second) => compareAgentPackages(second, first))
.map((packageItem) => ({
...packageItem,
isLatestForArch: latestByArch.get(packageItem.arch.toLowerCase()) === packageItem.fileName
}));
}
async function findLatestAgentPackage(arch = '') {
const packages = await listAgentPackages(arch);
const sortedPackages = packages
.slice()
.sort(compareAgentPackages);
return sortedPackages[sortedPackages.length - 1] || null;
}
function packageVersionMatchesArch(version, arch) {
if (!arch) return true;
return String(version.filePath || '').toLowerCase().includes(`_${arch}.deb`);
}
async function findLatestAgentPackageRecord(arch = '') {
try {
const packageItem = await repository.getPackageById('local-installer-agent');
const latestVersion = packageItem?.versions
?.filter((version) => version.filePath && packageVersionMatchesArch(version, arch))
?.[0];
if (!latestVersion) return null;
return {
filePath: latestVersion.filePath,
version: latestVersion.version
};
} catch (error) {
console.warn('Cannot read local-installer-agent package from database:', error.message);
return null;
}
}
async function loadCurrentUser(req, res, next) {
try {
const cookies = parseCookies(req.headers.cookie);
@@ -652,9 +841,143 @@ app.post('/logout', (req, res) => {
redirectWithNotice(res, '/login', 'success', 'Đã đăng xuất.');
});
app.get('/api/apps', asyncRoute(async (req, res) => {
const applications = await repository.listApplications();
res.json({
apps: applications
.filter((application) => application.status === 'Released')
.map((application) => ({
appId: application.code,
appName: application.name,
version: application.version,
status: application.status,
packageCount: application.packageCount
}))
});
}));
app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
const application = await repository.getApplicationById(req.params.appCode);
if (!application || application.status !== 'Released') {
res.status(404).json({ error: 'Application not found' });
return;
}
res.json({
appId: application.code,
appName: application.name,
version: application.version,
status: application.status,
packageCount: application.packageCount,
packages: application.packages
});
}));
app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req, res) => {
const manifest = await repository.getApplicationManifest(
req.params.appCode,
req.params.version,
getBaseUrl(req)
);
if (!manifest) {
res.status(404).json({ error: 'Application manifest not found' });
return;
}
res.json(manifest);
}));
app.get('/install-agent.sh', (req, res) => {
const baseUrl = getBaseUrl(req);
const agentUrl = `${baseUrl}/packages/agent/latest.deb`;
res.type('text/x-shellscript').send(`#!/usr/bin/env bash
set -euo pipefail
ARCH="$(dpkg --print-architecture)"
AGENT_URL="${agentUrl}?arch=$ARCH"
TMP_DEB="/tmp/local-installer-agent.deb"
echo "Downloading Local Installer Agent..."
curl -fL "$AGENT_URL" -o "$TMP_DEB"
echo "Installing Local Installer Agent..."
apt install -y "$TMP_DEB"
echo "Starting Local Installer Agent..."
systemctl enable local-installer-agent
systemctl restart local-installer-agent
echo "Checking Agent..."
curl -fsSL http://127.0.0.1:5010/health
echo ""
echo "Local Installer Agent installed successfully."
`);
});
app.use(requireAuthenticated);
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
const pageData = await repository.getPageData(req.currentUser);
const agentPackages = await listAgentPackages();
const preferredArch = normalizeAgentArch(req.query.arch) || 'amd64';
const latestAgentPackage = await findLatestAgentPackage(preferredArch);
const baseUrl = getBaseUrl(req);
res.render('agent', viewModel(req, 'agent', 'Agent packages', pageData, {
agentPackages,
latestAgentPackage,
agentPackageDir,
preferredArch,
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`,
latestAgentUrl: `${baseUrl}/packages/agent/latest.deb?arch=${preferredArch}`
}));
}));
app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), asyncRoute(async (req, res) => {
const version = String(req.body.version || '').trim();
const arch = normalizeAgentArch(req.body.arch);
try {
if (!req.file || !version || !arch) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/agent', 'warning', 'Vui lòng chọn file .deb, nhập version và architecture.');
return;
}
if (!isValidAgentVersion(version)) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/agent', 'warning', 'Version chỉ nên chứa chữ, số, dấu chấm, gạch ngang hoặc gạch dưới.');
return;
}
if (path.extname(req.file.originalname).toLowerCase() !== '.deb') {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/agent', 'warning', 'Agent package phải là file .deb.');
return;
}
const targetFileName = `local-installer-agent_${version}_${arch}.deb`;
const targetPath = path.join(agentPackageDir, targetFileName);
const isUpdate = fs.existsSync(targetPath);
await fsp.mkdir(agentPackageDir, { recursive: true });
await fsp.rename(req.file.path, targetPath);
redirectWithNotice(
res,
'/agent',
'success',
isUpdate ? 'Đã cập nhật Agent package.' : 'Đã upload Agent package mới.'
);
} catch (error) {
await removeUploadedFile(req.file);
throw error;
}
}));
app.post('/profile', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo);
const fullName = String(req.body.fullName || '').trim();

View File

@@ -262,6 +262,16 @@ function mapApplicationPackageRow(row) {
};
}
function toAbsoluteUrl(baseUrl, filePath) {
if (!filePath) return '';
if (/^https?:\/\//i.test(filePath)) return filePath;
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
return `${normalizedBaseUrl}${normalizedPath}`;
}
async function getUserById(id) {
const pool = await getPool();
const result = await pool.request()
@@ -806,6 +816,88 @@ async function getApplicationById(id) {
return application;
}
async function getApplicationManifest(appCode, version, baseUrl) {
const pool = await getPool();
const appResult = await pool.request()
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
.input('AppVersion', sql.NVarChar(50), String(version || '').trim())
.query(`
SELECT TOP (1) Id, AppCode, AppName, AppVersion
FROM dbo.Applications
WHERE AppCode = @AppCode
AND AppVersion = @AppVersion
AND Status = N'Released';
`);
const appRow = appResult.recordset[0];
if (!appRow) return null;
const componentResult = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, appRow.Id)
.query(`
SELECT
ap.Id,
p.PackageCode,
p.PackageName,
p.PackageType,
COALESCE(selected_version.Version, latest_version.Version) AS Version,
COALESCE(selected_version.FilePath, latest_version.FilePath) AS FilePath,
COALESCE(selected_version.DockerImage, latest_version.DockerImage) AS DockerImage,
COALESCE(selected_version.FileChecksumSha256, latest_version.FileChecksumSha256) AS FileChecksumSha256,
ROW_NUMBER() OVER (ORDER BY ap.AddedAt ASC, p.PackageCode ASC) * 10 AS InstallOrder
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS selected_version
ON selected_version.Id = ap.SelectedVersionId
OUTER APPLY (
SELECT TOP (1) latest.*
FROM dbo.PackageVersions AS latest
WHERE latest.PackageId = p.Id
AND latest.IsLatest = 1
ORDER BY latest.ReleaseDate DESC, latest.UploadedAt DESC
) AS latest_version
WHERE ap.ApplicationId = @ApplicationId
ORDER BY ap.AddedAt ASC, p.PackageCode ASC;
`);
const components = componentResult.recordset.map((row) => {
const installOrder = Number(row.InstallOrder || 10);
if (row.PackageType === 'docker') {
return {
componentId: row.PackageCode,
type: 'docker',
installOrder,
required: true,
image: row.DockerImage || '',
tag: row.Version || 'latest',
containerName: row.PackageCode
};
}
return {
componentId: row.PackageCode,
type: 'deb',
installOrder,
required: true,
packageName: row.PackageCode,
version: row.Version || '',
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
sha256: row.FileChecksumSha256 || ''
};
});
return {
schemaVersion: '1.0',
appId: appRow.AppCode,
appName: appRow.AppName,
version: appRow.AppVersion,
architecture: 'amd64',
components
};
}
async function getStats() {
const pool = await getPool();
const result = await pool.request().query(`
@@ -1216,6 +1308,7 @@ module.exports = {
getPageData,
listPackages,
listApplications,
getApplicationManifest,
getPackageById,
getApplicationById,
createPackageWithVersion,

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

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