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