Add safe guided settings workflow

This commit is contained in:
benya
2026-06-04 20:53:27 +03:00
parent 615c256f8b
commit 7d5ba3a402
4 changed files with 238 additions and 51 deletions
+123 -10
View File
@@ -10,6 +10,7 @@ const state = {
alerts: [],
remoteSessions: [],
selectedCommand: null,
presetReview: null,
};
const els = {
@@ -120,6 +121,13 @@ const els = {
presetWifiSsid: document.querySelector("#presetWifiSsid"),
presetWifiKey: document.querySelector("#presetWifiKey"),
presetDhcpLan: document.querySelector("#presetDhcpLan"),
presetReviewPanel: document.querySelector("#presetReviewPanel"),
presetReviewTitle: document.querySelector("#presetReviewTitle"),
presetReviewStatus: document.querySelector("#presetReviewStatus"),
presetReviewChange: document.querySelector("#presetReviewChange"),
presetReviewOutput: document.querySelector("#presetReviewOutput"),
cancelPresetReviewBtn: document.querySelector("#cancelPresetReviewBtn"),
applyPresetReviewBtn: document.querySelector("#applyPresetReviewBtn"),
reloadCommandsBtn: document.querySelector("#reloadCommandsBtn"),
reloadAuditBtn: document.querySelector("#reloadAuditBtn"),
commandSummary: document.querySelector("#commandSummary"),
@@ -696,6 +704,7 @@ async function loadDevices() {
async function selectDevice(id) {
state.selectedDeviceId = id;
state.deviceTab = "overview";
state.presetReview = null;
renderDevices();
renderDeviceDetail(currentDevice());
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]);
@@ -704,6 +713,7 @@ async function selectDevice(id) {
function showFleet() {
state.selectedDeviceId = null;
state.selectedCommand = null;
state.presetReview = null;
renderDevices();
renderDeviceDetail(null);
}
@@ -738,6 +748,7 @@ async function loadCommands() {
const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands?limit=50`);
state.commands = data.commands || [];
renderCommands(state.commands);
renderPresetReview();
if (state.selectedCommand) {
const refreshed = state.commands.find((command) => command.id === state.selectedCommand.id);
state.selectedCommand = refreshed || null;
@@ -933,13 +944,14 @@ function uciSetArgs() {
async function createDeviceCommand(type, args, options = {}) {
if (!state.selectedDeviceId) return;
if (!options.skipConfirm && !confirmDanger(type)) return;
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, {
const command = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, {
method: "POST",
body: JSON.stringify({ type, args }),
});
if (!options.skipRefresh) {
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
}
return command;
}
async function sendBulkCommand() {
@@ -1093,18 +1105,117 @@ function presetCommand(preset) {
}
}
async function sendPresetCommand(preset, action) {
function presetLabel(preset) {
return {
lan_ip: "LAN-адрес",
hostname: "Имя устройства",
wifi_ssid: "Название Wi-Fi",
wifi_key: "Пароль Wi-Fi",
dhcp_lan: "DHCP-сервер",
}[preset] || preset;
}
function presetDisplayValue(preset, value) {
if (preset === "wifi_key") return "Новый пароль будет установлен";
if (preset === "dhcp_lan") return value === "0" ? "Включен" : "Отключен";
return value;
}
function presetSafeOutput(review, output) {
if (review.preset !== "wifi_key") return output || "";
return String(output || "").replaceAll(review.args.value, "********");
}
async function reviewPreset(preset) {
const args = presetCommand(preset);
if (!args) return;
if (!args.value) {
setStatus("Preset value is required");
setStatus("Заполните значение настройки");
return;
}
const type = action === "preview" ? "uci_preview" : "uci_set";
if (!confirmDanger(type)) return;
setStatus(`Queueing ${preset} ${action}`);
await createDeviceCommand(type, args, { skipConfirm: true });
setStatus(`${preset} ${action} queued`);
setStatus("Проверка изменения");
const command = await createDeviceCommand("uci_preview", args, { skipConfirm: true });
state.presetReview = { preset, args, previewCommandId: command.id, applyCommandIds: [] };
renderPresetReview();
els.presetReviewPanel.scrollIntoView({ behavior: "smooth", block: "center" });
setStatus("Изменение отправлено на проверку");
}
function renderPresetReview() {
const review = state.presetReview;
if (!review) {
els.presetReviewPanel.classList.add("is-hidden");
return;
}
els.presetReviewPanel.classList.remove("is-hidden");
els.presetReviewTitle.textContent = presetLabel(review.preset);
els.presetReviewChange.innerHTML = `
<span>${escapeHtml(`${review.args.config}.${review.args.section}.${review.args.option}`)}</span>
<strong>${escapeHtml(presetDisplayValue(review.preset, review.args.value))}</strong>
`;
const preview = state.commands.find((command) => command.id === review.previewCommandId);
const applying = review.applyCommandIds.map((id) => state.commands.find((command) => command.id === id)).filter(Boolean);
const applyFailed = applying.some((command) => ["failed", "cancelled", "expired"].includes(command.status));
const applyDone = applying.length === 2 && applying.every((command) => command.status === "completed");
if (applyFailed) {
els.presetReviewStatus.textContent = "Ошибка применения";
els.presetReviewOutput.textContent = presetSafeOutput(review, applying.map((command) => command.output || `${command.type}: ${command.status}`).join("\n\n"));
els.applyPresetReviewBtn.disabled = true;
return;
}
if (applyDone) {
els.presetReviewStatus.textContent = "Применено безопасно";
els.presetReviewOutput.textContent = presetSafeOutput(review, applying.map((command) => command.output || `${command.type}: completed`).join("\n\n"));
els.applyPresetReviewBtn.disabled = true;
return;
}
if (applying.length) {
els.presetReviewStatus.textContent = "Применяется";
els.presetReviewOutput.textContent = "Настройка применяется. После commit confirmed роутер проверит связь с сервером.";
els.applyPresetReviewBtn.disabled = true;
return;
}
if (!preview || ["queued", "claimed"].includes(preview.status)) {
els.presetReviewStatus.textContent = "Проверяется";
els.presetReviewOutput.textContent = "Ожидание результата проверки от роутера...";
els.applyPresetReviewBtn.disabled = true;
return;
}
if (preview.status !== "completed") {
els.presetReviewStatus.textContent = "Проверка не пройдена";
els.presetReviewOutput.textContent = presetSafeOutput(review, preview.output || `Статус: ${preview.status}`);
els.applyPresetReviewBtn.disabled = true;
return;
}
els.presetReviewStatus.textContent = "Готово к применению";
els.presetReviewOutput.textContent = presetSafeOutput(review, preview.output || "Изменение проверено");
els.applyPresetReviewBtn.disabled = false;
}
async function applyPresetReview() {
const review = state.presetReview;
if (!review) return;
const preview = state.commands.find((command) => command.id === review.previewCommandId);
if (!preview || preview.status !== "completed") {
setStatus("Сначала дождитесь успешной проверки");
return;
}
if (!window.confirm("Применить проверенное изменение? При потере связи роутер автоматически восстановит конфигурацию.")) return;
const staged = await createDeviceCommand("uci_set", review.args, { skipConfirm: true, skipRefresh: true });
const confirmed = await createDeviceCommand("uci_commit_confirmed", {
config: review.args.config,
confirm_seconds: "15",
}, { skipConfirm: true, skipRefresh: true });
review.applyCommandIds = [staged.id, confirmed.id];
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
renderPresetReview();
setStatus("Безопасное применение запущено");
}
function cancelPresetReview() {
state.presetReview = null;
renderPresetReview();
setStatus("Изменение отменено");
}
async function cancelCommand(commandId) {
@@ -1264,11 +1375,13 @@ for (const button of document.querySelectorAll(".copy-info-btn")) {
});
}
for (const button of document.querySelectorAll(".preset-btn")) {
for (const button of document.querySelectorAll(".preset-review-btn")) {
button.addEventListener("click", () => {
sendPresetCommand(button.dataset.preset, button.dataset.action).catch((error) => setStatus(error.message));
reviewPreset(button.dataset.preset).catch((error) => setStatus(error.message));
});
}
els.applyPresetReviewBtn.addEventListener("click", () => applyPresetReview().catch((error) => setStatus(error.message)));
els.cancelPresetReviewBtn.addEventListener("click", cancelPresetReview);
for (const button of document.querySelectorAll(".diagnostic-btn")) {
button.addEventListener("click", () => {
+38 -27
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=5">
<link rel="stylesheet" href="/styles.css?v=6">
</head>
<body>
<main id="loginView" class="login-view">
@@ -548,44 +548,55 @@
</div>
<div class="preset-grid">
<div class="preset">
<h3>LAN IP</h3>
<h3>LAN-адрес</h3>
<p>Изменяет адрес локальной сети роутера</p>
<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>
<button class="preset-review-btn" data-preset="lan_ip" type="button">Проверить изменение</button>
</div>
<div class="preset">
<h3>Hostname</h3>
<h3>Имя устройства</h3>
<p>Изменяет hostname роутера</p>
<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>
<button class="preset-review-btn" data-preset="hostname" type="button">Проверить изменение</button>
</div>
<div class="preset">
<h3>Wi-Fi</h3>
<h3>Название Wi-Fi</h3>
<p>Изменяет SSID основной беспроводной сети</p>
<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>
<button class="preset-review-btn" data-preset="wifi_ssid" type="button">Проверить изменение</button>
</div>
<div class="preset">
<h3>DHCP LAN</h3>
<h3>Пароль Wi-Fi</h3>
<p>Изменяет пароль основной беспроводной сети</p>
<input id="presetWifiKey" type="password" placeholder="Новый пароль">
<button class="preset-review-btn" data-preset="wifi_key" type="button">Проверить изменение</button>
</div>
<div class="preset">
<h3>DHCP-сервер</h3>
<p>Включает или отключает выдачу адресов в LAN</p>
<select id="presetDhcpLan">
<option value="0">enabled</option>
<option value="1">disabled</option>
<option value="0">Включен</option>
<option value="1">Отключен</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>
<button class="preset-review-btn" data-preset="dhcp_lan" type="button">Проверить изменение</button>
</div>
</div>
<section id="presetReviewPanel" class="preset-review-panel is-hidden">
<div class="preset-review-header">
<div>
<span>Подготовленное изменение</span>
<strong id="presetReviewTitle">-</strong>
</div>
<span id="presetReviewStatus" class="summary-pill">Проверка</span>
</div>
<div id="presetReviewChange" class="preset-review-change"></div>
<pre id="presetReviewOutput" class="preset-review-output">Ожидание результата проверки...</pre>
<div class="preset-review-actions">
<button id="cancelPresetReviewBtn" type="button">Отменить</button>
<button id="applyPresetReviewBtn" class="primary" type="button" disabled>Применить безопасно</button>
</div>
<p class="preset-safety-note">После применения роутер проверит связь с сервером. Если связь пропадет, конфигурация будет автоматически восстановлена.</p>
</section>
</section>
<section id="commandsPanel" class="panel" data-device-tab="expert">
@@ -631,6 +642,6 @@
</main>
</div>
<script src="/app.js?v=12" defer></script>
<script src="/app.js?v=13" defer></script>
</body>
</html>
+72 -9
View File
@@ -722,15 +722,17 @@ select {
.preset-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.preset {
display: grid;
gap: 8px;
grid-template-rows: auto auto 1fr auto;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-raised);
padding: 12px;
}
@@ -740,15 +742,76 @@ select {
letter-spacing: 0;
}
.preset div {
display: flex;
flex-wrap: wrap;
gap: 6px;
.preset p {
min-height: 34px;
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.preset-btn {
min-height: 32px;
padding: 0 10px;
.preset-review-btn {
color: var(--accent);
}
.preset-review-panel {
display: grid;
gap: 12px;
border: 1px solid #31596a;
border-radius: 8px;
background: #192a32;
margin-top: 14px;
padding: 14px;
}
.preset-review-header,
.preset-review-actions {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.preset-review-header div {
display: grid;
gap: 3px;
}
.preset-review-header span,
.preset-safety-note {
color: var(--muted);
font-size: 12px;
}
.preset-review-change {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr);
gap: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-raised);
padding: 10px;
}
.preset-review-change span {
color: var(--muted);
}
.preset-review-output {
max-height: 260px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #101820;
color: #d9f3f6;
margin: 0;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.preset-safety-note {
margin: 0;
}
.section-title {