laster 0.0.1

This commit is contained in:
2026-05-25 15:49:42 +07:00
parent 14d3a3152a
commit e2c4881bb7
22 changed files with 1139 additions and 158 deletions

View File

@@ -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;
}

View File

@@ -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,73 +644,81 @@ function App() {
<td colSpan="5" className="table-empty">Chưa app phù hợp bộ lọc.</td>
</tr>
)}
{filteredApps.map((app) => (
<tr
key={app.appId}
className={selectedApp?.appId === app.appId ? 'selected-row' : ''}
onClick={() => setSelectedAppId(app.appId)}
>
<td>
<button className="table-title as-button" type="button" onClick={() => setSelectedAppId(app.appId)}>
{app.appName}
</button>
<span className="table-subtitle">{app.appId}</span>
</td>
<td>
<span className="badge badge-info">{app.version}</span>
</td>
<td>
<LocalStatus app={app} />
</td>
<td>{app.packageCount || 0}</td>
<td className="action-col">
<div className="action-group">
{!app.installed && (
<button
className="btn btn-primary compact"
type="button"
disabled={!agentHealth || busyAction === `install:${app.appId}`}
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" />}
Install
</button>
)}
{app.installed && app.canUpdate && (
<button
className="btn btn-warning compact"
type="button"
disabled={!agentHealth || busyAction === `update:${app.appId}`}
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" />}
Update
</button>
)}
{app.installed && (
<button
className="icon-button danger"
type="button"
title="Remove"
disabled={!agentHealth || busyAction === `remove:${app.appId}`}
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" />}
</button>
)}
</div>
</td>
</tr>
))}
{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' : ''}
onClick={() => setSelectedAppId(app.appId)}
>
<td>
<button className="table-title as-button" type="button" onClick={() => setSelectedAppId(app.appId)}>
{app.appName}
</button>
<span className="table-subtitle">{app.appCode || app.appId}</span>
</td>
<td>
<span className="badge badge-info">{app.version}</span>
</td>
<td>
<LocalStatus app={app} task={rowTaskBusy ? rowTask : null} />
</td>
<td>{app.packageCount || 0}</td>
<td className="action-col">
<div className="action-group">
{!app.installed && (
<button
className="btn btn-primary compact"
type="button"
disabled={!agentHealth || rowTaskBusy || installBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('install', app);
}}
>
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
Install
</button>
)}
{app.installed && app.canUpdate && (
<button
className="btn btn-warning compact"
type="button"
disabled={!agentHealth || rowTaskBusy || updateBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('update', app);
}}
>
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
Update
</button>
)}
{app.installed && (
<button
className="icon-button danger"
type="button"
title="Remove"
disabled={!agentHealth || rowTaskBusy || removeBusy}
onClick={(event) => {
event.stopPropagation();
runAppAction('remove', app);
}}
>
{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>

View File

@@ -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,

View File

@@ -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;