Initial OpenWrt RMM implementation

This commit is contained in:
benya
2026-06-04 15:26:43 +03:00
commit 3cc9385f14
44 changed files with 9892 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# OpenWrt RMM Web UI
Zero-dependency MVP web UI served by the Go server.
## Run
```sh
go run ./server/cmd/rmm-server
```
Then open:
```text
http://127.0.0.1:8080/
```
The UI uses the same origin as the API and stores the MVP operator token in browser local storage.
Current MVP features:
- device list and detail;
- command history and command detail output;
- command status filters;
- output copy;
- UCI backup, preview, staged set, commit, confirmed commit, revert, and restore;
- UCI presets for LAN IP, hostname, Wi-Fi SSID/password, and LAN DHCP toggle.
## Check
```sh
npm.cmd run check:web
```
+1054
View File
File diff suppressed because it is too large Load Diff
+439
View File
@@ -0,0 +1,439 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenWrt RMM</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main id="loginView" class="login-view">
<form id="loginForm" class="login-form">
<div class="brand login-brand">
<div class="brand-mark">R</div>
<div>
<h1>OpenWrt RMM</h1>
<p>Operator access</p>
</div>
</div>
<label>
Username
<input id="loginUsername" type="text" autocomplete="username" value="admin" required>
</label>
<label>
Password
<input id="loginPassword" type="password" autocomplete="current-password" required>
</label>
<button class="primary" type="submit">Sign in</button>
<p id="loginError" class="login-error"></p>
</form>
</main>
<div id="appShell" class="app-shell is-hidden">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">R</div>
<div>
<h1>OpenWrt RMM</h1>
<p id="apiState">API: checking</p>
</div>
</div>
<section class="panel auth-panel">
<div class="operator-row">
<div>
<small>Signed in as</small>
<strong id="operatorName">operator</strong>
</div>
<button id="logoutBtn" type="button">Sign out</button>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>Devices</h2>
<button id="refreshBtn" type="button">Refresh</button>
</div>
<div class="filters">
<button class="filter is-active" data-filter="all" type="button">Все</button>
<button class="filter" data-filter="online" type="button">Онлайн</button>
<button class="filter" data-filter="offline" type="button">Офлайн</button>
<button class="filter" data-filter="alerts" type="button">Проблемы</button>
</div>
<div class="fleet-filter-grid">
<input id="fleetSearch" type="search" placeholder="Поиск по имени, ID, тегу">
<input id="fleetGroupFilter" type="text" placeholder="Группа">
<input id="fleetTagFilter" type="text" placeholder="Тег">
</div>
<div class="bulk-command-grid">
<select id="bulkCommandType">
<option value="ping">ping</option>
<option value="traceroute">traceroute</option>
<option value="pkg_list_installed">pkg_list_installed</option>
</select>
<input id="bulkCommandTarget" type="text" placeholder="цель">
<button id="sendBulkCommandBtn" type="button">Запустить для выбранных</button>
</div>
<div id="deviceList" class="device-list"></div>
</section>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">Fleet</p>
<h2 id="pageTitle">Devices</h2>
</div>
<div id="statusLine" class="status-line">Idle</div>
</header>
<section id="emptyState" class="empty-state">
<h2>No device selected</h2>
<p>Waiting for agent check-in.</p>
</section>
<section id="deviceView" class="device-view is-hidden">
<div class="device-header">
<div>
<p class="eyebrow">Device</p>
<h2 id="deviceName"></h2>
<p id="deviceMeta"></p>
</div>
<span id="deviceBadge" class="badge"></span>
</div>
<section class="panel">
<div class="section-title">
<h2>Состояние роутера</h2>
<span class="summary-pill">кратко</span>
</div>
<div id="healthSummary" class="health-summary"></div>
</section>
<section class="panel">
<div class="section-title">
<h2>Группа и теги</h2>
<button id="saveFleetBtn" type="button">Сохранить</button>
</div>
<div class="fleet-form">
<label>
Group
<input id="fleetGroup" type="text" placeholder="core, lab, home">
</label>
<label>
Tags
<input id="fleetTags" type="text" placeholder="edge, vpn, test">
</label>
</div>
</section>
<div class="metrics-grid">
<div class="metric">
<span>Last seen</span>
<strong id="lastSeen">-</strong>
</div>
<div class="metric">
<span>Load average</span>
<strong id="loadAvg">-</strong>
</div>
<div class="metric">
<span>Uptime</span>
<strong id="uptime">-</strong>
</div>
<div class="metric">
<span>Default route</span>
<strong id="defaultRoute">-</strong>
</div>
<div class="metric">
<span>WAN IP</span>
<strong id="wanIp">-</strong>
</div>
<div class="metric">
<span>Memory</span>
<strong id="memoryUsage">-</strong>
</div>
<div class="metric">
<span>Disk</span>
<strong id="diskUsage">-</strong>
</div>
<div class="metric">
<span>Clients</span>
<strong id="clientCounts">-</strong>
</div>
<div class="metric">
<span>Connectivity</span>
<strong id="connectivityStatus">-</strong>
</div>
</div>
<div class="two-column">
<section class="panel">
<div class="section-title">
<h2>Inventory</h2>
</div>
<pre id="inventoryJson" class="code-block"></pre>
</section>
<section class="panel">
<div class="section-title">
<h2>Команда вручную</h2>
</div>
<div class="command-form">
<select id="commandType">
<option value="ping">ping</option>
<option value="traceroute">traceroute</option>
<option value="route_show">show routes</option>
<option value="interfaces_show">show interfaces</option>
<option value="pkg_list_installed">pkg_list_installed</option>
<option value="reboot">reboot</option>
</select>
<input id="commandTarget" type="text" placeholder="1.1.1.1">
<button id="sendCommandBtn" type="button">Запустить</button>
</div>
</section>
</div>
<section class="panel">
<div class="section-title">
<h2>Быстрая диагностика</h2>
<span class="summary-pill">без SSH</span>
</div>
<div class="diagnostic-grid">
<button class="diagnostic-btn" data-diagnostic="ping_server" type="button">Проверить связь с сервером</button>
<button class="diagnostic-btn" data-diagnostic="ping_internet" type="button">Проверить интернет</button>
<button class="diagnostic-btn" data-diagnostic="traceroute_internet" type="button">Маршрут до интернета</button>
<button class="diagnostic-btn" data-diagnostic="show_routes" type="button">Показать маршруты</button>
<button class="diagnostic-btn" data-diagnostic="show_interfaces" type="button">Показать интерфейсы</button>
</div>
</section>
<div class="two-column">
<section class="panel">
<div class="section-title">
<h2>Leases and Wi-Fi</h2>
</div>
<div id="clientList" class="table-list"></div>
</section>
<section class="panel">
<div class="section-title">
<h2>Interface counters</h2>
</div>
<div id="interfaceCounters" class="table-list"></div>
</section>
</div>
<section class="panel">
<div class="section-title">
<h2>Проблемы</h2>
<div class="section-actions">
<span id="alertSummary" class="summary-pill">0 active</span>
<button id="reloadAlertsBtn" type="button">Обновить</button>
</div>
</div>
<div id="alertList" class="table-list"></div>
</section>
<section class="panel">
<div class="section-title">
<h2>История мониторинга</h2>
<button id="reloadMetricsHistoryBtn" type="button">Обновить</button>
</div>
<div id="metricsHistory" class="table-list"></div>
</section>
<section class="panel">
<div class="section-title">
<h2>UCI configuration</h2>
</div>
<div class="uci-grid">
<label>
Package
<select id="uciConfig">
<option value="network">network</option>
<option value="wireless">wireless</option>
<option value="dhcp">dhcp</option>
<option value="firewall">firewall</option>
<option value="system">system</option>
</select>
</label>
<label>
Section
<input id="uciSection" type="text" placeholder="lan or @wifi-iface[0]">
</label>
<label>
Option
<input id="uciOption" type="text" placeholder="ipaddr">
</label>
<label>
Value
<input id="uciValue" type="text" placeholder="192.168.1.1">
</label>
<label class="check-row">
<input id="uciCommitNow" type="checkbox">
Commit immediately
</label>
</div>
<div class="uci-actions">
<button id="uciBackupBtn" type="button">Backup</button>
<button id="uciPreviewBtn" type="button">Preview</button>
<button id="uciShowBtn" type="button">Show</button>
<button id="uciSetBtn" type="button">Stage set</button>
<button id="uciCommitBtn" type="button">Commit</button>
<button id="uciCommitConfirmedBtn" type="button">Commit + check</button>
<button id="uciRevertBtn" type="button">Revert</button>
<button id="uciRestoreBtn" type="button">Restore backup</button>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>Packages</h2>
</div>
<div class="package-grid">
<select id="packageCommand">
<option value="pkg_list_installed">List installed</option>
<option value="pkg_update">Update package index</option>
<option value="pkg_list_upgradable">List upgradable</option>
<option value="pkg_install">Install package</option>
<option value="pkg_remove">Remove package</option>
</select>
<input id="packageName" type="text" placeholder="package name">
<button id="sendPackageCommandBtn" type="button">Queue</button>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>Remote access</h2>
<div class="section-actions">
<span id="remoteSummary" class="summary-pill">0 sessions</span>
<button id="reloadRemoteSessionsBtn" type="button">Reload</button>
</div>
</div>
<div class="remote-form">
<label>
Tunnel server
<input id="remoteServerHost" type="text" placeholder="10.10.10.2">
</label>
<label>
Server SSH port
<input id="remoteServerPort" type="number" min="1" max="65535" value="2222">
</label>
<label>
Remote port
<input id="remotePort" type="number" min="1" max="65535" placeholder="auto">
</label>
<label>
Router SSH port
<input id="remoteLocalPort" type="number" min="1" max="65535" value="22">
</label>
<label>
Duration
<select id="remoteDuration">
<option value="900">15 min</option>
<option value="1800">30 min</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
</select>
</label>
<button id="createRemoteSessionBtn" type="button">Open SSH access</button>
</div>
<div class="remote-hint">
Docker/Linux: tunnel endpoint must accept SSH for user <strong>rmm-tunnel</strong> and allow reverse ports.
</div>
<div id="remoteSessionList" class="table-list remote-session-list"></div>
</section>
<section id="commandsPanel" class="panel">
<div class="section-title">
<h2>UCI presets</h2>
</div>
<div class="preset-grid">
<div class="preset">
<h3>LAN IP</h3>
<input id="presetLanIp" type="text" placeholder="10.10.10.1/24">
<div>
<button class="preset-btn" data-preset="lan_ip" data-action="preview" type="button">Preview</button>
<button class="preset-btn" data-preset="lan_ip" data-action="stage" type="button">Stage</button>
</div>
</div>
<div class="preset">
<h3>Hostname</h3>
<input id="presetHostname" type="text" placeholder="OpenWrt">
<div>
<button class="preset-btn" data-preset="hostname" data-action="preview" type="button">Preview</button>
<button class="preset-btn" data-preset="hostname" data-action="stage" type="button">Stage</button>
</div>
</div>
<div class="preset">
<h3>Wi-Fi</h3>
<input id="presetWifiSsid" type="text" placeholder="SSID">
<input id="presetWifiKey" type="password" placeholder="Password">
<div>
<button class="preset-btn" data-preset="wifi_ssid" data-action="preview" type="button">SSID preview</button>
<button class="preset-btn" data-preset="wifi_ssid" data-action="stage" type="button">SSID stage</button>
<button class="preset-btn" data-preset="wifi_key" data-action="preview" type="button">Key preview</button>
<button class="preset-btn" data-preset="wifi_key" data-action="stage" type="button">Key stage</button>
</div>
</div>
<div class="preset">
<h3>DHCP LAN</h3>
<select id="presetDhcpLan">
<option value="0">enabled</option>
<option value="1">disabled</option>
</select>
<div>
<button class="preset-btn" data-preset="dhcp_lan" data-action="preview" type="button">Preview</button>
<button class="preset-btn" data-preset="dhcp_lan" data-action="stage" type="button">Stage</button>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>История команд</h2>
<div class="section-actions">
<span id="commandSummary" class="summary-pill">0 total</span>
<button id="reloadCommandsBtn" type="button">Обновить</button>
</div>
</div>
<div class="filters command-filters">
<button class="command-filter is-active" data-command-filter="all" type="button">All</button>
<button class="command-filter" data-command-filter="queued" type="button">Queued</button>
<button class="command-filter" data-command-filter="claimed" type="button">Running</button>
<button class="command-filter" data-command-filter="completed" type="button">Done</button>
<button class="command-filter" data-command-filter="failed" type="button">Failed</button>
<button class="command-filter" data-command-filter="expired" type="button">Expired</button>
<button class="command-filter" data-command-filter="cancelled" type="button">Cancelled</button>
</div>
<div id="commandList" class="table-list"></div>
</section>
<section id="commandDetailPanel" class="panel is-hidden">
<div class="section-title">
<h2>Command detail</h2>
<button id="copyCommandOutputBtn" type="button">Copy output</button>
</div>
<div id="commandDetailMeta" class="detail-meta"></div>
<pre id="commandDetailOutput" class="command-detail-output"></pre>
</section>
<section class="panel">
<div class="section-title">
<h2>Audit</h2>
<div class="section-actions">
<span id="auditSummary" class="summary-pill">0 events</span>
<button id="reloadAuditBtn" type="button">Reload</button>
</div>
</div>
<div id="auditList" class="table-list"></div>
</section>
</section>
</main>
</div>
<script src="/app.js?v=8" defer></script>
</body>
</html>
+793
View File
@@ -0,0 +1,793 @@
:root {
color-scheme: light;
--bg: #f4f6f8;
--surface: #ffffff;
--surface-muted: #eef2f5;
--text: #17202a;
--muted: #667085;
--line: #d9e0e7;
--accent: #166f7a;
--accent-strong: #10545c;
--good: #137a48;
--bad: #b42318;
--warn: #b54708;
--shadow: 0 14px 35px rgba(23, 32, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
color: var(--text);
cursor: pointer;
min-height: 36px;
padding: 0 12px;
}
button:hover {
border-color: var(--accent);
}
button.primary,
#sendCommandBtn {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.primary:hover,
#sendCommandBtn:hover {
background: var(--accent-strong);
}
input,
select {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
padding: 0 10px;
}
.app-shell {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 100vh;
}
.is-hidden {
display: none !important;
}
.login-view {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
}
.login-form {
display: grid;
width: min(100%, 380px);
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow);
padding: 24px;
}
.login-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
.login-brand {
margin-bottom: 6px;
}
.login-error {
min-height: 20px;
margin: 0;
color: var(--bad);
font-size: 13px;
}
.sidebar {
border-right: 1px solid var(--line);
background: #fbfcfd;
padding: 18px;
overflow: auto;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 800;
}
.brand h1,
.topbar h2,
.device-header h2,
.section-title h2,
.empty-state h2 {
margin: 0;
letter-spacing: 0;
}
.brand h1 {
font-size: 18px;
}
.brand p,
.device-header p,
.empty-state p {
margin: 4px 0 0;
color: var(--muted);
}
.workspace {
min-width: 0;
padding: 22px;
}
.topbar,
.device-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.status-line {
color: var(--muted);
font-size: 14px;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 14px;
padding: 14px;
}
.auth-panel label {
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.operator-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.operator-row div {
display: grid;
gap: 3px;
}
.operator-row small {
color: var(--muted);
}
.token-row,
.command-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.command-form {
grid-template-columns: 170px minmax(0, 1fr) auto;
}
.fleet-form,
.package-grid {
display: grid;
grid-template-columns: minmax(180px, 0.7fr) minmax(220px, 1fr) auto;
gap: 10px;
align-items: end;
}
.fleet-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.remote-grid {
display: grid;
gap: 8px;
}
.remote-form {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, 0.6fr)) auto;
gap: 10px;
align-items: end;
}
.remote-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.remote-hint {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
.remote-session-list {
margin-top: 12px;
}
.remote-session-row code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.health-summary,
.diagnostic-grid {
display: grid;
gap: 8px;
}
.health-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.health-item {
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
.health-item span {
color: var(--muted);
font-size: 12px;
}
.health-item strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.health-item.ok {
border-color: #abefc6;
background: #f6fef9;
}
.health-item.warn {
border-color: #fedf89;
background: #fffbeb;
}
.health-item.bad {
border-color: #fecdca;
background: #fffbfa;
}
.diagnostic-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.diagnostic-btn {
min-height: 46px;
white-space: normal;
}
.uci-grid {
display: grid;
grid-template-columns: 150px minmax(160px, 1fr) minmax(140px, 0.8fr) minmax(180px, 1fr) 180px;
gap: 10px;
align-items: end;
}
.uci-grid label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.uci-grid .check-row {
display: flex;
align-items: center;
min-height: 36px;
color: var(--text);
font-size: 14px;
}
.uci-grid .check-row input {
width: auto;
min-height: 0;
}
.uci-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.preset {
display: grid;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
}
.preset h3 {
margin: 0;
font-size: 14px;
letter-spacing: 0;
}
.preset div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-btn {
min-height: 32px;
padding: 0 10px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-actions {
display: flex;
align-items: center;
gap: 8px;
}
.summary-pill {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
padding: 5px 9px;
white-space: nowrap;
}
.section-title h2 {
font-size: 16px;
}
.filters {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.fleet-filter-grid,
.bulk-command-grid {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.fleet-filter-grid {
grid-template-columns: 1fr 1fr;
}
.fleet-filter-grid input:first-child {
grid-column: 1 / -1;
}
.bulk-command-grid {
grid-template-columns: 1fr 1fr;
}
.bulk-command-grid button {
grid-column: 1 / -1;
}
.command-filters {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.filter.is-active,
.command-filter.is-active {
background: var(--surface-muted);
border-color: var(--accent);
color: var(--accent-strong);
font-weight: 700;
}
.device-list {
display: grid;
gap: 8px;
}
.device-item {
display: grid;
gap: 5px;
width: 100%;
min-height: 72px;
padding: 10px;
text-align: left;
}
.device-item.is-selected {
border-color: var(--accent);
background: #f1fbfc;
}
.device-item strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-item span {
color: var(--muted);
font-size: 13px;
}
.device-item .device-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.device-alert-count {
border-radius: 999px;
background: #fef3f2;
color: var(--bad);
font-size: 12px;
font-weight: 800;
padding: 2px 7px;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
border-radius: 999px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
}
.badge.online {
background: #e7f6ee;
color: var(--good);
}
.badge.offline {
background: #fef3f2;
color: var(--bad);
}
.empty-state {
display: grid;
min-height: 360px;
place-content: center;
text-align: center;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metric {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
padding: 12px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
}
.metric strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) minmax(100px, 1fr);
gap: 8px;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px;
}
.mini-row span,
.mini-row small {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-row.warning {
border-color: #fedf89;
background: #fffbeb;
}
.alert-row {
grid-template-columns: minmax(170px, 1fr) 150px minmax(220px, 1.4fr) auto auto auto;
}
.alert-row.critical {
border-color: #fecdca;
background: #fffbfa;
}
.alert-row.warning span {
color: var(--warn);
font-weight: 700;
}
.alert-row.critical span {
color: var(--bad);
font-weight: 700;
}
.alert-row.acknowledged {
border-style: dashed;
opacity: 0.82;
}
.alert-detail {
min-width: 0;
}
.alert-detail summary {
overflow: hidden;
color: var(--muted);
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-detail div {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.two-column {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
gap: 14px;
}
.code-block {
max-height: 280px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #101820;
color: #d9f3f6;
margin: 0;
padding: 12px;
font-size: 12px;
line-height: 1.5;
}
.table-list {
display: grid;
gap: 8px;
}
.row {
display: grid;
grid-template-columns: minmax(160px, 1fr) 110px 90px 90px minmax(160px, 1fr) auto;
gap: 10px;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
.lifecycle-time {
color: var(--muted);
font-size: 13px;
}
.row.audit {
grid-template-columns: 160px minmax(140px, 1fr) minmax(180px, 2fr);
}
.row small {
color: var(--muted);
}
.row-actions {
display: flex;
gap: 6px;
}
.command-output {
min-width: 0;
}
.command-output summary {
overflow: hidden;
color: var(--muted);
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-output pre {
max-height: 260px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #101820;
color: #d9f3f6;
margin: 8px 0 0;
padding: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.detail-meta span {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
padding: 4px 10px;
}
.command-detail-output {
max-height: 520px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #101820;
color: #d9f3f6;
margin: 0;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.output-section {
color: #9fe7ef;
font-weight: 800;
}
.status {
font-weight: 700;
}
.status.completed {
color: var(--good);
}
.status.failed,
.status.cancelled,
.status.expired {
color: var(--bad);
}
.status.queued,
.status.claimed {
color: var(--warn);
}
@media (max-width: 980px) {
.app-shell,
.two-column,
.metrics-grid {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.row,
.row.audit,
.command-form,
.fleet-form,
.package-grid,
.health-summary,
.diagnostic-grid,
.uci-grid,
.preset-grid {
grid-template-columns: 1fr;
}
.alert-row {
grid-template-columns: 1fr;
}
}