client
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,10 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
agent/.venv/
|
agent/.venv/
|
||||||
agent/build/
|
agent/build/
|
||||||
|
web-client/dist/
|
||||||
|
|
||||||
web-server/uploads/
|
web-server/uploads/
|
||||||
|
|||||||
@@ -1,49 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PKG_NAME="local-installer-agent"
|
VERSION="${VERSION:-0.1.0}"
|
||||||
ARCH="${ARCH:-amd64}"
|
ARCH="${ARCH:-amd64}"
|
||||||
PUBLISH_DIR="${AGENT_PUBLISH_DIR:-../web-server/uploads/packages/agent}"
|
PKG_NAME="local-installer-agent"
|
||||||
|
|
||||||
if [ -z "${BUILD_ROOT:-}" ]; then
|
|
||||||
if [[ "$(pwd -P)" == /mnt/* ]]; then
|
|
||||||
BUILD_ROOT="/tmp/${PKG_NAME}-build"
|
|
||||||
else
|
|
||||||
BUILD_ROOT="build"
|
BUILD_ROOT="build"
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
next_patch_version() {
|
|
||||||
local latest=""
|
|
||||||
|
|
||||||
if [ -d "${PUBLISH_DIR}" ]; then
|
|
||||||
latest="$(
|
|
||||||
for package_path in "${PUBLISH_DIR}/${PKG_NAME}_"*"_${ARCH}.deb"; do
|
|
||||||
[ -e "${package_path}" ] || continue
|
|
||||||
package_file="$(basename "${package_path}")"
|
|
||||||
package_version="${package_file#${PKG_NAME}_}"
|
|
||||||
package_version="${package_version%_${ARCH}.deb}"
|
|
||||||
printf '%s\n' "${package_version}"
|
|
||||||
done | sort -V | tail -n 1
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${latest}" ]; then
|
|
||||||
latest="$(
|
|
||||||
sed -nE 's/^Version:[[:space:]]*([^[:space:]]+).*/\1/p' packaging/DEBIAN/control |
|
|
||||||
head -n 1
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${latest}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
|
||||||
printf '%s.%s.%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$((BASH_REMATCH[3] + 1))"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '0.1.0\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
VERSION="${VERSION:-$(next_patch_version)}"
|
|
||||||
BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}"
|
BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}"
|
||||||
|
|
||||||
rm -rf "${BUILD_ROOT}"
|
rm -rf "${BUILD_ROOT}"
|
||||||
@@ -56,11 +17,6 @@ mkdir -p "${BUILD_DIR}/DEBIAN"
|
|||||||
cp -r app "${BUILD_DIR}/opt/local-installer-agent/"
|
cp -r app "${BUILD_DIR}/opt/local-installer-agent/"
|
||||||
cp requirements.txt "${BUILD_DIR}/opt/local-installer-agent/"
|
cp requirements.txt "${BUILD_DIR}/opt/local-installer-agent/"
|
||||||
|
|
||||||
find "${BUILD_DIR}/opt/local-installer-agent/app" -type d -name "__pycache__" -prune -exec rm -rf {} +
|
|
||||||
find "${BUILD_DIR}/opt/local-installer-agent/app" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
|
|
||||||
find "${BUILD_DIR}/opt/local-installer-agent" -type d -exec chmod 755 {} +
|
|
||||||
find "${BUILD_DIR}/opt/local-installer-agent" -type f -exec chmod 644 {} +
|
|
||||||
|
|
||||||
cp packaging/systemd/local-installer-agent.service \
|
cp packaging/systemd/local-installer-agent.service \
|
||||||
"${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
|
"${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
|
||||||
|
|
||||||
@@ -72,11 +28,6 @@ cp packaging/DEBIAN/postrm "${BUILD_DIR}/DEBIAN/postrm"
|
|||||||
chmod 755 "${BUILD_DIR}/DEBIAN/postinst"
|
chmod 755 "${BUILD_DIR}/DEBIAN/postinst"
|
||||||
chmod 755 "${BUILD_DIR}/DEBIAN/prerm"
|
chmod 755 "${BUILD_DIR}/DEBIAN/prerm"
|
||||||
chmod 755 "${BUILD_DIR}/DEBIAN/postrm"
|
chmod 755 "${BUILD_DIR}/DEBIAN/postrm"
|
||||||
chmod 644 "${BUILD_DIR}/DEBIAN/control"
|
|
||||||
chmod 644 "${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
|
|
||||||
|
|
||||||
sed -i -E "s/^Version:.*/Version: ${VERSION}/" "${BUILD_DIR}/DEBIAN/control"
|
|
||||||
sed -i -E "s/^Architecture:.*/Architecture: ${ARCH}/" "${BUILD_DIR}/DEBIAN/control"
|
|
||||||
|
|
||||||
cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" <<EOF
|
cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" <<EOF
|
||||||
AGENT_VERSION=${VERSION}
|
AGENT_VERSION=${VERSION}
|
||||||
@@ -96,14 +47,7 @@ ALLOW_DOCKER=false
|
|||||||
ALLOW_DOCKER_COMPOSE=false
|
ALLOW_DOCKER_COMPOSE=false
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
dpkg-deb --root-owner-group --build "${BUILD_DIR}"
|
dpkg-deb --build "${BUILD_DIR}"
|
||||||
|
|
||||||
echo "Built package:"
|
echo "Built package:"
|
||||||
echo "${BUILD_DIR}.deb"
|
echo "${BUILD_DIR}.deb"
|
||||||
|
|
||||||
if [ "${PUBLISH:-false}" = "true" ]; then
|
|
||||||
mkdir -p "${PUBLISH_DIR}"
|
|
||||||
cp "${BUILD_DIR}.deb" "${PUBLISH_DIR}/"
|
|
||||||
echo "Published package:"
|
|
||||||
echo "${PUBLISH_DIR}/$(basename "${BUILD_DIR}.deb")"
|
|
||||||
fi
|
|
||||||
|
|||||||
4
web-client/.env.example
Normal file
4
web-client/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Leave empty in local dev to use Vite proxy: /api -> PACKAGE_PROXY_TARGET.
|
||||||
|
VITE_PACKAGE_BASE_URL=
|
||||||
|
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
|
||||||
|
PACKAGE_PROXY_TARGET=http://localhost:3000
|
||||||
41
web-client/README.md
Normal file
41
web-client/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Robot Installer Web Client
|
||||||
|
|
||||||
|
Web Client public cho user cài, cập nhật và gỡ app thông qua Local Installer Agent.
|
||||||
|
|
||||||
|
## Chạy local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Mặc định khi chạy dev, client gọi package server qua Vite proxy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
robot.package API: http://localhost:5173/api -> http://localhost:3000/api
|
||||||
|
Local Agent: http://127.0.0.1:5010
|
||||||
|
```
|
||||||
|
|
||||||
|
Có thể đổi trong UI hoặc qua `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_PACKAGE_BASE_URL=
|
||||||
|
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
|
||||||
|
PACKAGE_PROXY_TARGET=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Khi deploy `robot.installer` thật, đặt `VITE_PACKAGE_BASE_URL=https://robot.package` để browser gọi thẳng package server.
|
||||||
|
|
||||||
|
## Test thật
|
||||||
|
|
||||||
|
1. Chạy `web-server` tại `http://localhost:3000`.
|
||||||
|
2. Chạy hoặc cài Local Installer Agent tại `http://127.0.0.1:5010`.
|
||||||
|
3. Khi test local, Agent nên có:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ROBOT_PACKAGE_BASE_URL=http://localhost:3000
|
||||||
|
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:4173
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS=localhost,127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Mở Web Client, bấm `Retry`, chọn app đã `Released`, rồi bấm `Install`.
|
||||||
19
web-client/index.html
Normal file
19
web-client/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Robot Installer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1716
web-client/package-lock.json
generated
Normal file
1716
web-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
web-client/package.json
Normal file
20
web-client/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "robot-installer-web-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Public web client for installing Robot applications through the Local Installer Agent.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"lucide-react": "^0.468.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {}
|
||||||
|
}
|
||||||
861
web-client/src/main.jsx
Normal file
861
web-client/src/main.jsx
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
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,
|
||||||
|
TerminalSquare,
|
||||||
|
Trash2,
|
||||||
|
WifiOff,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DEFAULT_AGENT_BASE_URL,
|
||||||
|
DEFAULT_PACKAGE_BASE_URL,
|
||||||
|
fetchAgentHealth,
|
||||||
|
fetchAgentSystemInfo,
|
||||||
|
fetchApplicationDetail,
|
||||||
|
fetchApplicationManifest,
|
||||||
|
fetchInstalledApps,
|
||||||
|
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']);
|
||||||
|
|
||||||
|
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 statusTone(status) {
|
||||||
|
if (status === 'success' || status === 'installed' || status === 'online') return 'success';
|
||||||
|
if (status === 'running' || status === 'queued') return 'info';
|
||||||
|
if (status === 'failed' || status === 'offline') return 'danger';
|
||||||
|
if (status === 'update') return 'warning';
|
||||||
|
return 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat('vi-VN', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error || 'Có lỗi xảy ra');
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [settings, setSettings] = useState(readSettings);
|
||||||
|
const [draftSettings, setDraftSettings] = useState(settings);
|
||||||
|
const [apps, setApps] = useState([]);
|
||||||
|
const [installedApps, setInstalledApps] = useState([]);
|
||||||
|
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 [task, setTask] = useState(null);
|
||||||
|
const [taskLogs, setTaskLogs] = useState([]);
|
||||||
|
const [taskComponents, setTaskComponents] = useState([]);
|
||||||
|
const [taskStatus, setTaskStatus] = useState({ state: 'idle', message: '' });
|
||||||
|
|
||||||
|
const packageBaseUrl = settings.packageBaseUrl;
|
||||||
|
const agentBaseUrl = settings.agentBaseUrl;
|
||||||
|
const installCommand = `curl -fsSL ${joinUrl(packageBaseUrl, '/install-agent.sh')} | sudo bash`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
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.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 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 = await fetchPackageApps(packageBaseUrl);
|
||||||
|
setApps(nextApps);
|
||||||
|
setPackageStatus({ state: 'success', message: `${nextApps.length} app released` });
|
||||||
|
return nextApps;
|
||||||
|
} catch (error) {
|
||||||
|
setPackageStatus({ state: 'danger', message: getErrorMessage(error) });
|
||||||
|
setApps([]);
|
||||||
|
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) => {
|
||||||
|
setTaskStatus({ state: 'loading', message: 'Đang cập nhật task' });
|
||||||
|
try {
|
||||||
|
const [nextTask, nextLogs, nextComponents] = await Promise.all([
|
||||||
|
fetchTaskStatus(agentBaseUrl, taskId),
|
||||||
|
fetchTaskLogs(agentBaseUrl, taskId),
|
||||||
|
fetchTaskComponents(agentBaseUrl, taskId).catch(() => [])
|
||||||
|
]);
|
||||||
|
setTask(nextTask);
|
||||||
|
setTaskLogs(nextLogs);
|
||||||
|
setTaskComponents(nextComponents);
|
||||||
|
setTaskStatus({ state: statusTone(nextTask.status), message: nextTask.status });
|
||||||
|
|
||||||
|
if (TERMINAL_TASK_STATUSES.has(nextTask.status)) {
|
||||||
|
await refreshAgent();
|
||||||
|
}
|
||||||
|
return nextTask;
|
||||||
|
} catch (error) {
|
||||||
|
setTaskStatus({ state: 'danger', message: getErrorMessage(error) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [agentBaseUrl, refreshAgent]);
|
||||||
|
|
||||||
|
const startTask = useCallback((queuedTask, action, app) => {
|
||||||
|
setActiveTask({
|
||||||
|
taskId: queuedTask.taskId,
|
||||||
|
action,
|
||||||
|
appId: app.appId,
|
||||||
|
appName: app.appName,
|
||||||
|
queuedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
setTask({
|
||||||
|
taskId: queuedTask.taskId,
|
||||||
|
type: action,
|
||||||
|
appId: app.appId,
|
||||||
|
appName: app.appName,
|
||||||
|
status: queuedTask.status || 'queued',
|
||||||
|
progress: 0,
|
||||||
|
currentStep: 'queued'
|
||||||
|
});
|
||||||
|
setTaskLogs([]);
|
||||||
|
setTaskComponents([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(installCommand);
|
||||||
|
notify('success', 'Đã copy lệnh cài Agent');
|
||||||
|
} catch {
|
||||||
|
notify('warning', 'Không thể copy tự động trong browser này');
|
||||||
|
}
|
||||||
|
}, [installCommand, notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAll();
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedApp) {
|
||||||
|
setSelectedDetail(null);
|
||||||
|
setSelectedManifest(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAppId(selectedApp.appId);
|
||||||
|
loadSelectedDetail(selectedApp);
|
||||||
|
}, [loadSelectedDetail, selectedApp?.appId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTask?.taskId) return undefined;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
const nextTask = await loadTaskSnapshot(activeTask.taskId);
|
||||||
|
if (disposed || !nextTask) return;
|
||||||
|
if (TERMINAL_TASK_STATUSES.has(nextTask.status)) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const timer = window.setInterval(poll, 1200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [activeTask?.taskId, loadTaskSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setToast(null), 3200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand-block">
|
||||||
|
<div className="brand-mark">
|
||||||
|
<PackageCheck size={20} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="brand-copy">
|
||||||
|
<strong>Robot Installer</strong>
|
||||||
|
<span>Web Client</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-section">
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
<StatusBox
|
||||||
|
icon={Server}
|
||||||
|
title="Package API"
|
||||||
|
detail={packageStatus.message || packageBaseUrl}
|
||||||
|
tone={packageStatus.state === 'success' ? 'success' : packageStatus.state}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="nav-label">Endpoint</div>
|
||||||
|
<label className="settings-field">
|
||||||
|
<span>Package server</span>
|
||||||
|
<input
|
||||||
|
value={draftSettings.packageBaseUrl}
|
||||||
|
onChange={(event) => setDraftSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
packageBaseUrl: event.target.value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="settings-field">
|
||||||
|
<span>Local Agent</span>
|
||||||
|
<input
|
||||||
|
value={draftSettings.agentBaseUrl}
|
||||||
|
onChange={(event) => setDraftSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
agentBaseUrl: event.target.value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-secondary full" type="button" onClick={applySettings}>
|
||||||
|
<Settings size={15} aria-hidden="true" />
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-title">
|
||||||
|
<span>robot.installer</span>
|
||||||
|
<strong>{agentHealth ? 'Ready for install' : 'Waiting for Agent'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
|
||||||
|
<ExternalLink size={17} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
<button className="btn btn-secondary" type="button" onClick={refreshAll}>
|
||||||
|
<RefreshCcw size={15} aria-hidden="true" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Application catalog</h1>
|
||||||
|
<p>Released apps từ package server và trạng thái cài đặt trên máy local.</p>
|
||||||
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||||
|
<Clipboard size={15} aria-hidden="true" />
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-stats">
|
||||||
|
<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'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!agentHealth && (
|
||||||
|
<div className="offline-banner">
|
||||||
|
<AlertCircle size={19} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Local Installer Agent chưa online</strong>
|
||||||
|
<code>{installCommand}</code>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||||
|
<Clipboard size={15} aria-hidden="true" />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="workbench-grid">
|
||||||
|
<section className="table-panel">
|
||||||
|
<div className="page-filters inline">
|
||||||
|
<label className="filter-field wide">
|
||||||
|
<span>Search</span>
|
||||||
|
<div className="input-with-icon">
|
||||||
|
<Search size={15} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="App code, name, version..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="filter-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={filter} onChange={(event) => setFilter(event.target.value)}>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="available">Available</option>
|
||||||
|
<option value="installed">Installed</option>
|
||||||
|
<option value="updates">Updates</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Application</th>
|
||||||
|
<th>Released</th>
|
||||||
|
<th>Local</th>
|
||||||
|
<th>Packages</th>
|
||||||
|
<th className="action-col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{packageStatus.state === 'loading' && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="5" className="table-empty">Đang tải danh sách app...</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{packageStatus.state === 'danger' && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="5" className="table-empty danger-text">{packageStatus.message}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{packageStatus.state !== 'loading' && packageStatus.state !== 'danger' && filteredApps.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="page-pager">
|
||||||
|
<span>{filteredApps.length} / {mergedApps.length} apps</span>
|
||||||
|
<span>{packageBaseUrl}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="side-stack">
|
||||||
|
<AgentPanel health={agentHealth} systemInfo={systemInfo} status={agentStatus} />
|
||||||
|
<AppDetailPanel
|
||||||
|
app={selectedApp}
|
||||||
|
detail={selectedDetail}
|
||||||
|
manifest={selectedManifest}
|
||||||
|
status={detailStatus}
|
||||||
|
packageBaseUrl={packageBaseUrl}
|
||||||
|
/>
|
||||||
|
<TaskPanel
|
||||||
|
activeTask={activeTask}
|
||||||
|
task={task}
|
||||||
|
logs={taskLogs}
|
||||||
|
components={taskComponents}
|
||||||
|
status={taskStatus}
|
||||||
|
onRefresh={() => activeTask?.taskId && loadTaskSnapshot(activeTask.taskId)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{toast && <Toast toast={toast} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBox({ icon: Icon, title, detail, tone }) {
|
||||||
|
return (
|
||||||
|
<div className={`status-box tone-${tone || 'muted'}`}>
|
||||||
|
<span className="status-icon"><Icon size={16} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>{detail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, note, tone }) {
|
||||||
|
return (
|
||||||
|
<article className={`metric-card ${tone ? `tone-${tone}` : ''}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
<small>{note}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalStatus({ app }) {
|
||||||
|
if (app.localStatus === 'update') {
|
||||||
|
return (
|
||||||
|
<span className="status-inline">
|
||||||
|
<span className="badge badge-warning">Update</span>
|
||||||
|
<small>{app.installed.version}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.localStatus === 'installed') {
|
||||||
|
return (
|
||||||
|
<span className="status-inline">
|
||||||
|
<span className="badge badge-success">Installed</span>
|
||||||
|
<small>{app.installed.version}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="badge badge-muted">Available</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentPanel({ health, systemInfo, status }) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<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" />}
|
||||||
|
</div>
|
||||||
|
<dl className="detail-list compact-list">
|
||||||
|
<div>
|
||||||
|
<dt>Version</dt>
|
||||||
|
<dd>{health?.agentVersion || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd>{health?.hostname || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>OS</dt>
|
||||||
|
<dd>{systemInfo?.os || health?.os || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Arch</dt>
|
||||||
|
<dd>{systemInfo?.architecture || health?.architecture || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="agent-metrics">
|
||||||
|
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||||
|
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||||
|
const packages = detail?.packages || [];
|
||||||
|
const components = manifest?.components || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>{app?.appName || 'App detail'}</h2>
|
||||||
|
<p>{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>
|
||||||
|
|
||||||
|
{app ? (
|
||||||
|
<>
|
||||||
|
<dl className="detail-list compact-list">
|
||||||
|
<div>
|
||||||
|
<dt>Released</dt>
|
||||||
|
<dd>{app.version}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd><span className="badge badge-success">{app.status || 'Released'}</span></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Local</dt>
|
||||||
|
<dd>{app.installed ? `${app.installed.version} · ${app.installed.status || 'installed'}` : 'Not installed'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="component-list">
|
||||||
|
<div className="component-list-title">
|
||||||
|
<ShieldCheck size={15} aria-hidden="true" />
|
||||||
|
Components
|
||||||
|
</div>
|
||||||
|
{(components.length ? components : packages).slice(0, 5).map((item) => (
|
||||||
|
<div className="component-item" key={item.componentId || item.id || item.packageId}>
|
||||||
|
<div>
|
||||||
|
<strong>{item.componentId || item.code || item.name}</strong>
|
||||||
|
<span>{item.packageName || item.selectedVersion || item.type}</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-muted">{item.version || item.selectedVersion || item.type || 'pkg'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!components.length && !packages.length && (
|
||||||
|
<div className="table-empty compact-empty">{status.message || 'Chưa có component.'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="table-empty compact-empty">Chọn app để xem manifest.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskPanel({ activeTask, task, logs, components, status, onRefresh }) {
|
||||||
|
const progress = Math.max(0, Math.min(100, Number(task?.progress || 0)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel task-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Task monitor</h2>
|
||||||
|
<p>{activeTask?.taskId || 'Chưa có task'}</p>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button subtle" type="button" onClick={onRefresh} disabled={!activeTask?.taskId} title="Refresh task">
|
||||||
|
<RefreshCcw size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task ? (
|
||||||
|
<>
|
||||||
|
<div className="task-summary">
|
||||||
|
<div>
|
||||||
|
<span className={`badge badge-${statusTone(task.status)}`}>{task.status}</span>
|
||||||
|
<strong>{task.appName || task.appId}</strong>
|
||||||
|
<small>{task.currentStep || '-'}</small>
|
||||||
|
</div>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-track">
|
||||||
|
<div style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{components.length > 0 && (
|
||||||
|
<div className="component-list task-components">
|
||||||
|
<div className="component-list-title">
|
||||||
|
<Activity size={15} aria-hidden="true" />
|
||||||
|
Component progress
|
||||||
|
</div>
|
||||||
|
{components.map((component) => (
|
||||||
|
<div className="component-item" key={component.componentId}>
|
||||||
|
<div>
|
||||||
|
<strong>{component.componentId}</strong>
|
||||||
|
<span>{component.currentStep || component.type}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`badge badge-${statusTone(component.status)}`}>{component.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="logs-box">
|
||||||
|
<div className="component-list-title">
|
||||||
|
<TerminalSquare size={15} aria-hidden="true" />
|
||||||
|
Logs
|
||||||
|
</div>
|
||||||
|
<div className="log-lines">
|
||||||
|
{logs.slice(-8).map((log, index) => (
|
||||||
|
<div className={`log-line level-${log.level || 'info'}`} key={`${log.time}-${index}`}>
|
||||||
|
<time>{formatDate(log.time)}</time>
|
||||||
|
<span>{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<div className="table-empty compact-empty">{status.message || 'Đang chờ log.'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="table-empty compact-empty">Install, update hoặc remove để bắt đầu theo dõi.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({ toast }) {
|
||||||
|
const tone = toast.type === 'failure' ? 'danger' : toast.type;
|
||||||
|
const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity;
|
||||||
|
return (
|
||||||
|
<div className={`toast tone-${tone || 'info'}`}>
|
||||||
|
<Icon size={17} aria-hidden="true" />
|
||||||
|
<span>{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
204
web-client/src/services/api.js
Normal file
204
web-client/src/services/api.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
export const DEFAULT_PACKAGE_BASE_URL = normalizeUrl(
|
||||||
|
import.meta.env.VITE_PACKAGE_BASE_URL || window.location.origin
|
||||||
|
);
|
||||||
|
export const DEFAULT_AGENT_BASE_URL = normalizeUrl(
|
||||||
|
import.meta.env.VITE_AGENT_BASE_URL || 'http://127.0.0.1:5010'
|
||||||
|
);
|
||||||
|
|
||||||
|
export function normalizeUrl(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
return text.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinUrl(baseUrl, path) {
|
||||||
|
const normalizedBaseUrl = normalizeUrl(baseUrl);
|
||||||
|
const normalizedPath = String(path || '').startsWith('/') ? path : `/${path || ''}`;
|
||||||
|
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(baseUrl, path, options = {}) {
|
||||||
|
const {
|
||||||
|
timeoutMs = 8000,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
...fetchOptions
|
||||||
|
} = options;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(joinUrl(baseUrl, path), {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...headers
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = payload?.detail || payload?.error || payload || response.statusText;
|
||||||
|
throw new Error(`${response.status} ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`Request timeout: ${joinUrl(baseUrl, path)}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchApplicationDetail(packageBaseUrl, appId) {
|
||||||
|
return requestJson(packageBaseUrl, `/api/apps/${encodeURIComponent(appId)}`, { timeoutMs: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApplicationManifest(packageBaseUrl, appId, version) {
|
||||||
|
return requestJson(
|
||||||
|
packageBaseUrl,
|
||||||
|
`/api/apps/${encodeURIComponent(appId)}/versions/${encodeURIComponent(version)}/manifest`,
|
||||||
|
{ timeoutMs: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentHealth(agentBaseUrl) {
|
||||||
|
return requestJson(agentBaseUrl, '/health', { timeoutMs: 2800 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentSystemInfo(agentBaseUrl) {
|
||||||
|
return requestJson(agentBaseUrl, '/system-info', { timeoutMs: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInstalledApps(agentBaseUrl) {
|
||||||
|
const payload = await requestJson(agentBaseUrl, '/apps/installed', { timeoutMs: 7000 });
|
||||||
|
return Array.isArray(payload) ? payload.map(normalizeInstalledApp) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueInstall(agentBaseUrl, app) {
|
||||||
|
return requestJson(agentBaseUrl, '/apps/install', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 10000,
|
||||||
|
body: {
|
||||||
|
appId: app.appId,
|
||||||
|
appName: app.appName,
|
||||||
|
version: app.version
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueUpdate(agentBaseUrl, app, installedApp) {
|
||||||
|
return requestJson(agentBaseUrl, '/apps/update', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 10000,
|
||||||
|
body: {
|
||||||
|
appId: app.appId,
|
||||||
|
appName: app.appName,
|
||||||
|
currentVersion: installedApp?.version || '',
|
||||||
|
targetVersion: app.version
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueRemove(agentBaseUrl, app) {
|
||||||
|
return requestJson(agentBaseUrl, '/apps/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 10000,
|
||||||
|
body: {
|
||||||
|
appId: app.appId,
|
||||||
|
purge: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTaskStatus(agentBaseUrl, taskId) {
|
||||||
|
return normalizeTask(await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}`, { timeoutMs: 7000 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTaskLogs(agentBaseUrl, taskId) {
|
||||||
|
const payload = await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}/logs`, { timeoutMs: 7000 });
|
||||||
|
return Array.isArray(payload?.logs) ? payload.logs.map(normalizeLog) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTaskComponents(agentBaseUrl, taskId) {
|
||||||
|
const payload = await requestJson(agentBaseUrl, `/tasks/${encodeURIComponent(taskId)}/components`, { timeoutMs: 7000 });
|
||||||
|
return Array.isArray(payload?.components) ? payload.components.map(normalizeComponent) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePackageApp(app) {
|
||||||
|
return {
|
||||||
|
appId: String(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(),
|
||||||
|
packageCount: Number(app.packageCount || app.package_count || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInstalledApp(app) {
|
||||||
|
return {
|
||||||
|
appId: String(app.appId || app.app_id || '').trim(),
|
||||||
|
appName: String(app.appName || app.app_name || '').trim(),
|
||||||
|
version: String(app.installedVersion || app.version || app.package_version || '').trim(),
|
||||||
|
status: String(app.status || 'installed').trim(),
|
||||||
|
installedAt: app.installedAt || app.installed_at || '',
|
||||||
|
updatedAt: app.updatedAt || app.updated_at || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTask(task) {
|
||||||
|
return {
|
||||||
|
taskId: task.taskId || task.task_id,
|
||||||
|
type: task.type,
|
||||||
|
appId: task.appId || task.app_id,
|
||||||
|
appName: task.appName || task.app_name,
|
||||||
|
status: task.status,
|
||||||
|
progress: Number(task.progress || 0),
|
||||||
|
currentStep: task.currentStep || task.current_step,
|
||||||
|
currentComponentId: task.currentComponentId || task.current_component_id,
|
||||||
|
errorMessage: task.errorMessage || task.error_message,
|
||||||
|
createdAt: task.createdAt || task.created_at,
|
||||||
|
startedAt: task.startedAt || task.started_at,
|
||||||
|
finishedAt: task.finishedAt || task.finished_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLog(log) {
|
||||||
|
return {
|
||||||
|
time: log.time || log.timestamp || '',
|
||||||
|
level: log.level || 'info',
|
||||||
|
message: log.message || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComponent(component) {
|
||||||
|
return {
|
||||||
|
componentId: component.componentId || component.component_id,
|
||||||
|
type: component.type,
|
||||||
|
status: component.status,
|
||||||
|
progress: Number(component.progress || 0),
|
||||||
|
currentStep: component.currentStep || component.current_step,
|
||||||
|
errorMessage: component.errorMessage || component.error_message,
|
||||||
|
startedAt: component.startedAt || component.started_at,
|
||||||
|
finishedAt: component.finishedAt || component.finished_at
|
||||||
|
};
|
||||||
|
}
|
||||||
1132
web-client/src/styles.css
Normal file
1132
web-client/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
36
web-client/vite.config.js
Normal file
36
web-client/vite.config.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const packageProxyTarget = process.env.PACKAGE_PROXY_TARGET
|
||||||
|
|| process.env.VITE_PACKAGE_BASE_URL
|
||||||
|
|| 'http://localhost:3000';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: packageProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/install-agent.sh': {
|
||||||
|
target: packageProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: packageProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/packages': {
|
||||||
|
target: packageProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
strictPort: false
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -18,6 +18,11 @@ const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(
|
|||||||
const authCookieName = 'robot_installer_session';
|
const authCookieName = 'robot_installer_session';
|
||||||
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8);
|
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8);
|
||||||
const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret';
|
const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret';
|
||||||
|
const publicApiCorsOrigins = getCsvEnv(process.env.WEB_CLIENT_ORIGINS || process.env.PUBLIC_API_CORS_ORIGINS, [
|
||||||
|
'https://robot.installer',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:4173'
|
||||||
|
]);
|
||||||
const agentVersionCollator = new Intl.Collator('en', {
|
const agentVersionCollator = new Intl.Collator('en', {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sensitivity: 'base'
|
sensitivity: 'base'
|
||||||
@@ -99,6 +104,7 @@ app.set('views', path.join(__dirname, 'views'));
|
|||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||||
|
app.use(applyPublicApiCors);
|
||||||
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||||
const arch = normalizeAgentArch(req.query.arch);
|
const arch = normalizeAgentArch(req.query.arch);
|
||||||
const latestPackage = await findLatestAgentPackage(arch);
|
const latestPackage = await findLatestAgentPackage(arch);
|
||||||
@@ -142,6 +148,44 @@ function helpers() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCsvEnv(value, fallback) {
|
||||||
|
if (!value) return fallback;
|
||||||
|
return String(value)
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicApiCorsPath(pathname) {
|
||||||
|
return pathname === '/api/apps'
|
||||||
|
|| pathname.startsWith('/api/apps/')
|
||||||
|
|| pathname === '/install-agent.sh';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPublicApiCors(req, res, next) {
|
||||||
|
if (!isPublicApiCorsPath(req.path)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const allowAnyOrigin = publicApiCorsOrigins.includes('*');
|
||||||
|
|
||||||
|
if (origin && (allowAnyOrigin || publicApiCorsOrigins.includes(origin))) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', allowAnyOrigin ? '*' : origin);
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Accept, Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.sendStatus(204);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
function getVisibleNavItems(user) {
|
function getVisibleNavItems(user) {
|
||||||
return navItems.filter((item) => !item.adminOnly || (user && user.role === 'Admin'));
|
return navItems.filter((item) => !item.adminOnly || (user && user.role === 'Admin'));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user