import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { Activity, AlertCircle, Box, CheckCircle2, Clipboard, Cpu, Download, ExternalLink, HardDrive, Loader2, PackageCheck, Play, PlugZap, RefreshCcw, RotateCcw, Search, Server, Settings, ShieldCheck, Trash2, WifiOff, XCircle } from 'lucide-react'; import { DEFAULT_AGENT_BASE_URL, DEFAULT_PACKAGE_BASE_URL, fetchAgentHealth, fetchAgentSystemInfo, fetchApplicationDetail, fetchApplicationManifest, fetchInstalledApps, fetchLatestAgentPackage, fetchPackageApps, fetchTaskComponents, fetchTaskLogs, fetchTaskStatus, joinUrl, normalizeUrl, queueInstall, queueRemove, queueUpdate } from './services/api.js'; 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 { const parsed = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) || '{}'); return { packageBaseUrl: normalizeUrl(parsed.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL), agentBaseUrl: normalizeUrl(parsed.agentBaseUrl || DEFAULT_AGENT_BASE_URL) }; } catch { return { packageBaseUrl: DEFAULT_PACKAGE_BASE_URL, agentBaseUrl: DEFAULT_AGENT_BASE_URL }; } } function saveSettings(settings) { window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } 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: '' }); const [agentStatus, setAgentStatus] = useState({ state: 'idle', message: '' }); const [selectedAppId, setSelectedAppId] = useState(''); const [selectedDetail, setSelectedDetail] = useState(null); const [selectedManifest, setSelectedManifest] = useState(null); const [detailStatus, setDetailStatus] = useState({ state: 'idle', message: '' }); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [toast, setToast] = useState(null); const [busyAction, setBusyAction] = useState(''); const [activeTask, setActiveTask] = useState(null); 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])); }, [installedApps]); const mergedApps = useMemo(() => { return apps.map((app) => { const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode); const isInstalled = Boolean(installed); const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version); return { ...app, installed, localStatus: canUpdate ? 'update' : (isInstalled ? 'installed' : 'available'), canUpdate }; }); }, [apps, installedByAppId]); const filteredApps = useMemo(() => { const needle = query.trim().toLowerCase(); return mergedApps.filter((app) => { const matchesQuery = !needle || [ app.appId, app.appCode, app.appName, app.version, app.status ].join(' ').toLowerCase().includes(needle); if (!matchesQuery) return false; if (filter === 'installed') return app.localStatus === 'installed' || app.localStatus === 'update'; if (filter === 'updates') return app.localStatus === 'update'; if (filter === 'available') return app.localStatus === 'available'; return true; }); }, [filter, mergedApps, query]); const selectedApp = useMemo(() => { 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, installed: installedApps.length, updates: mergedApps.filter((app) => app.canUpdate).length, components: selectedManifest?.components?.length || selectedDetail?.packages?.length || 0 }; }, [apps.length, installedApps.length, mergedApps, selectedDetail, selectedManifest]); const notify = useCallback((type, message) => { setToast({ id: Date.now(), type, message }); }, []); const refreshPackage = useCallback(async () => { setPackageStatus({ state: 'loading', message: 'Đang tải app từ package server' }); try { const [nextApps, nextAgentPackage] = await Promise.all([ fetchPackageApps(packageBaseUrl), fetchLatestAgentPackage(packageBaseUrl).catch(() => null) ]); setApps(nextApps); 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]); const refreshAgent = useCallback(async () => { setAgentStatus({ state: 'loading', message: 'Đang kiểm tra Agent local' }); try { const health = await fetchAgentHealth(agentBaseUrl); setAgentHealth(health); setAgentStatus({ state: 'success', message: `${health.hostname || 'Agent'} online` }); const [info, installed] = await Promise.all([ fetchAgentSystemInfo(agentBaseUrl).catch(() => null), fetchInstalledApps(agentBaseUrl) ]); setSystemInfo(info); setInstalledApps(installed); return true; } catch (error) { setAgentHealth(null); setSystemInfo(null); setInstalledApps([]); setAgentStatus({ state: 'danger', message: getErrorMessage(error) }); return false; } }, [agentBaseUrl]); const refreshAll = useCallback(async () => { await Promise.all([refreshPackage(), refreshAgent()]); }, [refreshAgent, refreshPackage]); const loadSelectedDetail = useCallback(async (app) => { if (!app) { setSelectedDetail(null); setSelectedManifest(null); return; } setDetailStatus({ state: 'loading', message: 'Đang tải manifest' }); try { const [detail, manifest] = await Promise.all([ fetchApplicationDetail(packageBaseUrl, app.appId).catch(() => null), fetchApplicationManifest(packageBaseUrl, app.appId, app.version).catch(() => null) ]); setSelectedDetail(detail); setSelectedManifest(manifest); setDetailStatus({ state: 'success', message: manifest ? 'Manifest sẵn sàng' : 'Đã tải app detail' }); } catch (error) { setSelectedDetail(null); setSelectedManifest(null); setDetailStatus({ state: 'danger', message: getErrorMessage(error) }); } }, [packageBaseUrl]); const loadTaskSnapshot = useCallback(async (taskId) => { try { const [nextTask, logs, components] = await Promise.all([ fetchTaskStatus(agentBaseUrl, taskId), fetchTaskLogs(agentBaseUrl, taskId).catch(() => []), fetchTaskComponents(agentBaseUrl, taskId).catch(() => []) ]); const snapshot = { ...nextTask, logs, components, pollError: '' }; setActiveTask((current) => { if (!current || current.taskId !== taskId) return current; return { ...current, ...snapshot }; }); if (TERMINAL_TASK_STATUSES.has(snapshot.status)) { await refreshAgent(); } 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, status: queuedTask.status || 'queued', progress: 0, currentStep: 'queued', logs: [ { time: queuedAt, level: 'info', message: `Task ${queuedTask.taskId} queued` } ], components: [], pollError: '', queuedAt }); }, []); const runAppAction = useCallback(async (action, app) => { if (!agentHealth) { notify('warning', 'Agent local đang offline. Cài Agent rồi bấm Retry.'); return; } if (action === 'remove' && !window.confirm(`Remove ${app.appName} khỏi máy local?`)) { return; } const key = `${action}:${app.appId}`; setBusyAction(key); try { let queuedTask; if (action === 'install') { queuedTask = await queueInstall(agentBaseUrl, app); } else if (action === 'update') { queuedTask = await queueUpdate(agentBaseUrl, app, app.installed); } else { queuedTask = await queueRemove(agentBaseUrl, app); } startTask(queuedTask, action, app); notify('success', `Đã queue task ${queuedTask.taskId}`); } catch (error) { notify('failure', getErrorMessage(error)); } finally { setBusyAction(''); } }, [agentBaseUrl, agentHealth, notify, startTask]); const applySettings = useCallback(() => { const nextSettings = { packageBaseUrl: normalizeUrl(draftSettings.packageBaseUrl || DEFAULT_PACKAGE_BASE_URL), agentBaseUrl: normalizeUrl(draftSettings.agentBaseUrl || DEFAULT_AGENT_BASE_URL) }; setSettings(nextSettings); saveSettings(nextSettings); notify('info', 'Đã cập nhật endpoint test'); }, [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(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'); } }, [agentCommand, agentNeedsUpdate, notify]); useEffect(() => { refreshAll(); }, [refreshAll]); useEffect(() => { if (!selectedApp) { setSelectedDetail(null); setSelectedManifest(null); return; } setSelectedAppId(selectedApp.appId); loadSelectedDetail(selectedApp); }, [loadSelectedDetail, selectedApp?.appId]); useEffect(() => { 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}`); } } } } poll(); const timer = window.setInterval(poll, 1200); return () => { disposed = true; window.clearInterval(timer); }; }, [activeTask?.action, activeTask?.appName, activeTask?.status, activeTask?.taskId, loadTaskSnapshot, notify]); useEffect(() => { if (!toast) return undefined; const timer = window.setTimeout(() => setToast(null), 3200); return () => window.clearTimeout(timer); }, [toast]); return (
robot.installer {agentHealth ? 'Ready for install' : 'Waiting for Agent'}

Application catalog

Released apps từ package server và trạng thái cài đặt trên máy local.

{!agentHealth && (
)} {agentNeedsUpdate && (
)}
{packageStatus.state === 'loading' && ( )} {packageStatus.state === 'danger' && ( )} {packageStatus.state !== 'loading' && packageStatus.state !== 'danger' && filteredApps.length === 0 && ( )} {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 ( setSelectedAppId(app.appId)} > ); })}
Application Released Local Packages Actions
Đang tải danh sách app...
{packageStatus.message}
Chưa có app phù hợp bộ lọc.
{app.appCode || app.appId} {app.version} {app.packageCount || 0}
{!app.installed && ( )} {app.installed && app.canUpdate && ( )} {app.installed && ( )}
{filteredApps.length} / {mergedApps.length} apps {packageBaseUrl}
{toast && }
); } function StatusBox({ icon: Icon, title, detail, tone }) { return (
{title} {detail}
); } function MetricCard({ label, value, note, tone }) { return (
{label}
{value} {note}
); } function LocalStatus({ app, task }) { if (isTaskActive(task)) { return ( {TASK_ACTION_LABELS[task.action] || task.status} {clampProgress(task.progress)}% ); } if (app.localStatus === 'update') { return ( Update {app.installed.version} ); } if (app.localStatus === 'installed') { return ( Installed {app.installed.version} ); } return Available; } 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 (

Task monitor

{task.taskId}

{task.status || 'queued'} {canClear && ( )}
Action
{task.action || task.type || '-'}
App
{task.appName || task.appId || '-'}
Step
{task.currentStep || '-'}
{(task.errorMessage || task.pollError) && (
Error
{task.errorMessage || task.pollError}
)}
{components.map((component) => (
{component.componentId} {component.currentStep || component.type || '-'}
{component.progress}%
))} {!components.length && (
Waiting for component details...
)}
{visibleLogs.map((log, index) => (
{formatTaskTime(log.time)} {log.level}

{log.message}

))} {!visibleLogs.length && (
Waiting for task logs...
)}
); } function AgentPanel({ health, systemInfo, status, latestAgentPackage, needsUpdate, onCopyUpdate }) { return (

Local Agent

{status.message || '127.0.0.1:5010'}

{needsUpdate ?
Version
{health?.agentVersion || '-'} {needsUpdate && Update}
Latest
{latestAgentPackage?.version || '-'}
Host
{health?.hostname || '-'}
OS
{systemInfo?.os || health?.os || '-'}
Arch
{systemInfo?.architecture || health?.architecture || '-'}
{needsUpdate && (
{latestAgentPackage?.downloadUrl && ( )}
)}
); } function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) { const packages = detail?.packages || []; const components = manifest?.components || []; return (

{app?.appName || 'App detail'}

{app?.appCode || app?.appId || status.message || packageBaseUrl}

{status.state === 'loading' ?
{app ? ( <>
Released
{app.version}
Status
{app.status || 'Released'}
Local
{app.installed ? `${app.installed.version} · ${app.installed.status || 'installed'}` : 'Not installed'}
{(components.length ? components : packages).slice(0, 5).map((item) => (
{item.componentId || item.code || item.name} {item.packageName || item.selectedVersion || item.type}
{item.version || item.selectedVersion || item.type || 'pkg'}
))} {!components.length && !packages.length && (
{status.message || 'Chưa có component.'}
)}
) : (
Chọn app để xem manifest.
)}
); } function Toast({ toast }) { const tone = toast.type === 'failure' ? 'danger' : toast.type; const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity; return (
); } createRoot(document.getElementById('root')).render();