fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing
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 <div> (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 <noreply@anthropic.com>
This commit is contained in:
parent
02ff43095c
commit
2b34b11391
1 changed files with 72 additions and 17 deletions
89
admin.html
89
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("") : '<div class="empty">Нет привязанных устройств</div>';
|
||||
}
|
||||
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 = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
|
||||
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 = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
|
||||
loadGifts();
|
||||
} catch (e) {
|
||||
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
guideResult.innerHTML = `
|
||||
<p class="page-sub" style="margin:16px 0 8px">Выполни на новом сервере:</p>
|
||||
<div class="code-box">${res.command}<button class="copy-btn" onclick="copyText('${res.command}')">Копировать</button></div>
|
||||
<p class="page-sub" style="margin-top:12px" id="guide-status">Ожидаю установки…</p>
|
||||
|
|
@ -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 = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
|
||||
loadNodes();
|
||||
const result = document.getElementById("manual-result");
|
||||
try {
|
||||
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
|
||||
result.innerHTML = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
|
||||
loadNodes();
|
||||
} catch (e) {
|
||||
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrandName(name) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue