laster 0.0.1
This commit is contained in:
@@ -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 có 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>
|
||||
|
||||
Reference in New Issue
Block a user