laster 0.0.1
This commit is contained in:
@@ -33,6 +33,7 @@ class Downloader:
|
||||
self.repository.add_log(self.task_id, "info", f"Downloading {url}")
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response:
|
||||
response.raise_for_status()
|
||||
self._validate_response(url, response)
|
||||
with destination.open("wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
@@ -40,3 +41,13 @@ class Downloader:
|
||||
self.repository.add_log(self.task_id, "info", f"Downloaded to {destination}")
|
||||
return destination
|
||||
|
||||
def _validate_response(self, requested_url: str, response: httpx.Response) -> None:
|
||||
final_url = str(response.url)
|
||||
validate_url_host(final_url, settings.allowed_download_hosts)
|
||||
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type in {"text/html", "text/plain"}:
|
||||
raise ValueError(
|
||||
"download did not return a package file "
|
||||
f"(requested {requested_url}, final {final_url}, content-type {content_type or 'unknown'})"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import traceback
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.core.checksum import verify_sha256
|
||||
from app.core.checksum import sha256_file
|
||||
from app.core.command_runner import CommandRunner
|
||||
from app.core.downloader import Downloader
|
||||
from app.core.installer import DebInstaller
|
||||
@@ -197,8 +197,12 @@ class TaskRunner:
|
||||
|
||||
package_path = downloader.download(component["downloadUrl"])
|
||||
self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum")
|
||||
if not verify_sha256(package_path, component["sha256"]):
|
||||
raise ValueError(f"Checksum mismatch for {component_id}")
|
||||
actual_sha256 = sha256_file(package_path)
|
||||
expected_sha256 = component["sha256"].lower()
|
||||
if actual_sha256.lower() != expected_sha256:
|
||||
raise ValueError(
|
||||
f"Checksum mismatch for {component_id}: expected {expected_sha256}, got {actual_sha256}"
|
||||
)
|
||||
self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}")
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package")
|
||||
@@ -245,4 +249,3 @@ class TaskRunner:
|
||||
geteuid = getattr(os, "geteuid", None)
|
||||
if callable(geteuid) and geteuid() != 0:
|
||||
raise PermissionError("Agent must run as root to call apt and systemctl")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Package: local-installer-agent
|
||||
Version: 0.1.0
|
||||
Version: 0.1.3
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:-0.1.0}"
|
||||
VERSION="${VERSION:-0.1.3}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
PKG_NAME="local-installer-agent"
|
||||
BUILD_ROOT="build"
|
||||
BUILD_ROOT="${BUILD_ROOT:-build}"
|
||||
BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}"
|
||||
OUTPUT_PACKAGE="${BUILD_DIR}.deb"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[a-zA-Z0-9][a-zA-Z0-9._:+~=-]*$ ]]; then
|
||||
echo "Invalid VERSION: ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
echo "Invalid ARCH: ${ARCH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "${BUILD_ROOT}"
|
||||
|
||||
@@ -25,9 +36,15 @@ cp packaging/DEBIAN/postinst "${BUILD_DIR}/DEBIAN/postinst"
|
||||
cp packaging/DEBIAN/prerm "${BUILD_DIR}/DEBIAN/prerm"
|
||||
cp packaging/DEBIAN/postrm "${BUILD_DIR}/DEBIAN/postrm"
|
||||
|
||||
sed -i \
|
||||
-e "s/^Version:.*/Version: ${VERSION}/" \
|
||||
-e "s/^Architecture:.*/Architecture: ${ARCH}/" \
|
||||
"${BUILD_DIR}/DEBIAN/control"
|
||||
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/postinst"
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/prerm"
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/postrm"
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN"
|
||||
|
||||
cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" <<EOF
|
||||
AGENT_VERSION=${VERSION}
|
||||
@@ -47,7 +64,14 @@ ALLOW_DOCKER=false
|
||||
ALLOW_DOCKER_COMPOSE=false
|
||||
EOF
|
||||
|
||||
dpkg-deb --build "${BUILD_DIR}"
|
||||
dpkg-deb --root-owner-group --build "${BUILD_DIR}"
|
||||
|
||||
echo "Built package:"
|
||||
echo "${BUILD_DIR}.deb"
|
||||
echo "${OUTPUT_PACKAGE}"
|
||||
|
||||
if [ -n "${PUBLISH_DIR:-}" ]; then
|
||||
mkdir -p "${PUBLISH_DIR}"
|
||||
cp "${OUTPUT_PACKAGE}" "${PUBLISH_DIR}/"
|
||||
echo "Published package:"
|
||||
echo "${PUBLISH_DIR}/$(basename "${OUTPUT_PACKAGE}")"
|
||||
fi
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -10,45 +10,50 @@ server {
|
||||
location = /api {
|
||||
proxy_pass ${PACKAGE_PROXY_TARGET};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location ^~ /api/ {
|
||||
proxy_pass ${PACKAGE_PROXY_TARGET};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location = /install-agent.sh {
|
||||
proxy_pass ${PACKAGE_PROXY_TARGET};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass ${PACKAGE_PROXY_TARGET};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location ^~ /packages/ {
|
||||
proxy_pass ${PACKAGE_PROXY_TARGET};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
fetchApplicationDetail,
|
||||
fetchApplicationManifest,
|
||||
fetchInstalledApps,
|
||||
fetchLatestAgentPackage,
|
||||
fetchPackageApps,
|
||||
fetchTaskComponents,
|
||||
fetchTaskLogs,
|
||||
fetchTaskStatus,
|
||||
joinUrl,
|
||||
normalizeUrl,
|
||||
@@ -44,6 +47,22 @@ import './styles.css';
|
||||
|
||||
const SETTINGS_KEY = 'robot-installer-client-settings';
|
||||
const TERMINAL_TASK_STATUSES = new Set(['success', 'failed', 'cancelled']);
|
||||
const TASK_STATUS_TONES = {
|
||||
queued: 'info',
|
||||
running: 'info',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
cancelled: 'warning'
|
||||
};
|
||||
const TASK_ACTION_LABELS = {
|
||||
install: 'Installing',
|
||||
update: 'Updating',
|
||||
remove: 'Removing'
|
||||
};
|
||||
const AGENT_VERSION_COLLATOR = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
|
||||
function readSettings() {
|
||||
try {
|
||||
@@ -68,11 +87,64 @@ function getErrorMessage(error) {
|
||||
return error instanceof Error ? error.message : String(error || 'Có lỗi xảy ra');
|
||||
}
|
||||
|
||||
function copyTextFallback(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function isTaskActive(task) {
|
||||
return Boolean(task?.taskId && !TERMINAL_TASK_STATUSES.has(task.status));
|
||||
}
|
||||
|
||||
function clampProgress(value) {
|
||||
return Math.max(0, Math.min(100, Number(value) || 0));
|
||||
}
|
||||
|
||||
function statusBadgeClass(status) {
|
||||
const tone = TASK_STATUS_TONES[status] || 'info';
|
||||
return `badge badge-${tone}`;
|
||||
}
|
||||
|
||||
function formatTaskTime(value) {
|
||||
if (!value) return '--';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function compareAgentVersions(currentVersion, latestVersion) {
|
||||
const current = String(currentVersion || '').trim();
|
||||
const latest = String(latestVersion || '').trim();
|
||||
if (!current && !latest) return 0;
|
||||
if (!current) return -1;
|
||||
if (!latest) return 1;
|
||||
return AGENT_VERSION_COLLATOR.compare(current, latest);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [settings, setSettings] = useState(readSettings);
|
||||
const [draftSettings, setDraftSettings] = useState(settings);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [installedApps, setInstalledApps] = useState([]);
|
||||
const [latestAgentPackage, setLatestAgentPackage] = useState(null);
|
||||
const [agentHealth, setAgentHealth] = useState(null);
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
const [packageStatus, setPackageStatus] = useState({ state: 'idle', message: '' });
|
||||
@@ -90,6 +162,7 @@ function App() {
|
||||
const packageBaseUrl = settings.packageBaseUrl;
|
||||
const agentBaseUrl = settings.agentBaseUrl;
|
||||
const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`;
|
||||
const agentCommand = latestAgentPackage?.installCommand || installCommand;
|
||||
|
||||
const installedByAppId = useMemo(() => {
|
||||
return new Map(installedApps.map((app) => [app.appId, app]));
|
||||
@@ -97,7 +170,7 @@ function App() {
|
||||
|
||||
const mergedApps = useMemo(() => {
|
||||
return apps.map((app) => {
|
||||
const installed = installedByAppId.get(app.appId);
|
||||
const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode);
|
||||
const isInstalled = Boolean(installed);
|
||||
const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version);
|
||||
|
||||
@@ -115,6 +188,7 @@ function App() {
|
||||
return mergedApps.filter((app) => {
|
||||
const matchesQuery = !needle || [
|
||||
app.appId,
|
||||
app.appCode,
|
||||
app.appName,
|
||||
app.version,
|
||||
app.status
|
||||
@@ -132,6 +206,14 @@ function App() {
|
||||
return mergedApps.find((app) => app.appId === selectedAppId) || mergedApps[0] || null;
|
||||
}, [mergedApps, selectedAppId]);
|
||||
|
||||
const agentNeedsUpdate = useMemo(() => {
|
||||
return Boolean(
|
||||
agentHealth?.agentVersion
|
||||
&& latestAgentPackage?.version
|
||||
&& compareAgentVersions(agentHealth.agentVersion, latestAgentPackage.version) < 0
|
||||
);
|
||||
}, [agentHealth?.agentVersion, latestAgentPackage?.version]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
available: apps.length,
|
||||
@@ -148,13 +230,19 @@ function App() {
|
||||
const refreshPackage = useCallback(async () => {
|
||||
setPackageStatus({ state: 'loading', message: 'Đang tải app từ package server' });
|
||||
try {
|
||||
const nextApps = await fetchPackageApps(packageBaseUrl);
|
||||
const [nextApps, nextAgentPackage] = await Promise.all([
|
||||
fetchPackageApps(packageBaseUrl),
|
||||
fetchLatestAgentPackage(packageBaseUrl).catch(() => null)
|
||||
]);
|
||||
setApps(nextApps);
|
||||
setPackageStatus({ state: 'success', message: `${nextApps.length} app released` });
|
||||
setLatestAgentPackage(nextAgentPackage);
|
||||
const agentNote = nextAgentPackage?.version ? ` · Agent ${nextAgentPackage.version}` : '';
|
||||
setPackageStatus({ state: 'success', message: `${nextApps.length} app released${agentNote}` });
|
||||
return nextApps;
|
||||
} catch (error) {
|
||||
setPackageStatus({ state: 'danger', message: getErrorMessage(error) });
|
||||
setApps([]);
|
||||
setLatestAgentPackage(null);
|
||||
return [];
|
||||
}
|
||||
}, [packageBaseUrl]);
|
||||
@@ -211,24 +299,57 @@ function App() {
|
||||
|
||||
const loadTaskSnapshot = useCallback(async (taskId) => {
|
||||
try {
|
||||
const nextTask = await fetchTaskStatus(agentBaseUrl, taskId);
|
||||
const [nextTask, logs, components] = await Promise.all([
|
||||
fetchTaskStatus(agentBaseUrl, taskId),
|
||||
fetchTaskLogs(agentBaseUrl, taskId).catch(() => []),
|
||||
fetchTaskComponents(agentBaseUrl, taskId).catch(() => [])
|
||||
]);
|
||||
const snapshot = {
|
||||
...nextTask,
|
||||
logs,
|
||||
components,
|
||||
pollError: ''
|
||||
};
|
||||
|
||||
if (TERMINAL_TASK_STATUSES.has(nextTask.status)) {
|
||||
setActiveTask((current) => {
|
||||
if (!current || current.taskId !== taskId) return current;
|
||||
return { ...current, ...snapshot };
|
||||
});
|
||||
|
||||
if (TERMINAL_TASK_STATUSES.has(snapshot.status)) {
|
||||
await refreshAgent();
|
||||
}
|
||||
return nextTask;
|
||||
} catch {
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
setActiveTask((current) => {
|
||||
if (!current || current.taskId !== taskId) return current;
|
||||
return { ...current, pollError: message };
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [agentBaseUrl, refreshAgent]);
|
||||
|
||||
const startTask = useCallback((queuedTask, action, app) => {
|
||||
const queuedAt = new Date().toISOString();
|
||||
setActiveTask({
|
||||
taskId: queuedTask.taskId,
|
||||
action,
|
||||
appId: app.appId,
|
||||
appName: app.appName,
|
||||
queuedAt: new Date().toISOString()
|
||||
status: queuedTask.status || 'queued',
|
||||
progress: 0,
|
||||
currentStep: 'queued',
|
||||
logs: [
|
||||
{
|
||||
time: queuedAt,
|
||||
level: 'info',
|
||||
message: `Task ${queuedTask.taskId} queued`
|
||||
}
|
||||
],
|
||||
components: [],
|
||||
pollError: '',
|
||||
queuedAt
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -274,13 +395,24 @@ function App() {
|
||||
}, [draftSettings, notify]);
|
||||
|
||||
const copyInstallCommand = useCallback(async () => {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
if (copyTextFallback(agentCommand)) {
|
||||
notify('success', agentNeedsUpdate ? 'Da copy lenh update Agent' : 'Da copy lenh cai Agent');
|
||||
return;
|
||||
}
|
||||
|
||||
window.prompt('Copy Agent command', agentCommand);
|
||||
notify('warning', 'Browser dang chan copy tu dong. Hay copy lenh trong popup.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
notify('success', 'Đã copy lệnh cài Agent');
|
||||
await navigator.clipboard.writeText(agentCommand);
|
||||
notify('success', agentNeedsUpdate ? 'Đã copy lệnh update Agent' : 'Đã copy lệnh cài Agent');
|
||||
} catch {
|
||||
notify('warning', 'Không thể copy tự động trong browser này');
|
||||
}
|
||||
}, [installCommand, notify]);
|
||||
}, [agentCommand, agentNeedsUpdate, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
@@ -297,14 +429,25 @@ function App() {
|
||||
}, [loadSelectedDetail, selectedApp?.appId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTask?.taskId) return undefined;
|
||||
if (!activeTask?.taskId || TERMINAL_TASK_STATUSES.has(activeTask.status)) return undefined;
|
||||
let disposed = false;
|
||||
let terminalNotified = false;
|
||||
|
||||
async function poll() {
|
||||
const nextTask = await loadTaskSnapshot(activeTask.taskId);
|
||||
if (disposed || !nextTask) return;
|
||||
if (TERMINAL_TASK_STATUSES.has(nextTask.status)) {
|
||||
window.clearInterval(timer);
|
||||
if (!terminalNotified) {
|
||||
terminalNotified = true;
|
||||
if (nextTask.status === 'success') {
|
||||
notify('success', `${activeTask.appName || 'Task'} completed`);
|
||||
} else if (nextTask.status === 'failed') {
|
||||
notify('failure', nextTask.errorMessage || `Task ${nextTask.taskId} failed`);
|
||||
} else {
|
||||
notify('warning', `Task ${nextTask.taskId} ${nextTask.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +458,7 @@ function App() {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [activeTask?.taskId, loadTaskSnapshot]);
|
||||
}, [activeTask?.action, activeTask?.appName, activeTask?.status, activeTask?.taskId, loadTaskSnapshot, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return undefined;
|
||||
@@ -340,9 +483,9 @@ function App() {
|
||||
<div className="nav-label">Runtime</div>
|
||||
<StatusBox
|
||||
icon={agentHealth ? PlugZap : WifiOff}
|
||||
title={agentHealth ? 'Agent online' : 'Agent offline'}
|
||||
detail={agentHealth ? `${agentHealth.hostname || 'localhost'} · ${agentHealth.agentVersion || '-'}` : '127.0.0.1:5010'}
|
||||
tone={agentHealth ? 'success' : 'danger'}
|
||||
title={agentNeedsUpdate ? 'Agent update available' : (agentHealth ? 'Agent online' : 'Agent offline')}
|
||||
detail={agentHealth ? `${agentHealth.hostname || 'localhost'} · ${agentHealth.agentVersion || '-'}${agentNeedsUpdate ? ` -> ${latestAgentPackage.version}` : ''}` : '127.0.0.1:5010'}
|
||||
tone={agentNeedsUpdate ? 'warning' : (agentHealth ? 'success' : 'danger')}
|
||||
/>
|
||||
<StatusBox
|
||||
icon={Server}
|
||||
@@ -405,11 +548,11 @@ function App() {
|
||||
<div className="page-actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
Copy Agent command
|
||||
{agentNeedsUpdate ? 'Copy Agent update' : 'Copy Agent command'}
|
||||
</button>
|
||||
<a className="btn btn-primary" href={joinUrl(packageBaseUrl, '/install-agent.sh')} target="_blank" rel="noreferrer">
|
||||
<Download size={15} aria-hidden="true" />
|
||||
install-agent.sh
|
||||
{agentNeedsUpdate ? 'update-agent.sh' : 'install-agent.sh'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,7 +561,7 @@ function App() {
|
||||
<MetricCard label="Released apps" value={stats.available} note={packageStatus.state === 'loading' ? 'loading' : 'from API'} />
|
||||
<MetricCard label="Installed local" value={stats.installed} note={agentHealth ? 'Agent SQLite' : 'Agent offline'} />
|
||||
<MetricCard label="Updates" value={stats.updates} note="version diff" tone={stats.updates ? 'warning' : 'success'} />
|
||||
<MetricCard label="Components" value={stats.components} note={selectedApp?.appId || 'selected app'} />
|
||||
<MetricCard label="Components" value={stats.components} note={selectedApp?.appCode || selectedApp?.appId || 'selected app'} />
|
||||
</div>
|
||||
|
||||
{!agentHealth && (
|
||||
@@ -426,7 +569,21 @@ function App() {
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Local Installer Agent chưa online</strong>
|
||||
<code>{installCommand}</code>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentNeedsUpdate && (
|
||||
<div className="offline-banner agent-update-banner">
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Agent {latestAgentPackage.version} đã sẵn sàng</strong>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
@@ -487,7 +644,14 @@ function App() {
|
||||
<td colSpan="5" className="table-empty">Chưa có app phù hợp bộ lọc.</td>
|
||||
</tr>
|
||||
)}
|
||||
{filteredApps.map((app) => (
|
||||
{filteredApps.map((app) => {
|
||||
const rowTask = activeTask?.appId === app.appId ? activeTask : null;
|
||||
const rowTaskBusy = isTaskActive(rowTask);
|
||||
const installBusy = busyAction === `install:${app.appId}` || (rowTaskBusy && rowTask.action === 'install');
|
||||
const updateBusy = busyAction === `update:${app.appId}` || (rowTaskBusy && rowTask.action === 'update');
|
||||
const removeBusy = busyAction === `remove:${app.appId}` || (rowTaskBusy && rowTask.action === 'remove');
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={app.appId}
|
||||
className={selectedApp?.appId === app.appId ? 'selected-row' : ''}
|
||||
@@ -497,13 +661,13 @@ function App() {
|
||||
<button className="table-title as-button" type="button" onClick={() => setSelectedAppId(app.appId)}>
|
||||
{app.appName}
|
||||
</button>
|
||||
<span className="table-subtitle">{app.appId}</span>
|
||||
<span className="table-subtitle">{app.appCode || app.appId}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-info">{app.version}</span>
|
||||
</td>
|
||||
<td>
|
||||
<LocalStatus app={app} />
|
||||
<LocalStatus app={app} task={rowTaskBusy ? rowTask : null} />
|
||||
</td>
|
||||
<td>{app.packageCount || 0}</td>
|
||||
<td className="action-col">
|
||||
@@ -512,13 +676,13 @@ function App() {
|
||||
<button
|
||||
className="btn btn-primary compact"
|
||||
type="button"
|
||||
disabled={!agentHealth || busyAction === `install:${app.appId}`}
|
||||
disabled={!agentHealth || rowTaskBusy || installBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('install', app);
|
||||
}}
|
||||
>
|
||||
{busyAction === `install:${app.appId}` ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
|
||||
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
@@ -526,13 +690,13 @@ function App() {
|
||||
<button
|
||||
className="btn btn-warning compact"
|
||||
type="button"
|
||||
disabled={!agentHealth || busyAction === `update:${app.appId}`}
|
||||
disabled={!agentHealth || rowTaskBusy || updateBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('update', app);
|
||||
}}
|
||||
>
|
||||
{busyAction === `update:${app.appId}` ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
|
||||
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
@@ -541,19 +705,20 @@ function App() {
|
||||
className="icon-button danger"
|
||||
type="button"
|
||||
title="Remove"
|
||||
disabled={!agentHealth || busyAction === `remove:${app.appId}`}
|
||||
disabled={!agentHealth || rowTaskBusy || removeBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
runAppAction('remove', app);
|
||||
}}
|
||||
>
|
||||
{busyAction === `remove:${app.appId}` ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
|
||||
{removeBusy ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -564,7 +729,21 @@ function App() {
|
||||
</section>
|
||||
|
||||
<aside className="side-stack">
|
||||
<AgentPanel health={agentHealth} systemInfo={systemInfo} status={agentStatus} />
|
||||
<AgentPanel
|
||||
health={agentHealth}
|
||||
systemInfo={systemInfo}
|
||||
status={agentStatus}
|
||||
latestAgentPackage={latestAgentPackage}
|
||||
needsUpdate={agentNeedsUpdate}
|
||||
onCopyUpdate={copyInstallCommand}
|
||||
/>
|
||||
{activeTask && (
|
||||
<TaskPanel
|
||||
task={activeTask}
|
||||
onClear={() => setActiveTask(null)}
|
||||
onRefresh={() => loadTaskSnapshot(activeTask.taskId)}
|
||||
/>
|
||||
)}
|
||||
<AppDetailPanel
|
||||
app={selectedApp}
|
||||
detail={selectedDetail}
|
||||
@@ -606,7 +785,18 @@ function MetricCard({ label, value, note, tone }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LocalStatus({ app }) {
|
||||
function LocalStatus({ app, task }) {
|
||||
if (isTaskActive(task)) {
|
||||
return (
|
||||
<span className="status-inline">
|
||||
<span className={statusBadgeClass(task.status)}>
|
||||
{TASK_ACTION_LABELS[task.action] || task.status}
|
||||
</span>
|
||||
<small>{clampProgress(task.progress)}%</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.localStatus === 'update') {
|
||||
return (
|
||||
<span className="status-inline">
|
||||
@@ -628,7 +818,95 @@ function LocalStatus({ app }) {
|
||||
return <span className="badge badge-muted">Available</span>;
|
||||
}
|
||||
|
||||
function AgentPanel({ health, systemInfo, status }) {
|
||||
function TaskPanel({ task, onClear, onRefresh }) {
|
||||
const progress = clampProgress(task.progress);
|
||||
const statusTone = TASK_STATUS_TONES[task.status] || 'info';
|
||||
const components = task.components || [];
|
||||
const logs = task.logs || [];
|
||||
const visibleLogs = logs.slice(-6);
|
||||
const canClear = TERMINAL_TASK_STATUSES.has(task.status);
|
||||
|
||||
return (
|
||||
<section className="panel task-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>Task monitor</h2>
|
||||
<p>{task.taskId}</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh}>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canClear && (
|
||||
<button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}>
|
||||
<XCircle size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-progress" aria-label={`Task progress ${progress}%`}>
|
||||
<div className={`task-progress-bar tone-${statusTone}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<dl className="detail-list compact-list">
|
||||
<div>
|
||||
<dt>Action</dt>
|
||||
<dd>{task.action || task.type || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>App</dt>
|
||||
<dd>{task.appName || task.appId || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Step</dt>
|
||||
<dd>{task.currentStep || '-'}</dd>
|
||||
</div>
|
||||
{(task.errorMessage || task.pollError) && (
|
||||
<div>
|
||||
<dt>Error</dt>
|
||||
<dd className="danger-text">{task.errorMessage || task.pollError}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="component-list task-components">
|
||||
<div className="component-list-title">
|
||||
<Activity size={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{components.map((component) => (
|
||||
<div className="component-item task-component-item" key={component.componentId}>
|
||||
<div>
|
||||
<strong>{component.componentId}</strong>
|
||||
<span>{component.currentStep || component.type || '-'}</span>
|
||||
</div>
|
||||
<span className={statusBadgeClass(component.status)}>{component.progress}%</span>
|
||||
</div>
|
||||
))}
|
||||
{!components.length && (
|
||||
<div className="table-empty compact-empty">Waiting for component details...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="task-log-list">
|
||||
{visibleLogs.map((log, index) => (
|
||||
<div className="task-log-line" key={`${log.time}-${log.level}-${index}`}>
|
||||
<span>{formatTaskTime(log.time)}</span>
|
||||
<strong>{log.level}</strong>
|
||||
<p>{log.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{!visibleLogs.length && (
|
||||
<div className="table-empty compact-empty">Waiting for task logs...</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPanel({ health, systemInfo, status, latestAgentPackage, needsUpdate, onCopyUpdate }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
@@ -636,12 +914,21 @@ function AgentPanel({ health, systemInfo, status }) {
|
||||
<h2>Local Agent</h2>
|
||||
<p>{status.message || '127.0.0.1:5010'}</p>
|
||||
</div>
|
||||
{health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />}
|
||||
{needsUpdate ? <AlertCircle className="panel-state warning" size={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />}
|
||||
</div>
|
||||
<dl className="detail-list compact-list">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{health?.agentVersion || '-'}</dd>
|
||||
<dd>
|
||||
<span className={needsUpdate ? 'status-inline' : ''}>
|
||||
{health?.agentVersion || '-'}
|
||||
{needsUpdate && <span className="badge badge-warning">Update</span>}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest</dt>
|
||||
<dd>{latestAgentPackage?.version || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host</dt>
|
||||
@@ -660,6 +947,20 @@ function AgentPanel({ health, systemInfo, status }) {
|
||||
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||
</div>
|
||||
{needsUpdate && (
|
||||
<div className="agent-update-action">
|
||||
<button className="btn btn-warning" type="button" onClick={onCopyUpdate}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
Copy update command
|
||||
</button>
|
||||
{latestAgentPackage?.downloadUrl && (
|
||||
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Download size={15} aria-hidden="true" />
|
||||
Latest .deb
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -673,7 +974,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>{app?.appName || 'App detail'}</h2>
|
||||
<p>{app?.appId || status.message || packageBaseUrl}</p>
|
||||
<p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p>
|
||||
</div>
|
||||
{status.state === 'loading' ? <Loader2 className="spin panel-state" size={20} aria-hidden="true" /> : <Box className="panel-state" size={20} aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,12 @@ async function requestJson(baseUrl, path, options = {}) {
|
||||
headers,
|
||||
...fetchOptions
|
||||
} = options;
|
||||
const url = joinUrl(baseUrl, path);
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(joinUrl(baseUrl, path), {
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -50,13 +51,16 @@ async function requestJson(baseUrl, path, options = {}) {
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.detail || payload?.error || payload || response.statusText;
|
||||
throw new Error(`${response.status} ${detail}`);
|
||||
throw new Error(`${response.status} ${formatErrorDetail(detail)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Request timeout: ${joinUrl(baseUrl, path)}`);
|
||||
throw new Error(`Request timeout: ${url}`);
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error(`Cannot fetch ${url}. Check endpoint reachability and CORS for this Web Client origin.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -64,11 +68,41 @@ async function requestJson(baseUrl, path, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDetail(detail) {
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map(formatErrorDetail).filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
if (detail && typeof detail === 'object') {
|
||||
const location = Array.isArray(detail.loc) ? detail.loc.join('.') : '';
|
||||
const message = detail.msg || detail.message || detail.detail || detail.error;
|
||||
|
||||
if (message) {
|
||||
return location ? `${location}: ${message}` : String(message);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
|
||||
return String(detail || 'Request failed');
|
||||
}
|
||||
|
||||
export async function fetchPackageApps(packageBaseUrl) {
|
||||
const payload = await requestJson(packageBaseUrl, '/api/apps', { timeoutMs: 10000 });
|
||||
return Array.isArray(payload?.apps) ? payload.apps.map(normalizePackageApp) : [];
|
||||
}
|
||||
|
||||
export async function fetchLatestAgentPackage(packageBaseUrl, arch = 'amd64') {
|
||||
const query = arch ? `?arch=${encodeURIComponent(arch)}` : '';
|
||||
return normalizeLatestAgentPackage(
|
||||
await requestJson(packageBaseUrl, `/api/agent/latest${query}`, { timeoutMs: 7000 })
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchApplicationDetail(packageBaseUrl, appId) {
|
||||
return requestJson(packageBaseUrl, `/api/apps/${encodeURIComponent(appId)}`, { timeoutMs: 10000 });
|
||||
}
|
||||
@@ -146,7 +180,8 @@ export async function fetchTaskComponents(agentBaseUrl, taskId) {
|
||||
|
||||
function normalizePackageApp(app) {
|
||||
return {
|
||||
appId: String(app.appId || app.app_id || '').trim(),
|
||||
appId: String(app.appId || app.app_id || app.id || '').trim(),
|
||||
appCode: String(app.appCode || app.app_code || app.code || app.appId || app.app_id || '').trim(),
|
||||
appName: String(app.appName || app.app_name || app.name || '').trim(),
|
||||
version: String(app.version || '').trim(),
|
||||
status: String(app.status || 'Released').trim(),
|
||||
@@ -165,6 +200,17 @@ function normalizeInstalledApp(app) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLatestAgentPackage(agentPackage) {
|
||||
return {
|
||||
version: String(agentPackage?.version || '').trim(),
|
||||
arch: String(agentPackage?.arch || '').trim(),
|
||||
fileName: String(agentPackage?.fileName || agentPackage?.file_name || '').trim(),
|
||||
sizeLabel: String(agentPackage?.sizeLabel || agentPackage?.size_label || '').trim(),
|
||||
downloadUrl: String(agentPackage?.downloadUrl || agentPackage?.download_url || '').trim(),
|
||||
installCommand: String(agentPackage?.installCommand || agentPackage?.install_command || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task) {
|
||||
return {
|
||||
taskId: task.taskId || task.task_id,
|
||||
|
||||
@@ -511,6 +511,10 @@ code {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.agent-update-banner {
|
||||
border-color: rgba(181, 71, 8, 0.35);
|
||||
}
|
||||
|
||||
.offline-banner strong {
|
||||
color: #111827;
|
||||
display: block;
|
||||
@@ -790,6 +794,17 @@ tbody tr.selected-row td.action-col {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.panel-state.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -846,6 +861,18 @@ tbody tr.selected-row td.action-col {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-update-action {
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
.agent-update-action .btn {
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
@@ -893,6 +920,84 @@ tbody tr.selected-row td.action-col {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
margin: 12px 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-progress-bar {
|
||||
background: var(--primary);
|
||||
height: 100%;
|
||||
transition: width 0.22s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.task-progress-bar.tone-success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.task-progress-bar.tone-danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.task-progress-bar.tone-warning {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.task-components {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.task-component-item .badge {
|
||||
min-width: 44px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-log-list {
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
|
||||
.task-log-line {
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 64px 46px minmax(0, 1fr);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.task-log-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-log-line span,
|
||||
.task-log-line strong {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-log-line p {
|
||||
color: #172033;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toast {
|
||||
align-items: center;
|
||||
background: #111827;
|
||||
|
||||
@@ -16,6 +16,8 @@ SESSION_MAX_AGE_MS=28800000
|
||||
SESSION_COOKIE_SECURE=false
|
||||
EMAIL_CONFIRMATION_EXPIRES_MS=86400000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
APP_SHOW_ERROR_DETAILS=false
|
||||
WEB_CLIENT_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1: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
|
||||
|
||||
@@ -12,17 +12,20 @@ ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache su-exec
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p uploads/packages/agent \
|
||||
&& chown -R node:node uploads
|
||||
|
||||
USER node
|
||||
&& chown -R node:node uploads \
|
||||
&& chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
PORT: 3000
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173}
|
||||
WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1:3000}
|
||||
ports:
|
||||
- "${WEB_SERVER_PORT:-3000}:3000"
|
||||
volumes:
|
||||
@@ -27,3 +27,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
web_server_uploads:
|
||||
name: ${WEB_SERVER_UPLOADS_VOLUME:-robot-installer-web-server-uploads}
|
||||
|
||||
7
web-server/docker-entrypoint.sh
Normal file
7
web-server/docker-entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/uploads/packages/agent
|
||||
chown -R node:node /app/uploads
|
||||
|
||||
exec su-exec node "$@"
|
||||
@@ -1209,6 +1209,23 @@ tbody tr:hover td.action-col {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-state .material-symbols-outlined {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #9a3412;
|
||||
display: block;
|
||||
line-height: 1.45;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.center-page {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -8,7 +8,7 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool } = require('./src/db');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
const notiflixVersion = require('notiflix/package.json').version;
|
||||
|
||||
const app = express();
|
||||
@@ -24,6 +24,10 @@ const publicApiCorsOrigins = getCsvEnv(process.env.WEB_CLIENT_ORIGINS || process
|
||||
'http://localhost:5173',
|
||||
'http://localhost:4173'
|
||||
]);
|
||||
const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
|
||||
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
|
||||
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
|
||||
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
|
||||
const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
@@ -33,6 +37,15 @@ app.get('/healthz', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.get('/readyz', asyncRoute(async (req, res) => {
|
||||
const database = await checkDatabaseReadiness();
|
||||
|
||||
res.status(200).json({
|
||||
status: 'ready',
|
||||
database
|
||||
});
|
||||
}));
|
||||
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||
|
||||
@@ -135,8 +148,51 @@ app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||
'or upload it as package code local-installer-agent.'
|
||||
);
|
||||
}));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
app.use('/packages/agent', express.static(agentPackageDir));
|
||||
|
||||
app.get('/api/agent/latest', asyncRoute(async (req, res) => {
|
||||
const arch = normalizeAgentArch(req.query.arch) || 'amd64';
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const latestPackage = await findLatestAgentPackage(arch);
|
||||
|
||||
if (latestPackage) {
|
||||
res.json({
|
||||
version: latestPackage.version,
|
||||
arch: latestPackage.arch,
|
||||
fileName: latestPackage.fileName,
|
||||
size: latestPackage.size,
|
||||
sizeLabel: latestPackage.sizeLabel,
|
||||
downloadUrl: `${baseUrl}${latestPackage.downloadPath}`,
|
||||
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const latestPackageRecord = await findLatestAgentPackageRecord(arch);
|
||||
if (latestPackageRecord) {
|
||||
const recordPath = latestPackageRecord.filePath || '';
|
||||
const downloadUrl = /^https?:\/\//i.test(recordPath)
|
||||
? recordPath
|
||||
: `${baseUrl}${recordPath.startsWith('/') ? recordPath : `/${recordPath}`}`;
|
||||
|
||||
res.json({
|
||||
version: latestPackageRecord.version,
|
||||
arch,
|
||||
fileName: '',
|
||||
size: 0,
|
||||
sizeLabel: '',
|
||||
downloadUrl,
|
||||
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).json({
|
||||
error: `No Local Installer Agent package found for ${arch}`
|
||||
});
|
||||
}));
|
||||
|
||||
app.use('/packages/agent', express.static(agentPackageDir, { fallthrough: false }));
|
||||
app.use('/packages/agent', staticFileNotFoundHandler('Agent package file'));
|
||||
app.use(loadCurrentUser);
|
||||
|
||||
function helpers() {
|
||||
@@ -169,6 +225,8 @@ function getBooleanEnv(value, fallback) {
|
||||
function isPublicApiCorsPath(pathname) {
|
||||
return pathname === '/api/apps'
|
||||
|| pathname.startsWith('/api/apps/')
|
||||
|| pathname === '/api/agent/latest'
|
||||
|| pathname.startsWith('/api/package-versions/')
|
||||
|| pathname === '/install-agent.sh';
|
||||
}
|
||||
|
||||
@@ -196,6 +254,17 @@ function applyPublicApiCors(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function staticFileNotFoundHandler(label) {
|
||||
return (error, req, res, next) => {
|
||||
if (error.status === 404 || error.statusCode === 404 || error.code === 'ENOENT') {
|
||||
res.status(404).type('text/plain').send(`${label} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleNavItems(user) {
|
||||
return navItems.filter((item) => !item.adminOnly || (user && user.role === 'Admin'));
|
||||
}
|
||||
@@ -218,7 +287,12 @@ function getCurrentPath(req) {
|
||||
}
|
||||
|
||||
function viewModel(req, active, title, pageData, extra = {}) {
|
||||
const currentUser = pageData.currentUser || req.currentUser;
|
||||
const currentUser = pageData.currentUser || req.currentUser || {
|
||||
name: 'Guest',
|
||||
username: 'guest',
|
||||
role: 'Guest',
|
||||
email: ''
|
||||
};
|
||||
|
||||
return {
|
||||
active,
|
||||
@@ -232,11 +306,61 @@ function viewModel(req, active, title, pageData, extra = {}) {
|
||||
notiflixVersion,
|
||||
notice: getNotice(req),
|
||||
currentPath: getCurrentPath(req),
|
||||
databaseLabel: getSqlServerDisplayLabel(),
|
||||
helpers: helpers(),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function getSqlServerDisplayLabel() {
|
||||
const host = process.env.SQLSERVER_HOST || 'localhost';
|
||||
const port = process.env.SQLSERVER_PORT || '1433';
|
||||
const database = process.env.SQLSERVER_DATABASE || 'RobotInstaller';
|
||||
|
||||
return `${host}:${port}/${database}`;
|
||||
}
|
||||
|
||||
async function checkDatabaseReadiness() {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
DB_NAME() AS DatabaseName,
|
||||
CASE WHEN OBJECT_ID(N'dbo.Users', N'U') IS NULL THEN 0 ELSE 1 END AS HasUsers,
|
||||
CASE WHEN OBJECT_ID(N'dbo.vw_PackageList', N'V') IS NULL THEN 0 ELSE 1 END AS HasPackageList,
|
||||
CASE WHEN OBJECT_ID(N'dbo.vw_ApplicationList', N'V') IS NULL THEN 0 ELSE 1 END AS HasApplicationList;
|
||||
`);
|
||||
|
||||
const row = result.recordset[0] || {};
|
||||
const missingObjects = [];
|
||||
|
||||
if (!row.HasUsers) missingObjects.push('dbo.Users');
|
||||
if (!row.HasPackageList) missingObjects.push('dbo.vw_PackageList');
|
||||
if (!row.HasApplicationList) missingObjects.push('dbo.vw_ApplicationList');
|
||||
|
||||
if (missingObjects.length > 0) {
|
||||
const error = new Error(`Missing database objects: ${missingObjects.join(', ')}`);
|
||||
error.code = 'DB_SCHEMA_NOT_READY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
server: process.env.SQLSERVER_HOST || 'localhost',
|
||||
port: Number(process.env.SQLSERVER_PORT || 1433),
|
||||
database: row.DatabaseName || process.env.SQLSERVER_DATABASE || 'RobotInstaller'
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorDetails(error) {
|
||||
if (!getBooleanEnv(process.env.APP_SHOW_ERROR_DETAILS, process.env.NODE_ENV !== 'production')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code || error.name || 'ERROR',
|
||||
message: error.message || 'Unexpected error'
|
||||
};
|
||||
}
|
||||
|
||||
function authViewModel(req, mode, values = {}) {
|
||||
const formValues = mode === 'register'
|
||||
? {
|
||||
@@ -379,16 +503,53 @@ function sanitizeReturnTo(value) {
|
||||
}
|
||||
|
||||
function getBaseUrl(req) {
|
||||
const requestBaseUrl = getRequestBaseUrl(req);
|
||||
if (process.env.APP_BASE_URL) {
|
||||
return process.env.APP_BASE_URL.replace(/\/+$/, '');
|
||||
const configuredBaseUrl = process.env.APP_BASE_URL.replace(/\/+$/, '');
|
||||
if (!isLoopbackBaseUrl(configuredBaseUrl) || isLoopbackBaseUrl(requestBaseUrl)) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return requestBaseUrl;
|
||||
}
|
||||
|
||||
function getRequestBaseUrl(req) {
|
||||
const forwardedProtocol = req.headers['x-forwarded-proto'];
|
||||
const forwardedHost = req.headers['x-forwarded-host'];
|
||||
const forwardedPort = req.headers['x-forwarded-port'];
|
||||
const protocol = forwardedProtocol
|
||||
? String(forwardedProtocol).split(',')[0].trim()
|
||||
: req.protocol;
|
||||
let host = forwardedHost
|
||||
? String(forwardedHost).split(',')[0].trim()
|
||||
: req.get('host');
|
||||
|
||||
return `${protocol}://${req.get('host')}`;
|
||||
if (forwardedPort && host && !hostHasPort(host)) {
|
||||
const port = String(forwardedPort).split(',')[0].trim();
|
||||
if (port && !isDefaultPort(protocol, port)) {
|
||||
host = `${host}:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function isLoopbackBaseUrl(value) {
|
||||
try {
|
||||
const hostname = new URL(value).hostname.toLowerCase();
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hostHasPort(host) {
|
||||
return /:\d+$/.test(host) || /^\[[^\]]+\]:\d+$/.test(host);
|
||||
}
|
||||
|
||||
function isDefaultPort(protocol, port) {
|
||||
return (protocol === 'http' && port === '80') || (protocol === 'https' && port === '443');
|
||||
}
|
||||
|
||||
function normalizeAgentArch(value) {
|
||||
@@ -493,6 +654,7 @@ async function listAgentPackages(arch = '') {
|
||||
|
||||
async function findLatestAgentPackage(arch = '') {
|
||||
const packages = await listAgentPackages(arch);
|
||||
if (!packages) return null;
|
||||
const sortedPackages = packages
|
||||
.slice()
|
||||
.sort(compareAgentPackages);
|
||||
@@ -576,6 +738,14 @@ function normalizeApplicationStatus(value) {
|
||||
return ['Draft', 'Released', 'Archived'].includes(value) ? value : 'Draft';
|
||||
}
|
||||
|
||||
function isInstallerIdentifier(value) {
|
||||
return installerIdentifierPattern.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function isInstallerVersion(value) {
|
||||
return installerVersionPattern.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function getArrayField(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value) return [value];
|
||||
@@ -654,6 +824,91 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
async function listMissingManifestPackageFiles(manifest) {
|
||||
const missingPackageFiles = [];
|
||||
|
||||
for (const component of manifest.components || []) {
|
||||
if (component.type !== 'deb') continue;
|
||||
|
||||
const versionId = getPackageVersionIdFromDownloadUrl(component.downloadUrl);
|
||||
const packageVersion = versionId
|
||||
? await repository.getPackageVersionDownload(versionId)
|
||||
: null;
|
||||
const localPath = packageVersion
|
||||
? getLocalPackageFilePath(packageVersion.filePath)
|
||||
: null;
|
||||
|
||||
if (!localPath) continue;
|
||||
|
||||
try {
|
||||
await fsp.access(localPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
|
||||
missingPackageFiles.push({
|
||||
componentId: component.componentId,
|
||||
packageName: component.packageName,
|
||||
version: component.version,
|
||||
downloadUrl: component.downloadUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return missingPackageFiles;
|
||||
}
|
||||
|
||||
function getPackageVersionIdFromDownloadUrl(downloadUrl) {
|
||||
let pathname;
|
||||
|
||||
try {
|
||||
pathname = new URL(downloadUrl).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = pathname.match(/^\/api\/package-versions\/([^/]+)\/download$/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function getLocalPackageFilePath(filePath) {
|
||||
let pathname = String(filePath || '').trim();
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(pathname)) {
|
||||
try {
|
||||
pathname = new URL(pathname).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = '/uploads/packages/';
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
|
||||
const relativePath = decodeURIComponent(pathname.slice(prefix.length));
|
||||
if (!relativePath || relativePath.includes('\0')) return null;
|
||||
|
||||
const uploadRoot = path.resolve(uploadDir);
|
||||
const localPath = path.resolve(uploadRoot, relativePath);
|
||||
const pathDelta = path.relative(uploadRoot, localPath);
|
||||
|
||||
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
function getDownloadFileName(packageVersion, localPath) {
|
||||
const storedName = path.basename(localPath || '');
|
||||
if (storedName) return storedName.replace(/"/g, '');
|
||||
|
||||
const packageCode = String(packageVersion.packageCode || 'package').replace(/[^a-zA-Z0-9._+-]/g, '-');
|
||||
const version = String(packageVersion.version || 'latest').replace(/[^a-zA-Z0-9._:+~=-]/g, '-');
|
||||
return `${packageCode}_${version}.deb`;
|
||||
}
|
||||
|
||||
async function sendEmailConfirmation(req, user) {
|
||||
const token = await repository.createEmailConfirmationToken(user.id);
|
||||
const confirmUrl = `${getBaseUrl(req)}/confirm-email?token=${encodeURIComponent(token)}`;
|
||||
@@ -901,7 +1156,8 @@ app.get('/api/apps', asyncRoute(async (req, res) => {
|
||||
apps: applications
|
||||
.filter((application) => application.status === 'Released')
|
||||
.map((application) => ({
|
||||
appId: application.code,
|
||||
appId: application.id,
|
||||
appCode: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
@@ -918,7 +1174,8 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
appId: application.code,
|
||||
appId: application.id,
|
||||
appCode: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
@@ -927,6 +1184,39 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/package-versions/:versionId/download', asyncRoute(async (req, res) => {
|
||||
const packageVersion = await repository.getPackageVersionDownload(req.params.versionId);
|
||||
if (!packageVersion || packageVersion.packageType !== 'deb') {
|
||||
res.status(404).type('text/plain').send('Package version not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const localPath = getLocalPackageFilePath(packageVersion.filePath);
|
||||
if (!localPath) {
|
||||
res.status(409).type('text/plain').send('Package file path is not available on this package server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(localPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).type('text/plain').send('Package file not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.type('application/vnd.debian.binary-package');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${getDownloadFileName(packageVersion, localPath)}"`);
|
||||
if (packageVersion.checksum) {
|
||||
res.setHeader('X-Package-Sha256', packageVersion.checksum);
|
||||
}
|
||||
|
||||
res.sendFile(localPath);
|
||||
}));
|
||||
|
||||
app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req, res) => {
|
||||
const manifest = await repository.getApplicationManifest(
|
||||
req.params.appCode,
|
||||
@@ -939,6 +1229,16 @@ app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req,
|
||||
return;
|
||||
}
|
||||
|
||||
const missingPackageFiles = await listMissingManifestPackageFiles(manifest);
|
||||
if (missingPackageFiles.length > 0) {
|
||||
res.status(409).json({
|
||||
error: 'Package file is missing on the package server',
|
||||
detail: 'Upload the package file to this web-server storage or re-upload the package version.',
|
||||
missingPackageFiles
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(manifest);
|
||||
}));
|
||||
|
||||
@@ -949,24 +1249,54 @@ app.get('/install-agent.sh', (req, res) => {
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
PACKAGE_BASE_URL="${baseUrl}"
|
||||
AGENT_URL="${agentUrl}?arch=$ARCH"
|
||||
AGENT_ENV="/etc/local-installer-agent/agent.env"
|
||||
PACKAGE_HOST="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\\1#')"
|
||||
TMP_DEB="/tmp/local-installer-agent.deb"
|
||||
|
||||
set_agent_env() {
|
||||
KEY="$1"
|
||||
VALUE="$2"
|
||||
|
||||
if grep -q "^$KEY=" "$AGENT_ENV"; then
|
||||
sed -i "s|^$KEY=.*|$KEY=$VALUE|" "$AGENT_ENV"
|
||||
else
|
||||
echo "$KEY=$VALUE" >> "$AGENT_ENV"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Downloading Local Installer Agent..."
|
||||
curl -fL "$AGENT_URL" -o "$TMP_DEB"
|
||||
|
||||
echo "Installing Local Installer Agent..."
|
||||
apt install -y "$TMP_DEB"
|
||||
|
||||
echo "Configuring Local Installer Agent..."
|
||||
mkdir -p /etc/local-installer-agent
|
||||
touch "$AGENT_ENV"
|
||||
set_agent_env ROBOT_PACKAGE_BASE_URL "$PACKAGE_BASE_URL"
|
||||
set_agent_env ALLOWED_ORIGINS "$PACKAGE_BASE_URL,http://localhost:3000,http://127.0.0.1:3000"
|
||||
set_agent_env ALLOWED_DOWNLOAD_HOSTS "$PACKAGE_HOST,localhost,127.0.0.1"
|
||||
|
||||
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
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsSL http://127.0.0.1:5010/health; then
|
||||
echo ""
|
||||
echo "Local Installer Agent installed successfully."
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Local Installer Agent installed successfully."
|
||||
echo "Local Installer Agent did not become healthy. Recent service logs:"
|
||||
journalctl -u local-installer-agent -n 80 --no-pager || true
|
||||
exit 1
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1136,19 +1466,34 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
||||
try {
|
||||
const artifact = await getArtifactFromUpload(req.file);
|
||||
const packageType = normalizePackageType(req.body.packageType);
|
||||
const packageCode = String(req.body.packageCode || '').trim();
|
||||
const packageName = String(req.body.packageName || '').trim();
|
||||
const version = String(req.body.version || '').trim();
|
||||
|
||||
if (!req.body.packageCode || !req.body.packageName || !req.body.version) {
|
||||
if (!packageCode || !packageName || !version) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng nhập Package code, Package name và Version.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerIdentifier(packageCode)) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', `Package code khong hop le. ${installerIdentifierHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerVersion(version)) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', `Package version khong hop le. ${installerVersionHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.createPackageWithVersion({
|
||||
packageCode: req.body.packageCode.trim(),
|
||||
packageName: req.body.packageName.trim(),
|
||||
packageCode,
|
||||
packageName,
|
||||
packageType,
|
||||
description: req.body.description,
|
||||
version: req.body.version.trim(),
|
||||
version,
|
||||
releaseDate: req.body.releaseDate,
|
||||
filePath: artifact.filePath,
|
||||
dockerImage: req.body.dockerImage && req.body.dockerImage.trim(),
|
||||
@@ -1173,16 +1518,23 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
||||
app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const artifact = await getArtifactFromUpload(req.file);
|
||||
const version = String(req.body.version || '').trim();
|
||||
|
||||
if (!req.body.packageId || !req.body.version) {
|
||||
if (!req.body.packageId || !version) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng chọn package và nhập version mới.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerVersion(version)) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', `Package version khong hop le. ${installerVersionHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.addPackageVersion({
|
||||
packageId: req.body.packageId,
|
||||
version: req.body.version.trim(),
|
||||
version,
|
||||
releaseDate: req.body.releaseDate,
|
||||
filePath: artifact.filePath,
|
||||
dockerImage: req.body.dockerImage && req.body.dockerImage.trim(),
|
||||
@@ -1274,16 +1626,30 @@ app.get('/applications/export.csv', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
app.post('/applications', asyncRoute(async (req, res) => {
|
||||
if (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
||||
const appCode = String(req.body.appCode || '').trim();
|
||||
const appName = String(req.body.appName || '').trim();
|
||||
const appVersion = String(req.body.appVersion || '').trim();
|
||||
|
||||
if (!appCode || !appName || !appVersion) {
|
||||
redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerIdentifier(appCode)) {
|
||||
redirectWithNotice(res, '/builder', 'warning', `App code khong hop le. ${installerIdentifierHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerVersion(appVersion)) {
|
||||
redirectWithNotice(res, '/builder', 'warning', `App version khong hop le. ${installerVersionHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const applicationId = await repository.createApplication({
|
||||
appCode: req.body.appCode.trim(),
|
||||
appName: req.body.appName.trim(),
|
||||
appVersion: req.body.appVersion.trim(),
|
||||
appCode,
|
||||
appName,
|
||||
appVersion,
|
||||
notes: req.body.notes,
|
||||
status: normalizeApplicationStatus(req.body.status),
|
||||
createdByUserId: req.currentUser.id,
|
||||
@@ -1304,18 +1670,31 @@ app.post('/applications', asyncRoute(async (req, res) => {
|
||||
app.post('/applications/:id/edit', asyncRoute(async (req, res) => {
|
||||
const applicationId = String(req.params.id || '').trim();
|
||||
const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`);
|
||||
const appCode = String(req.body.appCode || '').trim();
|
||||
const appName = String(req.body.appName || '').trim();
|
||||
const appVersion = String(req.body.appVersion || '').trim();
|
||||
|
||||
if (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
||||
if (!appCode || !appName || !appVersion) {
|
||||
redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerIdentifier(appCode)) {
|
||||
redirectWithNotice(res, returnTo, 'warning', `App code khong hop le. ${installerIdentifierHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInstallerVersion(appVersion)) {
|
||||
redirectWithNotice(res, returnTo, 'warning', `App version khong hop le. ${installerVersionHint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedApplication = await repository.updateApplication({
|
||||
applicationId,
|
||||
appCode: req.body.appCode.trim(),
|
||||
appName: req.body.appName.trim(),
|
||||
appVersion: req.body.appVersion.trim(),
|
||||
appCode,
|
||||
appName,
|
||||
appVersion,
|
||||
notes: req.body.notes,
|
||||
status: normalizeApplicationStatus(req.body.status),
|
||||
packages: getSelectedApplicationPackages(req.body)
|
||||
@@ -1557,8 +1936,11 @@ app.use(async (error, req, res, next) => {
|
||||
}));
|
||||
|
||||
res.status(500).render(
|
||||
'not-found',
|
||||
'error',
|
||||
viewModel(req, 'dashboard', 'Có lỗi xảy ra', pageData, {
|
||||
errorTitle: 'Không thể tải dữ liệu',
|
||||
errorMessage: 'Web server không đọc được dữ liệu từ SQL Server. Kiểm tra env kết nối, network tới SQL Server và schema/views trong database.',
|
||||
errorDetails: getErrorDetails(error),
|
||||
notice: {
|
||||
type: 'failure',
|
||||
message: 'Không thể xử lý thao tác. Kiểm tra log server để xem chi tiết.'
|
||||
|
||||
@@ -262,11 +262,28 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(baseUrl, filePath) {
|
||||
if (!filePath) return '';
|
||||
if (/^https?:\/\//i.test(filePath)) return filePath;
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
||||
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
try {
|
||||
const parsed = new URL(filePath);
|
||||
if (isLoopbackHost(parsed.hostname) && parsed.pathname.startsWith('/uploads/')) {
|
||||
return `${normalizedBaseUrl}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
|
||||
|
||||
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||
@@ -824,7 +841,7 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
.query(`
|
||||
SELECT TOP (1) Id, AppCode, AppName, AppVersion
|
||||
FROM dbo.Applications
|
||||
WHERE AppCode = @AppCode
|
||||
WHERE (CONVERT(NVARCHAR(36), Id) = @AppCode OR AppCode = @AppCode)
|
||||
AND AppVersion = @AppVersion
|
||||
AND Status = N'Released';
|
||||
`);
|
||||
@@ -840,6 +857,7 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
p.PackageCode,
|
||||
p.PackageName,
|
||||
p.PackageType,
|
||||
COALESCE(selected_version.Id, latest_version.Id) AS PackageVersionId,
|
||||
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,
|
||||
@@ -883,14 +901,14 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
required: true,
|
||||
packageName: row.PackageCode,
|
||||
version: row.Version || '',
|
||||
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
|
||||
downloadUrl: `${String(baseUrl || '').replace(/\/+$/, '')}/api/package-versions/${encodeURIComponent(String(row.PackageVersionId))}/download`,
|
||||
sha256: row.FileChecksumSha256 || ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
appId: appRow.AppCode,
|
||||
appId: String(appRow.Id),
|
||||
appName: appRow.AppName,
|
||||
version: appRow.AppVersion,
|
||||
architecture: 'amd64',
|
||||
@@ -943,6 +961,43 @@ async function getActivity() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getPackageVersionDownload(packageVersionId) {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.NVarChar(100), String(packageVersionId || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1)
|
||||
pv.Id,
|
||||
pv.PackageId,
|
||||
pv.Version,
|
||||
pv.FilePath,
|
||||
pv.FileChecksumSha256,
|
||||
pv.FileSizeBytes,
|
||||
p.PackageCode,
|
||||
p.PackageName,
|
||||
p.PackageType
|
||||
FROM dbo.PackageVersions AS pv
|
||||
INNER JOIN dbo.Packages AS p
|
||||
ON p.Id = pv.PackageId
|
||||
WHERE CONVERT(NVARCHAR(36), pv.Id) = @Id;
|
||||
`);
|
||||
|
||||
const row = result.recordset[0];
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
packageId: String(row.PackageId),
|
||||
packageCode: row.PackageCode,
|
||||
packageName: row.PackageName,
|
||||
packageType: row.PackageType,
|
||||
version: row.Version,
|
||||
filePath: row.FilePath || '',
|
||||
checksum: row.FileChecksumSha256 || '',
|
||||
fileSizeBytes: Number(row.FileSizeBytes || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function createPackageWithVersion(input) {
|
||||
const pool = await getPool();
|
||||
const transaction = new sql.Transaction(pool);
|
||||
@@ -1309,6 +1364,7 @@ module.exports = {
|
||||
listPackages,
|
||||
listApplications,
|
||||
getApplicationManifest,
|
||||
getPackageVersionDownload,
|
||||
getPackageById,
|
||||
getApplicationById,
|
||||
createPackageWithVersion,
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
<form id="builderForm" class="form-stack" action="/applications" method="post">
|
||||
<label class="form-field">
|
||||
<span>App code</span>
|
||||
<input type="text" name="appCode" required>
|
||||
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>App version</span>
|
||||
<input type="text" name="appVersion" required>
|
||||
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field full">
|
||||
<span>App name</span>
|
||||
|
||||
18
web-server/views/error.ejs
Normal file
18
web-server/views/error.ejs
Normal file
@@ -0,0 +1,18 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page center-page">
|
||||
<div class="empty-state error-state">
|
||||
<span class="material-symbols-outlined">database_off</span>
|
||||
<h1><%= errorTitle || 'Không thể tải dữ liệu' %></h1>
|
||||
<p><%= errorMessage || 'Web server đang gặp lỗi khi đọc dữ liệu. Kiểm tra log container để xem nguyên nhân chi tiết.' %></p>
|
||||
<% if (errorDetails) { %>
|
||||
<code class="error-detail"><%= errorDetails.code %>: <%= errorDetails.message %></code>
|
||||
<% } %>
|
||||
<a class="btn btn-primary" href="/">
|
||||
<span class="material-symbols-outlined">sync</span>
|
||||
Thử lại
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
@@ -11,11 +11,11 @@
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>App code</span>
|
||||
<input type="text" name="appCode" required data-edit-app-field="appCode">
|
||||
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." 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">
|
||||
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required data-edit-app-field="appVersion">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Status</span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Package code</span>
|
||||
<input type="text" name="packageCode" placeholder="NAV-STACK" required>
|
||||
<input type="text" name="packageCode" placeholder="NAV-STACK" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Package type</span>
|
||||
@@ -29,7 +29,7 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Version</span>
|
||||
<input type="text" name="version" placeholder="1.0.0" required>
|
||||
<input type="text" name="version" placeholder="1.0.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Release date</span>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="status-dot"></span>
|
||||
<div>
|
||||
<strong>SQL Server</strong>
|
||||
<span>172.20.235.176</span>
|
||||
<span><%= databaseLabel %></span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>New version</span>
|
||||
<input type="text" name="version" placeholder="2.5.0" required>
|
||||
<input type="text" name="version" placeholder="2.5.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Release date</span>
|
||||
|
||||
Reference in New Issue
Block a user