From 2b34b1139179b0751de81efe3582186e0689225a Mon Sep 17 00:00:00 2001 From: savsis Date: Mon, 14 Sep 2026 07:35:44 +0500 Subject: [PATCH] =?UTF-8?q?fix:=2012=20admin=20actions=20failed=20complete?= =?UTF-8?q?ly=20silently=20on=20error=20=E2=80=94=20no=20message,=20no=20v?= =?UTF-8?q?isible=20change,=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result
(createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 --- admin.html | 89 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/admin.html b/admin.html index ce75d1b..07e0588 100644 --- a/admin.html +++ b/admin.html @@ -1335,7 +1335,13 @@ async function loadTotpStatus() { } async function startEnableTotp() { - const res = await api("/admin/api/2fa/setup", { method: "POST" }); + let res; + try { + res = await api("/admin/api/2fa/setup", { method: "POST" }); + } catch (e) { + alert("Не получилось: " + e.message); + return; + } document.getElementById("totp-secret-display").textContent = res.secret; document.getElementById("totp-setup-box").dataset.secret = res.secret; document.getElementById("totp-setup-box").style.display = "block"; @@ -1525,7 +1531,11 @@ async function grantSubscription() { const node = DD.ucGrantNode.getValue(); const plan = DD.ucGrantPlan.getValue(); if (!node || !plan) return; - await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) }); + try { + await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) }); + } catch (e) { + alert("Не получилось: " + e.message); + } openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", "")); } function renderDevices(devices) { @@ -1541,28 +1551,48 @@ function renderDevices(devices) { `).join("") : '
Нет привязанных устройств
'; } async function deleteDevice(deviceId) { - await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" }); + try { + await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" }); + } catch (e) { + alert("Не получилось: " + e.message); + } const data = await api(`/admin/api/users/${devicesTgId}`); renderDevices(data.devices); } async function saveHwidLimit() { const val = document.getElementById("devices-limit").value.trim(); - await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) }); + try { + await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) }); + } catch (e) { + alert("Не получилось: " + e.message); + } } async function revokeSub(uuid) { if (!confirm("Отозвать подписку?")) return; - await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" }); + try { + await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" }); + } catch (e) { + alert("Не получилось: " + e.message); + } loadSubscriptions(); } async function holdSub(uuid) { - await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" }); + try { + await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" }); + } catch (e) { + alert("Не получилось: " + e.message); + } loadSubscriptions(); } async function resumeSub(uuid) { - await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" }); + try { + await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" }); + } catch (e) { + alert("Не получилось: " + e.message); + } loadSubscriptions(); } @@ -1590,9 +1620,14 @@ async function loadGifts() { async function createGift() { const node = DD.giftNode.getValue(); const plan = DD.giftPlan.getValue(); - const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) }); - document.getElementById("gift-result").innerHTML = `
${res.link}
`; - loadGifts(); + const result = document.getElementById("gift-result"); + try { + const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) }); + result.innerHTML = `
${res.link}
`; + loadGifts(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } } function copyText(t) { navigator.clipboard.writeText(t); } @@ -1720,12 +1755,20 @@ async function saveEditNode() { } async function toggleNode(code, enabled) { - await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); + try { + await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); + } catch (e) { + alert("Не получилось: " + e.message); + } loadNodes(); } async function deleteNode(code) { if (!confirm("Удалить ноду?")) return; - await api(`/admin/api/nodes/${code}`, { method: "DELETE" }); + try { + await api(`/admin/api/nodes/${code}`, { method: "DELETE" }); + } catch (e) { + alert("Не получилось: " + e.message); + } loadNodes(); } @@ -1745,8 +1788,15 @@ async function generateGuide() { const include_hysteria2 = document.getElementById("ng-hy").checked; const hysteria_port = parseInt(document.getElementById("ng-hy-port").value || "443"); if (!label || !address) return; - const res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) }); - document.getElementById("guide-result").innerHTML = ` + const guideResult = document.getElementById("guide-result"); + let res; + try { + res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) }); + } catch (e) { + guideResult.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + return; + } + guideResult.innerHTML = `

Выполни на новом сервере:

${res.command}

Ожидаю установки…

@@ -1774,9 +1824,14 @@ async function createManualNode() { shared_uuid: document.getElementById("nm-uuid").value.trim() || null, kind: document.getElementById("nm-uuid").value.trim() ? "external" : "managed", }; - await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) }); - document.getElementById("manual-result").innerHTML = '

Нода добавлена.

'; - loadNodes(); + const result = document.getElementById("manual-result"); + try { + await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) }); + result.innerHTML = '

Нода добавлена.

'; + loadNodes(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } } function applyBrandName(name) {