fix UI client

This commit is contained in:
2026-06-08 09:27:55 +07:00
parent 08b94337ad
commit f9bec78c82
4 changed files with 313 additions and 149 deletions

View File

@@ -1,14 +1,40 @@
from __future__ import annotations
import os
import subprocess
from collections.abc import Mapping
from app.config import settings
from app.storage.repository import Repository
def _tail_output(label: str, output: str, line_limit: int = 6) -> str:
lines = [line.strip() for line in output.splitlines() if line.strip()]
if not lines:
return ""
return f"{label}: {' | '.join(lines[-line_limit:])}"
def _command_output_summary(stdout: str, stderr: str) -> str:
parts = [
part
for part in (
_tail_output("stderr", stderr),
_tail_output("stdout", stdout),
)
if part
]
summary = " ; ".join(parts)
return summary[:1600]
class CommandError(RuntimeError):
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str) -> None:
super().__init__(f"Command failed with exit code {returncode}: {' '.join(command)}")
message = f"Command failed with exit code {returncode}: {' '.join(command)}"
output_summary = _command_output_summary(stdout, stderr)
if output_summary:
message = f"{message}. Last output: {output_summary}"
super().__init__(message)
self.command = command
self.returncode = returncode
self.stdout = stdout
@@ -20,15 +46,26 @@ class CommandRunner:
self.repository = repository
self.task_id = task_id
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
def run(
self,
command: list[str],
timeout: int | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
if self.task_id:
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
command_env = os.environ.copy()
if env:
command_env.update(env)
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
env=command_env,
stdin=subprocess.DEVNULL,
text=True,
timeout=timeout or settings.command_timeout_seconds,
)
@@ -47,4 +84,3 @@ class CommandRunner:
raise CommandError(command, result.returncode, result.stdout, result.stderr)
return result

View File

@@ -5,6 +5,22 @@ from pathlib import Path
from app.core.command_runner import CommandRunner
APT_DPKG_OPTIONS = [
"-o",
"Dpkg::Use-Pty=0",
"-o",
"Dpkg::Options::=--force-confdef",
"-o",
"Dpkg::Options::=--force-confold",
]
APT_NONINTERACTIVE_ENV = {
"DEBIAN_FRONTEND": "noninteractive",
"DEBCONF_NONINTERACTIVE_SEEN": "true",
"APT_LISTCHANGES_FRONTEND": "none",
}
def _parse_deb_control_output(output: str) -> dict[str, str]:
metadata: dict[str, str] = {}
@@ -49,11 +65,23 @@ class DebInstaller:
return metadata
def install_deb(self, file_path: Path) -> None:
self.command_runner.run(["apt", "install", "-y", str(file_path)])
self.command_runner.run(
[
"apt-get",
*APT_DPKG_OPTIONS,
"install",
"--yes",
str(file_path),
],
env=APT_NONINTERACTIVE_ENV,
)
def remove_package(self, package_name: str, purge: bool = False) -> None:
action = "purge" if purge else "remove"
self.command_runner.run(["apt", action, "-y", package_name])
self.command_runner.run(
["apt-get", *APT_DPKG_OPTIONS, action, "--yes", package_name],
env=APT_NONINTERACTIVE_ENV,
)
def get_package_version(self, package_name: str) -> str | None:
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])

View File

@@ -233,6 +233,7 @@ function App() {
const [busyAction, setBusyAction] = useState('');
const [activeTask, setActiveTask] = useState(null);
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const packageBaseUrl = settings.packageBaseUrl;
const agentBaseUrl = settings.agentBaseUrl;
@@ -300,15 +301,6 @@ function App() {
);
}, [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 });
}, []);
@@ -628,17 +620,18 @@ function App() {
}, [toast]);
useEffect(() => {
if (!endpointDialogOpen) return undefined;
if (!endpointDialogOpen && !agentDialogOpen) return undefined;
function onKeyDown(event) {
if (event.key === 'Escape') {
closeEndpointDialog();
if (endpointDialogOpen) closeEndpointDialog();
if (agentDialogOpen) setAgentDialogOpen(false);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [closeEndpointDialog, endpointDialogOpen]);
}, [agentDialogOpen, closeEndpointDialog, endpointDialogOpen]);
const agentStatusTitle = !canUseAgentEndpoint
? (isClientWindows ? 'Remote endpoint needed' : 'Linux client required')
@@ -706,6 +699,12 @@ function App() {
<strong>{topbarAgentState}</strong>
</div>
<div className="topbar-actions">
<AgentStatusButton
detail={agentStatusDetail}
onClick={() => setAgentDialogOpen(true)}
title={agentStatusTitle}
tone={agentStatusTone}
/>
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
<ExternalLink size={17} aria-hidden="true" />
</a>
@@ -717,34 +716,6 @@ function App() {
</header>
<section className="page">
<div className="page-header">
<div>
<h1>Application catalog</h1>
<p>Released apps from package server and install state on the selected Agent endpoint.</p>
</div>
{canShowAgentCommand && (
<div className="page-actions">
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
<Clipboard size={15} aria-hidden="true" />
{agentNeedsUpdate ? 'Copy Agent update' : 'Copy Agent command'}
</button>
{isClientLinux && isLocalAgentEndpoint && (
<a className="btn btn-primary" href={joinUrl(packageBaseUrl, '/install-agent.sh')} target="_blank" rel="noreferrer">
<Download size={15} aria-hidden="true" />
{agentNeedsUpdate ? 'update-agent.sh' : '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 target" 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?.appCode || selectedApp?.appId || 'selected app'} />
</div>
{!canUseAgentEndpoint && (
<ClientOsNotice
agentCommand={agentCommand}
@@ -937,17 +908,6 @@ function App() {
</section>
<aside className="side-stack">
<AgentPanel
health={agentHealth}
systemInfo={systemInfo}
status={agentStatus}
title={agentTargetLabel}
endpoint={agentBaseUrl}
latestAgentPackage={latestAgentPackage}
needsUpdate={agentNeedsUpdate}
onCopyUpdate={copyInstallCommand}
showAgentActions={canShowAgentCommand}
/>
{activeTask && (
<TaskPanel
task={activeTask}
@@ -968,6 +928,20 @@ function App() {
</main>
{toast && <Toast toast={toast} />}
{agentDialogOpen && (
<AgentDialog
endpoint={agentBaseUrl}
health={agentHealth}
latestAgentPackage={latestAgentPackage}
needsUpdate={agentNeedsUpdate}
onClose={() => setAgentDialogOpen(false)}
onCopyUpdate={copyInstallCommand}
showAgentActions={canShowAgentCommand}
status={agentStatus}
systemInfo={systemInfo}
title={agentTargetLabel}
/>
)}
{endpointDialogOpen && (
<EndpointDialog
draftSettings={draftSettings}
@@ -980,6 +954,43 @@ function App() {
);
}
function AgentStatusButton({ title, detail, tone, onClick }) {
const Icon = tone === 'success'
? CheckCircle2
: tone === 'danger'
? XCircle
: tone === 'warning'
? AlertCircle
: Loader2;
return (
<button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}>
<span className="agent-status-icon">
<Icon className={tone === 'info' ? 'spin' : ''} size={16} aria-hidden="true" />
</span>
<span className="agent-status-copy">
<strong>{title}</strong>
<small>{detail}</small>
</span>
</button>
);
}
function AgentDialog(props) {
return (
<div className="dialog-backdrop">
<section
aria-labelledby="agent-dialog-title"
aria-modal="true"
className="dialog-panel agent-dialog-panel"
role="dialog"
>
<AgentPanel {...props} />
</section>
</div>
);
}
function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
return (
<div className="dialog-backdrop">
@@ -1056,18 +1067,6 @@ function StatusBox({ icon: Icon, title, detail, tone }) {
);
}
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 ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
return (
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
@@ -1213,17 +1212,49 @@ function TaskPanel({ task, onClear, onRefresh }) {
);
}
function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPackage, needsUpdate, onCopyUpdate, showAgentActions }) {
function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPackage, needsUpdate, onClose, onCopyUpdate, showAgentActions }) {
const statusTone = needsUpdate
? 'warning'
: health
? 'success'
: status.state === 'loading'
? 'info'
: status.state === 'warning'
? 'warning'
: 'danger';
const statusLabel = needsUpdate
? 'Update available'
: health
? 'Online'
: status.state === 'loading'
? 'Checking'
: status.state === 'warning'
? 'Warning'
: 'Offline';
return (
<section className="panel">
<section className="agent-detail">
<div className="panel-header">
<div>
<h2>{title || 'Agent'}</h2>
<h2 id="agent-dialog-title">{title || 'Agent'}</h2>
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
</div>
{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 className="panel-actions">
{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" />}
<button className="icon-button subtle" type="button" title="Close" onClick={onClose}>
<X size={16} aria-hidden="true" />
</button>
</div>
</div>
<dl className="detail-list compact-list">
<div>
<dt>Status</dt>
<dd><span className={`badge badge-${statusTone}`}>{statusLabel}</span></dd>
</div>
<div>
<dt>Endpoint</dt>
<dd>{endpoint || '127.0.0.1:5010'}</dd>
</div>
<div>
<dt>Version</dt>
<dd>
@@ -1254,13 +1285,13 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
</div>
{needsUpdate && showAgentActions && (
{showAgentActions && (
<div className="agent-update-action">
<button className="btn btn-warning" type="button" onClick={onCopyUpdate}>
<button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}>
<Clipboard size={15} aria-hidden="true" />
Copy update command
{needsUpdate ? 'Copy update command' : 'Copy Agent command'}
</button>
{latestAgentPackage?.downloadUrl && (
{needsUpdate && latestAgentPackage?.downloadUrl && (
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
<Download size={15} aria-hidden="true" />
Latest .deb
@@ -1311,7 +1342,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
{status.state === 'danger' && (
<div className="table-empty compact-empty danger-text">{status.message}</div>
)}
{(components.length ? components : packages).slice(0, 5).map((item) => (
{(components.length ? components : packages).map((item) => (
<div className="component-item" key={item.componentId || item.id || item.packageId}>
<div>
<strong>{item.componentId || item.code || item.name}</strong>

View File

@@ -336,32 +336,20 @@ code {
gap: 8px;
}
.topbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.page {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 24px;
padding: 16px 24px 24px;
}
.page-header {
align-items: flex-start;
display: flex;
flex-shrink: 0;
gap: 16px;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h1 {
color: #111827;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
}
.page-header p,
.panel-header p {
color: var(--on-surface-variant);
font-size: 13px;
@@ -463,15 +451,100 @@ code {
color: var(--danger);
}
.dashboard-stats {
display: grid;
flex-shrink: 0;
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 14px;
.agent-status-button {
align-items: center;
background: #ffffff;
border: 1px solid #dbe3ea;
border-radius: var(--radius);
color: #334155;
display: inline-grid;
gap: 8px;
grid-template-columns: auto minmax(0, 1fr);
min-height: 38px;
max-width: 360px;
padding: 6px 10px;
text-align: left;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.agent-status-button:active {
transform: scale(0.98);
}
.agent-status-button:hover {
background: #f8fafc;
}
.agent-status-icon {
align-items: center;
border-radius: 999px;
display: inline-flex;
height: 24px;
justify-content: center;
width: 24px;
}
.agent-status-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.agent-status-copy strong,
.agent-status-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-status-copy strong {
color: #111827;
font-size: 12px;
font-weight: 800;
}
.agent-status-copy small {
color: #64748b;
font-size: 11px;
font-weight: 700;
margin-top: 1px;
}
.agent-status-button.tone-success {
background: var(--success-bg);
border-color: rgba(6, 118, 71, 0.28);
}
.agent-status-button.tone-success .agent-status-icon,
.agent-status-button.tone-success .agent-status-copy strong {
color: var(--success);
}
.agent-status-button.tone-danger {
background: var(--danger-bg);
border-color: rgba(180, 35, 24, 0.24);
}
.agent-status-button.tone-danger .agent-status-icon,
.agent-status-button.tone-danger .agent-status-copy strong {
color: var(--danger);
}
.agent-status-button.tone-warning {
background: var(--warning-bg);
border-color: rgba(181, 71, 8, 0.28);
}
.agent-status-button.tone-warning .agent-status-icon,
.agent-status-button.tone-warning .agent-status-copy strong {
color: var(--warning);
}
.agent-status-button.tone-info .agent-status-icon,
.agent-status-button.tone-info .agent-status-copy strong {
color: var(--info);
}
.metric-card,
.panel,
.table-panel,
.offline-banner {
@@ -481,14 +554,6 @@ code {
box-shadow: var(--shadow-sm);
}
.metric-card {
display: flex;
flex-direction: column;
min-height: 88px;
padding: 14px;
}
.metric-card > span,
.filter-field span,
.detail-list dt,
.component-list-title {
@@ -499,34 +564,6 @@ code {
text-transform: uppercase;
}
.metric-card div {
align-items: baseline;
display: flex;
justify-content: space-between;
margin-top: auto;
}
.metric-card strong {
color: #111827;
font-family: "Manrope", Arial, sans-serif;
font-size: 27px;
font-weight: 800;
}
.metric-card small {
color: var(--on-surface-variant);
font-size: 11px;
font-weight: 700;
}
.metric-card.tone-warning {
border-color: rgba(181, 71, 8, 0.3);
}
.metric-card.tone-success {
border-color: rgba(6, 118, 71, 0.28);
}
.offline-banner {
align-items: center;
display: grid;
@@ -572,7 +609,7 @@ code {
display: grid;
flex: 1;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
min-height: 0;
}
@@ -589,7 +626,7 @@ code {
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: auto;
overflow: hidden;
}
.side-stack > .panel:not(.app-detail-panel) {
@@ -597,7 +634,7 @@ code {
}
.app-detail-panel {
flex: 1 1 360px;
flex: 1 1 auto;
overflow: auto;
}
@@ -606,6 +643,16 @@ code {
overflow: visible;
}
.app-detail-panel .detail-list {
display: grid;
gap: 0 18px;
grid-template-columns: 1fr;
}
.app-detail-panel .detail-list div {
grid-template-columns: 82px minmax(0, 1fr);
}
.page-filters {
align-items: center;
display: flex;
@@ -1066,6 +1113,24 @@ tbody tr.selected-row td.action-col {
width: min(480px, 100%);
}
.agent-dialog-panel {
width: min(640px, 100%);
}
.agent-detail {
display: flex;
flex-direction: column;
min-height: 0;
}
.agent-detail .panel-header {
padding: 16px;
}
.agent-detail .detail-list {
padding-top: 6px;
}
.dialog-panel form {
display: flex;
flex-direction: column;
@@ -1150,7 +1215,7 @@ tbody tr.selected-row td.action-col {
}
}
@media (max-width: 1180px) {
@media (max-width: 1320px) {
.workbench-grid {
grid-template-columns: 1fr;
overflow: auto;
@@ -1199,15 +1264,17 @@ tbody tr.selected-row td.action-col {
overflow: visible;
}
.dashboard-stats,
.side-stack {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.side-stack {
overflow: visible;
}
}
@media (max-width: 680px) {
.topbar,
.page-header,
.page-filters,
.offline-banner {
align-items: stretch;
@@ -1220,27 +1287,29 @@ tbody tr.selected-row td.action-col {
padding: 12px 16px;
}
.topbar-actions,
.page-actions {
.topbar-actions {
flex-wrap: wrap;
}
.topbar-actions .btn,
.page-actions .btn,
.page-actions a {
.topbar-actions .agent-status-button {
flex: 1;
max-width: none;
}
.page {
padding: 16px;
}
.dashboard-stats,
.side-stack,
.nav-section {
grid-template-columns: 1fr;
}
.app-detail-panel .detail-list {
grid-template-columns: 1fr;
}
.offline-banner {
display: flex;
}