Add safe guided settings workflow
This commit is contained in:
+123
-10
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user