fix UI client
This commit is contained in:
@@ -1,14 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.storage.repository import Repository
|
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):
|
class CommandError(RuntimeError):
|
||||||
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str) -> None:
|
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.command = command
|
||||||
self.returncode = returncode
|
self.returncode = returncode
|
||||||
self.stdout = stdout
|
self.stdout = stdout
|
||||||
@@ -20,15 +46,26 @@ class CommandRunner:
|
|||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.task_id = task_id
|
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:
|
if self.task_id:
|
||||||
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
|
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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
env=command_env,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout or settings.command_timeout_seconds,
|
timeout=timeout or settings.command_timeout_seconds,
|
||||||
)
|
)
|
||||||
@@ -47,4 +84,3 @@ class CommandRunner:
|
|||||||
raise CommandError(command, result.returncode, result.stdout, result.stderr)
|
raise CommandError(command, result.returncode, result.stdout, result.stderr)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ from pathlib import Path
|
|||||||
from app.core.command_runner import CommandRunner
|
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]:
|
def _parse_deb_control_output(output: str) -> dict[str, str]:
|
||||||
metadata: dict[str, str] = {}
|
metadata: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -49,11 +65,23 @@ class DebInstaller:
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
def install_deb(self, file_path: Path) -> None:
|
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:
|
def remove_package(self, package_name: str, purge: bool = False) -> None:
|
||||||
action = "purge" if purge else "remove"
|
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:
|
def get_package_version(self, package_name: str) -> str | None:
|
||||||
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
|
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ function App() {
|
|||||||
const [busyAction, setBusyAction] = useState('');
|
const [busyAction, setBusyAction] = useState('');
|
||||||
const [activeTask, setActiveTask] = useState(null);
|
const [activeTask, setActiveTask] = useState(null);
|
||||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||||
|
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||||
|
|
||||||
const packageBaseUrl = settings.packageBaseUrl;
|
const packageBaseUrl = settings.packageBaseUrl;
|
||||||
const agentBaseUrl = settings.agentBaseUrl;
|
const agentBaseUrl = settings.agentBaseUrl;
|
||||||
@@ -300,15 +301,6 @@ function App() {
|
|||||||
);
|
);
|
||||||
}, [agentHealth?.agentVersion, latestAgentPackage?.version]);
|
}, [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) => {
|
const notify = useCallback((type, message) => {
|
||||||
setToast({ id: Date.now(), type, message });
|
setToast({ id: Date.now(), type, message });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -628,17 +620,18 @@ function App() {
|
|||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!endpointDialogOpen) return undefined;
|
if (!endpointDialogOpen && !agentDialogOpen) return undefined;
|
||||||
|
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
closeEndpointDialog();
|
if (endpointDialogOpen) closeEndpointDialog();
|
||||||
|
if (agentDialogOpen) setAgentDialogOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [closeEndpointDialog, endpointDialogOpen]);
|
}, [agentDialogOpen, closeEndpointDialog, endpointDialogOpen]);
|
||||||
|
|
||||||
const agentStatusTitle = !canUseAgentEndpoint
|
const agentStatusTitle = !canUseAgentEndpoint
|
||||||
? (isClientWindows ? 'Remote endpoint needed' : 'Linux client required')
|
? (isClientWindows ? 'Remote endpoint needed' : 'Linux client required')
|
||||||
@@ -706,6 +699,12 @@ function App() {
|
|||||||
<strong>{topbarAgentState}</strong>
|
<strong>{topbarAgentState}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<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">
|
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
|
||||||
<ExternalLink size={17} aria-hidden="true" />
|
<ExternalLink size={17} aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
@@ -717,34 +716,6 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="page">
|
<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 && (
|
{!canUseAgentEndpoint && (
|
||||||
<ClientOsNotice
|
<ClientOsNotice
|
||||||
agentCommand={agentCommand}
|
agentCommand={agentCommand}
|
||||||
@@ -937,17 +908,6 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="side-stack">
|
<aside className="side-stack">
|
||||||
<AgentPanel
|
|
||||||
health={agentHealth}
|
|
||||||
systemInfo={systemInfo}
|
|
||||||
status={agentStatus}
|
|
||||||
title={agentTargetLabel}
|
|
||||||
endpoint={agentBaseUrl}
|
|
||||||
latestAgentPackage={latestAgentPackage}
|
|
||||||
needsUpdate={agentNeedsUpdate}
|
|
||||||
onCopyUpdate={copyInstallCommand}
|
|
||||||
showAgentActions={canShowAgentCommand}
|
|
||||||
/>
|
|
||||||
{activeTask && (
|
{activeTask && (
|
||||||
<TaskPanel
|
<TaskPanel
|
||||||
task={activeTask}
|
task={activeTask}
|
||||||
@@ -968,6 +928,20 @@ function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{toast && <Toast toast={toast} />}
|
{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 && (
|
{endpointDialogOpen && (
|
||||||
<EndpointDialog
|
<EndpointDialog
|
||||||
draftSettings={draftSettings}
|
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 }) {
|
function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="dialog-backdrop">
|
<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 }) {
|
function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
|
||||||
return (
|
return (
|
||||||
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
|
<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 (
|
return (
|
||||||
<section className="panel">
|
<section className="agent-detail">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{title || 'Agent'}</h2>
|
<h2 id="agent-dialog-title">{title || 'Agent'}</h2>
|
||||||
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
|
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<dl className="detail-list compact-list">
|
<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>
|
<div>
|
||||||
<dt>Version</dt>
|
<dt>Version</dt>
|
||||||
<dd>
|
<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><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||||
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||||
</div>
|
</div>
|
||||||
{needsUpdate && showAgentActions && (
|
{showAgentActions && (
|
||||||
<div className="agent-update-action">
|
<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" />
|
<Clipboard size={15} aria-hidden="true" />
|
||||||
Copy update command
|
{needsUpdate ? 'Copy update command' : 'Copy Agent command'}
|
||||||
</button>
|
</button>
|
||||||
{latestAgentPackage?.downloadUrl && (
|
{needsUpdate && latestAgentPackage?.downloadUrl && (
|
||||||
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
|
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
|
||||||
<Download size={15} aria-hidden="true" />
|
<Download size={15} aria-hidden="true" />
|
||||||
Latest .deb
|
Latest .deb
|
||||||
@@ -1311,7 +1342,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
|||||||
{status.state === 'danger' && (
|
{status.state === 'danger' && (
|
||||||
<div className="table-empty compact-empty danger-text">{status.message}</div>
|
<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 className="component-item" key={item.componentId || item.id || item.packageId}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.componentId || item.code || item.name}</strong>
|
<strong>{item.componentId || item.code || item.name}</strong>
|
||||||
|
|||||||
@@ -336,32 +336,20 @@ code {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
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 {
|
.panel-header p {
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -463,15 +451,100 @@ code {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats {
|
.agent-status-button {
|
||||||
display: grid;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
background: #ffffff;
|
||||||
gap: 14px;
|
border: 1px solid #dbe3ea;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
border-radius: var(--radius);
|
||||||
margin-bottom: 14px;
|
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,
|
.panel,
|
||||||
.table-panel,
|
.table-panel,
|
||||||
.offline-banner {
|
.offline-banner {
|
||||||
@@ -481,14 +554,6 @@ code {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 88px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card > span,
|
|
||||||
.filter-field span,
|
.filter-field span,
|
||||||
.detail-list dt,
|
.detail-list dt,
|
||||||
.component-list-title {
|
.component-list-title {
|
||||||
@@ -499,34 +564,6 @@ code {
|
|||||||
text-transform: uppercase;
|
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 {
|
.offline-banner {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -572,7 +609,7 @@ code {
|
|||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
|
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +626,7 @@ code {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-stack > .panel:not(.app-detail-panel) {
|
.side-stack > .panel:not(.app-detail-panel) {
|
||||||
@@ -597,7 +634,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-detail-panel {
|
.app-detail-panel {
|
||||||
flex: 1 1 360px;
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,6 +643,16 @@ code {
|
|||||||
overflow: visible;
|
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 {
|
.page-filters {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1066,6 +1113,24 @@ tbody tr.selected-row td.action-col {
|
|||||||
width: min(480px, 100%);
|
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 {
|
.dialog-panel form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1150,7 +1215,7 @@ tbody tr.selected-row td.action-col {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1320px) {
|
||||||
.workbench-grid {
|
.workbench-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -1199,15 +1264,17 @@ tbody tr.selected-row td.action-col {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats,
|
|
||||||
.side-stack {
|
.side-stack {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-stack {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.topbar,
|
.topbar,
|
||||||
.page-header,
|
|
||||||
.page-filters,
|
.page-filters,
|
||||||
.offline-banner {
|
.offline-banner {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -1220,27 +1287,29 @@ tbody tr.selected-row td.action-col {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions,
|
.topbar-actions {
|
||||||
.page-actions {
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions .btn,
|
.topbar-actions .btn,
|
||||||
.page-actions .btn,
|
.topbar-actions .agent-status-button {
|
||||||
.page-actions a {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats,
|
|
||||||
.side-stack,
|
.side-stack,
|
||||||
.nav-section {
|
.nav-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-detail-panel .detail-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.offline-banner {
|
.offline-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user