laster 0.0.2
This commit is contained in:
@@ -8,3 +8,4 @@ PACKAGE_PROXY_TARGET=http://robot-installer-web-server:3000
|
||||
|
||||
VITE_PACKAGE_BASE_URL=
|
||||
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
|
||||
VITE_APP_OPEN_URL=http://127.0.0.1
|
||||
|
||||
@@ -9,9 +9,11 @@ COPY . .
|
||||
|
||||
ARG VITE_PACKAGE_BASE_URL=
|
||||
ARG VITE_AGENT_BASE_URL=http://127.0.0.1:5010
|
||||
ARG VITE_APP_OPEN_URL=http://127.0.0.1
|
||||
|
||||
RUN VITE_PACKAGE_BASE_URL="${VITE_PACKAGE_BASE_URL}" \
|
||||
VITE_AGENT_BASE_URL="${VITE_AGENT_BASE_URL}" \
|
||||
VITE_APP_OPEN_URL="${VITE_APP_OPEN_URL}" \
|
||||
npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
@@ -21,6 +21,7 @@ Có thể đổi trong UI hoặc qua `.env`:
|
||||
```env
|
||||
VITE_PACKAGE_BASE_URL=
|
||||
VITE_AGENT_BASE_URL=http://127.0.0.1:5010
|
||||
VITE_APP_OPEN_URL=http://127.0.0.1
|
||||
PACKAGE_PROXY_TARGET=http://localhost:3000
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
args:
|
||||
VITE_PACKAGE_BASE_URL: ${VITE_PACKAGE_BASE_URL:-}
|
||||
VITE_AGENT_BASE_URL: ${VITE_AGENT_BASE_URL:-http://127.0.0.1:5010}
|
||||
VITE_APP_OPEN_URL: ${VITE_APP_OPEN_URL:-http://127.0.0.1}
|
||||
container_name: ${WEB_CLIENT_CONTAINER_NAME:-robot-installer-web-client}
|
||||
environment:
|
||||
PACKAGE_PROXY_TARGET: ${PACKAGE_PROXY_TARGET:-http://robot-installer-web-server:3000}
|
||||
|
||||
4
web-client/package-lock.json
generated
4
web-client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "robot-installer-web-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "robot-installer-web-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "robot-installer-web-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Public web client for installing Robot applications through the Local Installer Agent.",
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
fetchTaskComponents,
|
||||
fetchTaskLogs,
|
||||
fetchTaskStatus,
|
||||
getAppOpenUrl,
|
||||
joinUrl,
|
||||
normalizeUrl,
|
||||
queueInstall,
|
||||
@@ -353,6 +354,34 @@ function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startPreflightFailedTask = useCallback((action, app, message) => {
|
||||
const failedAt = new Date().toISOString();
|
||||
const taskId = `preflight_${Date.now()}`;
|
||||
|
||||
setActiveTask({
|
||||
taskId,
|
||||
action,
|
||||
appId: app.appId,
|
||||
appName: app.appName,
|
||||
status: 'failed',
|
||||
progress: 5,
|
||||
currentStep: 'package preflight failed',
|
||||
errorMessage: message,
|
||||
logs: [
|
||||
{
|
||||
time: failedAt,
|
||||
level: 'error',
|
||||
message
|
||||
}
|
||||
],
|
||||
components: [],
|
||||
pollError: '',
|
||||
localOnly: true,
|
||||
queuedAt: failedAt,
|
||||
finishedAt: failedAt
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runAppAction = useCallback(async (action, app) => {
|
||||
if (!agentHealth) {
|
||||
notify('warning', 'Agent local đang offline. Cài Agent rồi bấm Retry.');
|
||||
@@ -368,8 +397,14 @@ function App() {
|
||||
try {
|
||||
let queuedTask;
|
||||
if (action === 'install') {
|
||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||
setSelectedManifest(manifest);
|
||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||
queuedTask = await queueInstall(agentBaseUrl, app);
|
||||
} else if (action === 'update') {
|
||||
const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version);
|
||||
setSelectedManifest(manifest);
|
||||
setDetailStatus({ state: 'success', message: 'Manifest ready' });
|
||||
queuedTask = await queueUpdate(agentBaseUrl, app, app.installed);
|
||||
} else {
|
||||
queuedTask = await queueRemove(agentBaseUrl, app);
|
||||
@@ -378,11 +413,17 @@ function App() {
|
||||
startTask(queuedTask, action, app);
|
||||
notify('success', `Đã queue task ${queuedTask.taskId}`);
|
||||
} catch (error) {
|
||||
notify('failure', getErrorMessage(error));
|
||||
const message = getErrorMessage(error);
|
||||
if (action === 'install' || action === 'update') {
|
||||
setSelectedManifest(null);
|
||||
setDetailStatus({ state: 'danger', message });
|
||||
startPreflightFailedTask(action, app, message);
|
||||
}
|
||||
notify('failure', message);
|
||||
} finally {
|
||||
setBusyAction('');
|
||||
}
|
||||
}, [agentBaseUrl, agentHealth, notify, startTask]);
|
||||
}, [agentBaseUrl, agentHealth, notify, packageBaseUrl, startPreflightFailedTask, startTask]);
|
||||
|
||||
const applySettings = useCallback(() => {
|
||||
const nextSettings = {
|
||||
@@ -650,6 +691,9 @@ function App() {
|
||||
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');
|
||||
const openUrl = app.installed
|
||||
? getAppOpenUrl({ ...app, openUrl: app.installed.openUrl || app.openUrl })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -700,6 +744,19 @@ function App() {
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{app.installed && openUrl && (
|
||||
<a
|
||||
className="btn btn-secondary compact"
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`Open ${app.appName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open App
|
||||
</a>
|
||||
)}
|
||||
{app.installed && (
|
||||
<button
|
||||
className="icon-button danger"
|
||||
@@ -825,6 +882,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
const logs = task.logs || [];
|
||||
const visibleLogs = logs.slice(-6);
|
||||
const canClear = TERMINAL_TASK_STATUSES.has(task.status);
|
||||
const canRefresh = !task.localOnly;
|
||||
|
||||
return (
|
||||
<section className="panel task-panel">
|
||||
@@ -835,7 +893,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
</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}>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canClear && (
|
||||
@@ -1001,6 +1059,9 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{status.state === 'danger' && (
|
||||
<div className="table-empty compact-empty danger-text">{status.message}</div>
|
||||
)}
|
||||
{(components.length ? components : packages).slice(0, 5).map((item) => (
|
||||
<div className="component-item" key={item.componentId || item.id || item.packageId}>
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,9 @@ export const DEFAULT_PACKAGE_BASE_URL = normalizeUrl(
|
||||
export const DEFAULT_AGENT_BASE_URL = normalizeUrl(
|
||||
import.meta.env.VITE_AGENT_BASE_URL || 'http://127.0.0.1:5010'
|
||||
);
|
||||
export const DEFAULT_APP_OPEN_URL = normalizeUrl(
|
||||
import.meta.env.VITE_APP_OPEN_URL || 'http://127.0.0.1'
|
||||
);
|
||||
|
||||
export function normalizeUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
@@ -16,6 +19,30 @@ export function joinUrl(baseUrl, path) {
|
||||
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizeOpenUrl(value) {
|
||||
const text = normalizeUrl(value);
|
||||
if (!text) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? text : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppOpenUrl(app) {
|
||||
return normalizeOpenUrl(
|
||||
app?.openUrl
|
||||
|| app?.open_url
|
||||
|| app?.webUrl
|
||||
|| app?.web_url
|
||||
|| app?.homepageUrl
|
||||
|| app?.homepage_url
|
||||
|| app?.homepage
|
||||
) || normalizeOpenUrl(DEFAULT_APP_OPEN_URL);
|
||||
}
|
||||
|
||||
async function requestJson(baseUrl, path, options = {}) {
|
||||
const {
|
||||
timeoutMs = 8000,
|
||||
@@ -50,8 +77,7 @@ async function requestJson(baseUrl, path, options = {}) {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.detail || payload?.error || payload || response.statusText;
|
||||
throw new Error(`${response.status} ${formatErrorDetail(detail)}`);
|
||||
throw new Error(`${response.status} ${formatErrorDetail(payload || response.statusText)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -75,7 +101,27 @@ function formatErrorDetail(detail) {
|
||||
|
||||
if (detail && typeof detail === 'object') {
|
||||
const location = Array.isArray(detail.loc) ? detail.loc.join('.') : '';
|
||||
const message = detail.msg || detail.message || detail.detail || detail.error;
|
||||
const messageParts = [
|
||||
detail.msg,
|
||||
detail.message,
|
||||
detail.error,
|
||||
detail.detail
|
||||
]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (Array.isArray(detail.missingPackageFiles) && detail.missingPackageFiles.length > 0) {
|
||||
const missingFiles = detail.missingPackageFiles
|
||||
.map(formatMissingPackageFile)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
||||
if (missingFiles) {
|
||||
messageParts.push(`Missing package files: ${missingFiles}`);
|
||||
}
|
||||
}
|
||||
|
||||
const message = [...new Set(messageParts)].join('. ');
|
||||
|
||||
if (message) {
|
||||
return location ? `${location}: ${message}` : String(message);
|
||||
@@ -91,6 +137,17 @@ function formatErrorDetail(detail) {
|
||||
return String(detail || 'Request failed');
|
||||
}
|
||||
|
||||
function formatMissingPackageFile(item) {
|
||||
if (!item || typeof item !== 'object') return String(item || '').trim();
|
||||
|
||||
const packageName = String(item.packageName || item.componentId || 'package').trim();
|
||||
const version = String(item.version || '').trim();
|
||||
const downloadUrl = String(item.downloadUrl || '').trim();
|
||||
const label = [packageName, version].filter(Boolean).join(' ');
|
||||
|
||||
return downloadUrl ? `${label} (${downloadUrl})` : label;
|
||||
}
|
||||
|
||||
export async function fetchPackageApps(packageBaseUrl) {
|
||||
const payload = await requestJson(packageBaseUrl, '/api/apps', { timeoutMs: 10000 });
|
||||
return Array.isArray(payload?.apps) ? payload.apps.map(normalizePackageApp) : [];
|
||||
@@ -185,7 +242,16 @@ function normalizePackageApp(app) {
|
||||
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)
|
||||
packageCount: Number(app.packageCount || app.package_count || 0),
|
||||
openUrl: normalizeOpenUrl(
|
||||
app.openUrl
|
||||
|| app.open_url
|
||||
|| app.webUrl
|
||||
|| app.web_url
|
||||
|| app.homepageUrl
|
||||
|| app.homepage_url
|
||||
|| app.homepage
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +262,16 @@ function normalizeInstalledApp(app) {
|
||||
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 || ''
|
||||
updatedAt: app.updatedAt || app.updated_at || '',
|
||||
openUrl: normalizeOpenUrl(
|
||||
app.openUrl
|
||||
|| app.open_url
|
||||
|| app.webUrl
|
||||
|| app.web_url
|
||||
|| app.homepageUrl
|
||||
|| app.homepage_url
|
||||
|| app.homepage
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user