Simplify operator operations

This commit is contained in:
benya
2026-06-04 20:26:10 +03:00
parent b4149b86f6
commit ee80018652
4 changed files with 272 additions and 68 deletions
+69 -20
View File
@@ -38,6 +38,8 @@ const els = {
quickDiagnosticBtn: document.querySelector("#quickDiagnosticBtn"),
openLuciBtn: document.querySelector("#openLuciBtn"),
remoteAccessPanel: document.querySelector("#remoteAccessPanel"),
runFullDiagnosticBtn: document.querySelector("#runFullDiagnosticBtn"),
diagnosticStatus: document.querySelector("#diagnosticStatus"),
deviceName: document.querySelector("#deviceName"),
deviceMeta: document.querySelector("#deviceMeta"),
deviceBadge: document.querySelector("#deviceBadge"),
@@ -226,6 +228,17 @@ function statusLabel(status) {
}[status] || status || "-";
}
function remoteStatusLabel(status) {
return {
requested: "Запрашивается",
queued: "Открывается",
active: "Активен",
closed: "Закрыт",
failed: "Ошибка",
expired: "Завершен",
}[status] || statusLabel(status);
}
function alertTypeLabel(type) {
return {
offline: "Роутер не выходит на связь",
@@ -818,9 +831,9 @@ async function loadRemoteSessions() {
function renderRemoteSessions(sessions) {
els.remoteSessionList.innerHTML = "";
const active = sessions.filter((session) => ["requested", "queued", "active"].includes(session.status)).length;
els.remoteSummary.textContent = `${active} open / ${sessions.length} total`;
els.remoteSummary.textContent = active ? `${active} активн.` : "Нет активных сессий";
if (sessions.length === 0) {
els.remoteSessionList.textContent = "No remote sessions";
els.remoteSessionList.innerHTML = '<div class="inline-empty">Удаленный доступ еще не открывался</div>';
return;
}
for (const session of sessions) {
@@ -829,25 +842,29 @@ function renderRemoteSessions(sessions) {
const connectCommand = session.remote_port ? `ssh -p ${session.remote_port} root@${session.server_host || "server"}` : "-";
const canOpenLuCI = session.status === "active" && session.luci_port;
const row = document.createElement("div");
row.className = "row remote-session-row";
row.className = "remote-session-row";
row.innerHTML = `
<div>
<strong>${escapeHtml((session.target || "ssh").toUpperCase())} ${escapeHtml(statusLabel(session.status))}</strong><br>
<small>${escapeHtml(session.id)}</small>
<div class="remote-session-main">
<span class="remote-session-status ${escapeHtml(session.status || "")}"><i></i>${escapeHtml(remoteStatusLabel(session.status))}</span>
<div>
<strong>Доступ к роутеру</strong>
<small>${escapeHtml(endpoint)} · до ${escapeHtml(formatShortDate(session.expires_at))}</small>
</div>
</div>
<span>${escapeHtml(endpoint)}</span>
<span>expires ${escapeHtml(formatShortDate(session.expires_at))}</span>
<code>${escapeHtml(connectCommand)}</code>
<div class="row-actions">
<button type="button" data-action="luci" ${canOpenLuCI ? "" : "disabled"}>Open LuCI</button>
<button type="button" data-action="commands">Commands</button>
<button type="button" data-action="close" ${canClose ? "" : "disabled"}>Close</button>
<button type="button" data-action="copy" ${session.remote_port ? "" : "disabled"}>Копировать SSH</button>
<button class="primary" type="button" data-action="luci" ${canOpenLuCI ? "" : "disabled"}>Открыть LuCI</button>
<button type="button" data-action="close" ${canClose ? "" : "disabled"}>Закрыть</button>
</div>
`;
row.querySelector('[data-action="luci"]').addEventListener("click", () => {
window.open(`/luci/${encodeURIComponent(session.device_id)}/${encodeURIComponent(session.id)}/`, "_blank", "noopener");
});
row.querySelector('[data-action="commands"]').addEventListener("click", scrollToCommands);
row.querySelector('[data-action="copy"]').addEventListener("click", async () => {
await navigator.clipboard.writeText(connectCommand);
setStatus("SSH-команда скопирована");
});
row.querySelector('[data-action="close"]').addEventListener("click", () => closeRemoteSession(session.id));
els.remoteSessionList.appendChild(row);
}
@@ -889,7 +906,9 @@ async function createDeviceCommand(type, args, options = {}) {
method: "POST",
body: JSON.stringify({ type, args }),
});
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
if (!options.skipRefresh) {
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
}
}
async function sendBulkCommand() {
@@ -967,8 +986,8 @@ async function createRemoteSession() {
setStatus("Tunnel server is required");
return;
}
if (!window.confirm("Open temporary SSH access to this router?")) return;
setStatus("Opening remote SSH access");
if (!window.confirm(`Открыть временный доступ к роутеру на ${Math.round(durationSeconds / 60)} минут?`)) return;
setStatus("Открытие удаленного доступа");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/remote-sessions`, {
method: "POST",
body: JSON.stringify({
@@ -982,17 +1001,18 @@ async function createRemoteSession() {
}),
});
await Promise.all([loadRemoteSessions(), loadCommands(), loadAudit()]);
setStatus("Remote SSH command queued");
setStatus("Команда открытия доступа отправлена");
}
async function closeRemoteSession(sessionId) {
if (!state.selectedDeviceId) return;
setStatus("Closing remote session");
if (!window.confirm("Закрыть удаленный доступ к роутеру?")) return;
setStatus("Закрытие удаленного доступа");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/remote-sessions/${encodeURIComponent(sessionId)}/close`, {
method: "POST",
});
await Promise.all([loadRemoteSessions(), loadAudit()]);
setStatus("Remote session closed");
setStatus("Удаленный доступ закрыт");
}
function presetCommand(preset) {
@@ -1097,12 +1117,40 @@ function diagnosticCommand(name) {
async function sendDiagnostic(name) {
const command = diagnosticCommand(name);
if (!command) return;
setStatus(`Queueing ${command.type}`);
setStatus("Запуск проверки");
await createDeviceCommand(command.type, command.args);
setStatus("Diagnostic queued");
setStatus("Проверка поставлена в очередь");
scrollToCommands();
}
async function runFullDiagnostic() {
const checks = ["ping_server", "ping_internet", "show_routes", "show_interfaces"];
els.runFullDiagnosticBtn.disabled = true;
els.diagnosticStatus.classList.add("is-running");
els.diagnosticStatus.innerHTML = `
<span class="operation-icon">↻</span>
<div><strong>Диагностика запускается</strong><small>Отправляем проверки на роутер</small></div>
`;
try {
for (const name of checks) {
const command = diagnosticCommand(name);
await createDeviceCommand(command.type, command.args, { skipRefresh: true });
}
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
els.diagnosticStatus.classList.remove("is-running");
els.diagnosticStatus.classList.add("is-complete");
els.diagnosticStatus.innerHTML = `
<span class="operation-icon">✓</span>
<div><strong>Диагностика запущена</strong><small>Результаты появятся в истории команд</small></div>
<button id="openDiagnosticResultsBtn" type="button">Открыть результаты</button>
`;
els.diagnosticStatus.querySelector("#openDiagnosticResultsBtn").addEventListener("click", scrollToCommands);
setStatus("Полная диагностика поставлена в очередь");
} finally {
els.runFullDiagnosticBtn.disabled = false;
}
}
async function runAlertDiagnostics(alert) {
const target = alert.details && alert.details.target ? alert.details.target : "1.1.1.1";
if (alert.type === "latency_high" || alert.type === "packet_loss_high" || alert.type === "wan_down") {
@@ -1150,6 +1198,7 @@ els.refreshBtn.addEventListener("click", () => loadDevices().catch((error) => se
els.backToFleetBtn.addEventListener("click", showFleet);
els.quickDiagnosticBtn.addEventListener("click", () => selectDeviceTab("operations"));
els.openLuciBtn.addEventListener("click", openLuciOrRemoteAccess);
els.runFullDiagnosticBtn.addEventListener("click", () => runFullDiagnostic().catch((error) => setStatus(error.message)));
els.reloadCommandsBtn.addEventListener("click", () => loadCommands().catch((error) => setStatus(error.message)));
els.reloadAuditBtn.addEventListener("click", () => loadAudit().catch((error) => setStatus(error.message)));
els.reloadAlertsBtn.addEventListener("click", () => loadAlerts().catch((error) => setStatus(error.message)));
+56 -37
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenWrt RMM</title>
<link rel="stylesheet" href="/styles.css?v=3">
<link rel="stylesheet" href="/styles.css?v=4">
</head>
<body>
<main id="loginView" class="login-view">
@@ -258,9 +258,20 @@
<section class="panel" data-device-tab="operations">
<div class="section-title">
<h2>Быстрая диагностика</h2>
<span class="summary-pill">без SSH</span>
<div>
<h2>Диагностика</h2>
<p class="section-subtitle">Проверки выполняются агентом без подключения по SSH</p>
</div>
<button id="runFullDiagnosticBtn" class="primary" type="button">Запустить полную диагностику</button>
</div>
<div id="diagnosticStatus" class="operation-status">
<span class="operation-icon"></span>
<div>
<strong>Готово к проверке</strong>
<small>Будут проверены сервер, интернет, маршруты и интерфейсы</small>
</div>
</div>
<h3 class="operation-subheading">Выборочная проверка</h3>
<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>
@@ -397,38 +408,18 @@
<section id="remoteAccessPanel" class="panel" data-device-tab="operations">
<div class="section-title">
<h2>Remote access</h2>
<div>
<h2>Удаленный доступ</h2>
<p class="section-subtitle">Временный доступ к LuCI и SSH автоматически закроется по таймеру</p>
</div>
<div class="section-actions">
<span id="remoteSummary" class="summary-pill">0 sessions</span>
<button id="reloadRemoteSessionsBtn" type="button">Reload</button>
<span id="remoteSummary" class="summary-pill">Нет активных сессий</span>
<button id="reloadRemoteSessionsBtn" class="icon-button" type="button" title="Обновить сессии" aria-label="Обновить сессии"></button>
</div>
</div>
<div class="remote-form">
<div class="remote-quick-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>
LuCI protocol
<select id="remoteLuCIScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</label>
<label>
Duration
Длительность доступа
<select id="remoteDuration">
<option value="900">15 min</option>
<option value="1800">30 min</option>
@@ -436,17 +427,45 @@
<option value="7200">2 hours</option>
</select>
</label>
<button id="createRemoteSessionBtn" type="button">Open remote access</button>
</div>
<div class="remote-hint">
Active sessions provide SSH and an authenticated Open LuCI button. Choose the protocol used by this router.
<button id="createRemoteSessionBtn" class="primary" type="button">Открыть удаленный доступ</button>
</div>
<details class="advanced-settings">
<summary>Дополнительные настройки подключения</summary>
<div class="remote-form">
<label>
Сервер туннеля
<input id="remoteServerHost" type="text" placeholder="10.10.10.2">
</label>
<label>
SSH-порт сервера
<input id="remoteServerPort" type="number" min="1" max="65535" value="2222">
</label>
<label>
Внешний порт
<input id="remotePort" type="number" min="1" max="65535" placeholder="автоматически">
</label>
<label>
SSH-порт роутера
<input id="remoteLocalPort" type="number" min="1" max="65535" value="22">
</label>
<label>
Протокол LuCI
<select id="remoteLuCIScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</label>
</div>
</details>
<div id="remoteSessionList" class="table-list remote-session-list"></div>
</section>
<section class="panel" data-device-tab="operations">
<div class="section-title">
<h2>UCI presets</h2>
<div>
<h2>Быстрые настройки</h2>
<p class="section-subtitle">Сначала проверьте изменения, затем примените их</p>
</div>
</div>
<div class="preset-grid">
<div class="preset">
@@ -533,6 +552,6 @@
</main>
</div>
<script src="/app.js?v=10" defer></script>
<script src="/app.js?v=11" defer></script>
</body>
</html>
+140 -4
View File
@@ -473,7 +473,7 @@ select {
.remote-form {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(5, minmax(110px, 0.6fr)) auto;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, 0.6fr));
gap: 10px;
align-items: end;
}
@@ -485,12 +485,36 @@ select {
font-size: 12px;
}
.remote-hint {
margin-top: 10px;
.remote-quick-form {
display: grid;
grid-template-columns: minmax(180px, 280px) auto;
gap: 10px;
align-items: end;
}
.remote-quick-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.advanced-settings {
margin-top: 12px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.advanced-settings summary {
color: var(--muted);
cursor: pointer;
font-size: 13px;
}
.advanced-settings .remote-form {
margin-top: 12px;
}
.remote-session-list {
margin-top: 12px;
}
@@ -501,6 +525,64 @@ select {
white-space: nowrap;
}
.remote-session-row {
display: grid;
grid-template-columns: minmax(260px, 1.3fr) minmax(260px, 1fr) auto;
gap: 14px;
align-items: center;
min-height: 70px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-raised);
padding: 12px;
}
.remote-session-main {
display: flex;
min-width: 0;
gap: 12px;
align-items: center;
}
.remote-session-main div {
display: grid;
min-width: 0;
gap: 4px;
}
.remote-session-main small {
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-session-status {
display: inline-flex;
gap: 6px;
align-items: center;
color: var(--warn);
font-size: 12px;
font-weight: 700;
}
.remote-session-status i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.remote-session-status.active {
color: var(--good);
}
.remote-session-status.closed,
.remote-session-status.failed,
.remote-session-status.expired {
color: var(--muted);
}
.health-summary,
.diagnostic-grid {
display: grid;
@@ -547,7 +629,7 @@ select {
}
.diagnostic-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(130px, 1fr));
}
.diagnostic-btn {
@@ -555,6 +637,55 @@ select {
white-space: normal;
}
.operation-status {
display: flex;
gap: 12px;
align-items: center;
min-height: 64px;
border: 1px solid #3b663e;
border-radius: 8px;
background: #203329;
padding: 12px;
}
.operation-status.is-running {
border-color: #31596a;
background: #223640;
}
.operation-status div {
display: grid;
flex: 1;
gap: 3px;
}
.operation-status small {
color: var(--muted);
}
.operation-icon {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 34px;
place-items: center;
border-radius: 50%;
background: #31533a;
color: var(--good);
font-size: 18px;
font-weight: 800;
}
.operation-status.is-running .operation-icon {
background: #2e5869;
color: var(--accent);
}
.operation-subheading {
margin: 18px 0 10px;
font-size: 13px;
}
.uci-grid {
display: grid;
grid-template-columns: 150px minmax(160px, 1fr) minmax(140px, 0.8fr) minmax(180px, 1fr) 180px;
@@ -1263,6 +1394,11 @@ select {
flex-wrap: wrap;
}
.remote-quick-form,
.remote-session-row {
grid-template-columns: 1fr;
}
.client-toolbar,
.network-health {
grid-template-columns: 1fr;