fix server - agent

This commit is contained in:
2026-05-27 08:37:47 +07:00
parent 13765e58d2
commit b0443d5950
13 changed files with 170 additions and 11 deletions

View File

@@ -38,6 +38,7 @@ class TaskRunner:
manifest["appName"],
manifest["version"],
manifest_hash,
manifest.get("openUrl"),
)
self.repository.update_task(
task_id,

View File

@@ -181,6 +181,7 @@ class AppManifest(CamelModel):
app_id: str = Field(alias="appId")
app_name: str = Field(alias="appName")
version: str
open_url: str | None = Field(default=None, alias="openUrl")
architecture: str = "amd64"
components: list[dict[str, Any]]
signature: str | None = None

View File

@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS installed_apps (
app_id TEXT PRIMARY KEY,
app_name TEXT NOT NULL,
version TEXT NOT NULL,
open_url TEXT,
manifest_hash TEXT,
status TEXT NOT NULL,
installed_at TEXT NOT NULL,
@@ -86,6 +87,15 @@ CREATE TABLE IF NOT EXISTS agent_config (
"""
def _ensure_column(connection: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {
row["name"]
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
}
if column not in columns:
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def get_connection() -> sqlite3.Connection:
connection = sqlite3.connect(settings.db_path, timeout=30)
connection.row_factory = sqlite3.Row
@@ -99,4 +109,4 @@ def initialize_database() -> None:
settings.log_dir.mkdir(parents=True, exist_ok=True)
with get_connection() as connection:
connection.executescript(SCHEMA)
_ensure_column(connection, "installed_apps", "open_url", "TEXT")

View File

@@ -160,7 +160,7 @@ class Repository:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT app_id, app_name, version, manifest_hash, status, installed_at, updated_at
SELECT app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at
FROM installed_apps
ORDER BY app_name ASC
"""
@@ -173,22 +173,24 @@ class Repository:
app_name: str,
version: str,
manifest_hash: str | None,
open_url: str | None = None,
status: str = "installed",
) -> None:
now = utc_now()
with get_connection() as connection:
connection.execute(
"""
INSERT INTO installed_apps (app_id, app_name, version, manifest_hash, status, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO installed_apps (app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(app_id) DO UPDATE SET
app_name = excluded.app_name,
version = excluded.version,
open_url = excluded.open_url,
manifest_hash = excluded.manifest_hash,
status = excluded.status,
updated_at = excluded.updated_at
""",
(app_id, app_name, version, manifest_hash, status, now, now),
(app_id, app_name, version, open_url, manifest_hash, status, now, now),
)
def delete_installed_app(self, app_id: str) -> None:
@@ -252,4 +254,3 @@ class Repository:
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))

View File

@@ -125,6 +125,7 @@ CREATE TABLE dbo.Applications
Status NVARCHAR(50) NOT NULL
CONSTRAINT DF_Applications_Status DEFAULT N'Draft',
Notes NVARCHAR(500) NULL,
OpenUrl NVARCHAR(500) NULL,
CONSTRAINT FK_Applications_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Applications_Status CHECK (Status IN (N'Draft', N'Released', N'Archived')),

View File

@@ -76,6 +76,7 @@ SELECT
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
@@ -94,6 +95,7 @@ GROUP BY
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
@@ -108,6 +110,7 @@ SELECT
a.AppCode,
a.AppName,
a.AppVersion,
a.OpenUrl AS AppOpenUrl,
ap.PackageId,
p.PackageCode,
p.PackageName,

View File

@@ -412,6 +412,7 @@
name: trigger.dataset.appName || '',
version: trigger.dataset.appVersion || '',
status: trigger.dataset.appStatus || 'Draft',
openUrl: trigger.dataset.appOpenUrl || '',
notes: trigger.dataset.appNotes || '',
packages: parseJsonAttribute(trigger.dataset.appPackages, [])
};
@@ -446,6 +447,7 @@
setEditAppField(form, 'appName', app.name);
setEditAppField(form, 'appVersion', app.version);
setEditAppField(form, 'status', app.status);
setEditAppField(form, 'openUrl', app.openUrl);
setEditAppField(form, 'notes', app.notes);
form.querySelectorAll('[data-edit-app-package]').forEach((checkbox) => {

View File

@@ -844,6 +844,24 @@ function normalizeApplicationStatus(value) {
return ['Draft', 'Released', 'Archived'].includes(value) ? value : 'Draft';
}
function normalizeApplicationOpenUrl(value) {
const text = String(value || '').trim();
if (!text) return '';
const candidate = /^https?:\/\//i.test(text) ? text : `http://${text}`;
try {
const parsed = new URL(candidate);
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
return null;
}
return parsed.href.replace(/\/+$/, '');
} catch {
return null;
}
}
function isInstallerIdentifier(value) {
return installerIdentifierPattern.test(String(value || '').trim());
}
@@ -1297,7 +1315,8 @@ app.get('/api/apps', asyncRoute(async (req, res) => {
appName: application.name,
version: application.version,
status: application.status,
packageCount: application.packageCount
packageCount: application.packageCount,
openUrl: application.openUrl
}))
});
}));
@@ -1316,6 +1335,7 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
version: application.version,
status: application.status,
packageCount: application.packageCount,
openUrl: application.openUrl,
packages: application.packages
});
}));
@@ -1839,7 +1859,7 @@ app.get('/applications', asyncRoute(async (req, res) => {
app.get('/applications/export.csv', asyncRoute(async (req, res) => {
const applications = await repository.listApplications();
const rows = [
['AppCode', 'AppName', 'AppVersion', 'Status', 'CreatedAt', 'CreatedBy', 'PackageCount', 'Packages', 'Notes'],
['AppCode', 'AppName', 'AppVersion', 'Status', 'CreatedAt', 'CreatedBy', 'PackageCount', 'Packages', 'OpenUrl', 'Notes'],
...applications.map((application) => [
application.code,
application.name,
@@ -1849,6 +1869,7 @@ app.get('/applications/export.csv', asyncRoute(async (req, res) => {
application.createdBy,
application.packageCount,
application.packages.map((packageItem) => `${packageItem.code}@${packageItem.selectedVersion}`).join('; '),
application.openUrl,
application.notes
])
];
@@ -1860,12 +1881,18 @@ app.post('/applications', asyncRoute(async (req, res) => {
const appCode = String(req.body.appCode || '').trim();
const appName = String(req.body.appName || '').trim();
const appVersion = String(req.body.appVersion || '').trim();
const openUrl = normalizeApplicationOpenUrl(req.body.openUrl);
if (!appCode || !appName || !appVersion) {
redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.');
return;
}
if (openUrl === null) {
redirectWithNotice(res, '/builder', 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.');
return;
}
if (!isInstallerIdentifier(appCode)) {
redirectWithNotice(res, '/builder', 'warning', `App code khong hop le. ${installerIdentifierHint}`);
return;
@@ -1882,6 +1909,7 @@ app.post('/applications', asyncRoute(async (req, res) => {
appName,
appVersion,
notes: req.body.notes,
openUrl,
status: normalizeApplicationStatus(req.body.status),
createdByUserId: req.currentUser.id,
packages: getSelectedApplicationPackages(req.body)
@@ -1904,12 +1932,18 @@ app.post('/applications/:id/edit', asyncRoute(async (req, res) => {
const appCode = String(req.body.appCode || '').trim();
const appName = String(req.body.appName || '').trim();
const appVersion = String(req.body.appVersion || '').trim();
const openUrl = normalizeApplicationOpenUrl(req.body.openUrl);
if (!appCode || !appName || !appVersion) {
redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.');
return;
}
if (openUrl === null) {
redirectWithNotice(res, returnTo, 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.');
return;
}
if (!isInstallerIdentifier(appCode)) {
redirectWithNotice(res, returnTo, 'warning', `App code khong hop le. ${installerIdentifierHint}`);
return;
@@ -1927,6 +1961,7 @@ app.post('/applications/:id/edit', asyncRoute(async (req, res) => {
appName,
appVersion,
notes: req.body.notes,
openUrl,
status: normalizeApplicationStatus(req.body.status),
packages: getSelectedApplicationPackages(req.body)
});

View File

@@ -9,6 +9,7 @@ const PLACEHOLDER_PASSWORD_HASH = 'ui-placeholder-password-hash';
const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPIRES_MS || 1000 * 60 * 60 * 24);
let emailConfirmationSchemaPromise;
let applicationOpenUrlSchemaPromise;
function toDateInputValue(value) {
const date = value ? new Date(value) : new Date();
@@ -185,6 +186,86 @@ async function ensureEmailConfirmationSchema() {
return emailConfirmationSchemaPromise;
}
async function ensureApplicationOpenUrlSchema() {
if (!applicationOpenUrlSchemaPromise) {
applicationOpenUrlSchemaPromise = getPool().then((pool) => pool.request().query(`
IF COL_LENGTH(N'dbo.Applications', N'OpenUrl') IS NULL
BEGIN
ALTER TABLE dbo.Applications
ADD OpenUrl NVARCHAR(500) NULL;
END;
EXEC(N'
CREATE OR ALTER VIEW dbo.vw_ApplicationList
AS
SELECT
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username AS CreatedByUsername,
COUNT_BIG(ap.Id) AS PackageCount
FROM dbo.Applications AS a
INNER JOIN dbo.Users AS u
ON u.Id = a.CreatedByUserId
LEFT JOIN dbo.ApplicationPackages AS ap
ON ap.ApplicationId = a.Id
GROUP BY
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username;
');
EXEC(N'
CREATE OR ALTER VIEW dbo.vw_ApplicationPackageDetails
AS
SELECT
ap.Id,
ap.ApplicationId,
a.AppCode,
a.AppName,
a.AppVersion,
a.OpenUrl AS AppOpenUrl,
ap.PackageId,
p.PackageCode,
p.PackageName,
p.PackageType,
ap.SelectedVersionId,
pv.Version AS SelectedVersion,
pv.FilePath,
pv.DockerImage,
ap.AddedAt,
ap.Notes
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a
ON a.Id = ap.ApplicationId
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS pv
ON pv.Id = ap.SelectedVersionId;
');
`));
}
return applicationOpenUrlSchemaPromise;
}
function normalizePackageStatus(isActive) {
return isActive ? 'Active' : 'Archived';
}
@@ -242,6 +323,7 @@ function mapApplicationRow(row) {
createdAt: formatDate(row.CreatedAt),
createdBy: row.CreatedByUsername || '',
notes: row.Notes || row.Description || '',
openUrl: row.OpenUrl || '',
packageCount: Number(row.PackageCount || 0),
packages: []
};
@@ -251,6 +333,7 @@ function mapApplicationPackageRow(row) {
return {
id: String(row.Id),
applicationId: String(row.ApplicationId),
appOpenUrl: row.AppOpenUrl || '',
packageId: String(row.PackageId),
code: row.PackageCode,
name: row.PackageName,
@@ -759,6 +842,7 @@ async function getPackageById(id) {
}
async function listApplications() {
await ensureApplicationOpenUrlSchema();
const pool = await getPool();
const result = await pool.request().query(`
SELECT *
@@ -771,6 +855,7 @@ async function listApplications() {
}
async function listApplicationPackages(applicationId) {
await ensureApplicationOpenUrlSchema();
const pool = await getPool();
const request = pool.request();
let whereClause = '';
@@ -816,6 +901,7 @@ async function attachApplicationPackages(applications) {
}
async function getApplicationById(id) {
await ensureApplicationOpenUrlSchema();
const pool = await getPool();
const appResult = await pool.request()
.input('Id', sql.NVarChar(100), id)
@@ -834,12 +920,13 @@ async function getApplicationById(id) {
}
async function getApplicationManifest(appCode, version, baseUrl) {
await ensureApplicationOpenUrlSchema();
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
SELECT TOP (1) Id, AppCode, AppName, AppVersion, OpenUrl
FROM dbo.Applications
WHERE (CONVERT(NVARCHAR(36), Id) = @AppCode OR AppCode = @AppCode)
AND AppVersion = @AppVersion
@@ -911,6 +998,7 @@ async function getApplicationManifest(appCode, version, baseUrl) {
appId: String(appRow.Id),
appName: appRow.AppName,
version: appRow.AppVersion,
openUrl: appRow.OpenUrl || '',
architecture: 'amd64',
components
};
@@ -1199,6 +1287,7 @@ async function deletePackageVersion(packageVersionId) {
}
async function createApplication(input) {
await ensureApplicationOpenUrlSchema();
const pool = await getPool();
const transaction = new sql.Transaction(pool);
@@ -1210,12 +1299,13 @@ async function createApplication(input) {
.input('AppName', sql.NVarChar(200), input.appName)
.input('AppVersion', sql.NVarChar(50), input.appVersion)
.input('Notes', sql.NVarChar(500), input.notes || null)
.input('OpenUrl', sql.NVarChar(500), input.openUrl || null)
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, CreatedByUserId, Status)
INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, OpenUrl, CreatedByUserId, Status)
OUTPUT inserted.Id
VALUES (@AppCode, @AppName, @AppVersion, @Notes, @CreatedByUserId, @Status);
VALUES (@AppCode, @AppName, @AppVersion, @Notes, @OpenUrl, @CreatedByUserId, @Status);
`);
const applicationId = String(appResult.recordset[0].Id);
@@ -1244,6 +1334,7 @@ async function createApplication(input) {
}
async function updateApplication(input) {
await ensureApplicationOpenUrlSchema();
const pool = await getPool();
const transaction = new sql.Transaction(pool);
@@ -1270,6 +1361,7 @@ async function updateApplication(input) {
.input('AppName', sql.NVarChar(200), input.appName)
.input('AppVersion', sql.NVarChar(50), input.appVersion)
.input('Notes', sql.NVarChar(500), input.notes || null)
.input('OpenUrl', sql.NVarChar(500), input.openUrl || null)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
UPDATE dbo.Applications
@@ -1277,6 +1369,7 @@ async function updateApplication(input) {
AppName = @AppName,
AppVersion = @AppVersion,
Notes = @Notes,
OpenUrl = @OpenUrl,
Status = @Status,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id

View File

@@ -17,6 +17,7 @@
data-app-name="<%= application.name %>"
data-app-version="<%= application.version %>"
data-app-status="<%= application.status %>"
data-app-open-url="<%= application.openUrl %>"
data-app-notes="<%= application.notes %>"
data-app-packages="<%= JSON.stringify(application.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>"
>
@@ -53,6 +54,7 @@
<div><dt>Package count</dt><dd><%= application.packageCount %></dd></div>
<div><dt>Created date</dt><dd><%= application.createdAt %></dd></div>
<div><dt>Created by</dt><dd><%= application.createdBy %></dd></div>
<div><dt>Open URL</dt><dd class="mono"><%= application.openUrl || '-' %></dd></div>
<div><dt>Status</dt><dd><span class="badge <%= helpers.statusClass(application.status) %>"><%= application.status %></span></dd></div>
</dl>
</section>
@@ -95,6 +97,7 @@
data-app-name="<%= application.name %>"
data-app-version="<%= application.version %>"
data-app-status="<%= application.status %>"
data-app-open-url="<%= application.openUrl %>"
data-app-notes="<%= application.notes %>"
data-app-packages="<%= JSON.stringify(application.packages.map((item) => ({ packageId: item.packageId, selectedVersionId: item.selectedVersionId }))) %>"
>

View File

@@ -82,6 +82,7 @@
data-app-name="<%= item.name %>"
data-app-version="<%= item.version %>"
data-app-status="<%= item.status %>"
data-app-open-url="<%= item.openUrl %>"
data-app-notes="<%= item.notes %>"
data-app-packages="<%= JSON.stringify(item.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>"
>

View File

@@ -38,6 +38,10 @@
<span>App name</span>
<input type="text" name="appName" required>
</label>
<label class="form-field full">
<span>Open URL</span>
<input type="text" name="openUrl" placeholder="http://127.0.0.1:5000">
</label>
<label class="form-field full">
<span>Notes</span>
<textarea name="notes"></textarea>

View File

@@ -29,6 +29,10 @@
<span>App name</span>
<input type="text" name="appName" required data-edit-app-field="appName">
</label>
<label class="form-field full">
<span>Open URL</span>
<input type="text" name="openUrl" placeholder="http://127.0.0.1:5000" data-edit-app-field="openUrl">
</label>
<label class="form-field full">
<span>Notes</span>
<textarea name="notes" data-edit-app-field="notes"></textarea>