Initial OpenWrt RMM implementation
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+439
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user