fix UI client
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user