Improve PSTN commit-changes UX, collapse Extensions, drop call-limit UI
The "Commit changes (restart Asterisk)" button was already wired
correctly, but lived in a passive banner that was easy to miss after
a save, letting an admin keep testing against Asterisk's stale
AST_CONFIG() read without realizing a restart was needed. Every
PSTN-affecting save now also prompts immediately ("Restart Asterisk
now?"), once per logical action (a single save, or one whole batch),
with the banner kept as a fallback for "not now".
Also:
- Extensions card is now collapsible like the other cards, open by
default.
- Concurrent-call caps card removed from the dashboard UI (the
underlying dialplan cap and pstn-limits.conf are untouched, just no
longer editable from this page).
- Categories/Rooms and Groups/Personal numbers are now visually
grouped under section headers, with a note clarifying that a Room
is a real dialable ring-group extension while a Group is a
dashboard-only bulk-action convenience, since both being "named
sets of extensions" invited exactly that confusion.
This commit is contained in:
@@ -2430,6 +2430,17 @@ INDEX_HTML = """<!doctype html>
|
||||
}
|
||||
details.card > .card-body { padding: 0 var(--sp-4) var(--sp-4); }
|
||||
|
||||
/* Groups cards that belong to the same underlying system (Easy Asterisk
|
||||
device provisioning vs. PSTN-trunk permissions) under one label, so the
|
||||
page itself explains why e.g. "Rooms" and "Groups" both exist instead
|
||||
of reading as duplicates. */
|
||||
.section-head {
|
||||
margin: var(--sp-6) 0 var(--sp-2); padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-faint); font-weight: 600; font-size: 0.75rem;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Inline "what does this mean" disclosure — keeps the explanation one click
|
||||
away instead of pushing every control below three paragraphs of prose. */
|
||||
details.help { margin: 0 0 var(--sp-3); }
|
||||
@@ -2659,19 +2670,19 @@ INDEX_HTML = """<!doctype html>
|
||||
<div class="card-body">
|
||||
<b>Changes may not be live yet.</b>
|
||||
<p class="muted" style="margin:var(--sp-1) 0 var(--sp-2)">
|
||||
Edits are written to disk immediately, but Asterisk doesn't always pick them up without a restart — confirmed on personal-DID group reassignment specifically. Existing calls are never affected.
|
||||
Edits are written to disk immediately, but Asterisk doesn't always pick them up without a restart. Saving never disturbs an active call by itself — but restarting does, so it isn't automatic; click below (or answer "Restart now?" right after a save) once it's a good time.
|
||||
</p>
|
||||
<button class="action" id="pstn-restart-btn">Commit changes (restart Asterisk)</button>
|
||||
<span id="pstn-restart-msg" class="muted" style="margin-left:var(--sp-2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h3>Extensions <span class="count muted" id="ext-count"></span></h3>
|
||||
<button class="action ea-only" id="ext-add-toggle">+ Add extension</button>
|
||||
</div>
|
||||
<details class="card" id="card-extensions" open>
|
||||
<summary>Extensions <span class="count muted" id="ext-count"></span></summary>
|
||||
<div class="card-body">
|
||||
<div class="row" style="justify-content:flex-end; margin-bottom:var(--sp-2)">
|
||||
<button class="action ea-only" id="ext-add-toggle">+ Add extension</button>
|
||||
</div>
|
||||
<div class="callout" id="ext-password-callout">
|
||||
<div class="grow" id="ext-password-text" style="flex:1"></div>
|
||||
<button class="icon" id="ext-password-dismiss" title="Dismiss">×</button>
|
||||
@@ -2729,7 +2740,9 @@ INDEX_HTML = """<!doctype html>
|
||||
<button class="primary" id="ext-save-all">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="section-head ea-only">Easy Asterisk device setup</div>
|
||||
|
||||
<details class="card ea-only" id="card-categories">
|
||||
<summary>Categories <span class="count" id="cat-count"></span></summary>
|
||||
@@ -2784,12 +2797,15 @@ INDEX_HTML = """<!doctype html>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="section-head">Groups & personal numbers (PSTN permissions)</div>
|
||||
|
||||
<details class="card" id="card-groups">
|
||||
<summary>Groups <span class="count" id="grp-count"></span></summary>
|
||||
<div class="card-body">
|
||||
<details class="help">
|
||||
<summary>What groups do</summary>
|
||||
<p class="muted">Named sets of extensions for bulk actions — e.g. enable messaging for everyone in "Sales" at once. A management convenience only: applying an action writes the same per-extension setting each member's own Messaging checkbox above would, one time. It isn't a runtime concept the dialplan knows about, and membership changes never retroactively affect anything already applied. A group can also own a personal number below, which <i>is</i> evaluated live against current membership on every call.</p>
|
||||
<p class="muted">Not the same thing as a <b>Room</b> above: a Room is a real, dialable ring-group extension that rings its members live on every call. A Group has no extension of its own and can't be dialed — it only exists to save you re-clicking the same setting on several extensions, and to optionally own a personal number.</p>
|
||||
</details>
|
||||
<div class="row" style="margin-bottom:var(--sp-2)">
|
||||
<input type="text" id="grp-name" placeholder="Group name, e.g. Sales" style="width:12rem">
|
||||
@@ -2806,18 +2822,6 @@ INDEX_HTML = """<!doctype html>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card pstn-only" id="card-limits">
|
||||
<summary>Concurrent-call caps</summary>
|
||||
<div class="card-body">
|
||||
<p class="muted">A call over either cap gets a busy signal (and an ntfy alert, if enabled) — existing calls are never affected. Usually live on the next call; if a call doesn't reflect a recent change, use "Commit changes" at the top.</p>
|
||||
<div class="row">
|
||||
<label class="muted">Max outbound<br><input type="text" id="limit-out" style="width:5.5rem"></label>
|
||||
<label class="muted">Max inbound<br><input type="text" id="limit-in" style="width:5.5rem"></label>
|
||||
<button class="action" id="limits-save" style="align-self:flex-end">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card pstn-only" id="card-dids">
|
||||
<summary>Personal numbers <span class="count" id="pd-count"></span></summary>
|
||||
<div class="card-body">
|
||||
@@ -3074,7 +3078,7 @@ async function refreshExtensionsTab() {
|
||||
await loadExtensions();
|
||||
if (eaInstalled) await loadEaRooms();
|
||||
await loadGroups();
|
||||
if (pstnInstalled) { await loadPstnLimits(); await loadPersonalDids(); }
|
||||
if (pstnInstalled) await loadPersonalDids();
|
||||
}
|
||||
|
||||
function renderEaDeviceCategoryOptions() {
|
||||
@@ -3389,6 +3393,7 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
|
||||
btn.textContent = "Saving…";
|
||||
|
||||
let saved = 0;
|
||||
let touchedPstn = false;
|
||||
const failures = [];
|
||||
for (const row of dirty) {
|
||||
const {ext, model, edits} = row;
|
||||
@@ -3419,7 +3424,7 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
|
||||
r = await postJSON("/api/pstn-messaging", {ext: ext, enabled: messaging});
|
||||
}
|
||||
if (!r.ok) throw new Error(r.message || "permission save failed");
|
||||
if (pstnInstalled) markPstnDirty();
|
||||
if (pstnInstalled) { markPstnDirty(); touchedPstn = true; }
|
||||
}
|
||||
saved++;
|
||||
} catch (err) {
|
||||
@@ -3435,6 +3440,7 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
|
||||
toast(`Saved ${saved} extension${saved === 1 ? "" : "s"}`, "ok");
|
||||
}
|
||||
await loadExtensions();
|
||||
if (touchedPstn) await offerPstnRestart();
|
||||
});
|
||||
|
||||
function postJSON(url, body) {
|
||||
@@ -3637,13 +3643,6 @@ async function removeEaRoomMember(roomExt, device) {
|
||||
loadEaRooms();
|
||||
}
|
||||
|
||||
async function loadPstnLimits() {
|
||||
const res = await fetch("/api/pstn-limits");
|
||||
const data = await res.json();
|
||||
document.getElementById("limit-out").value = data.max_outbound;
|
||||
document.getElementById("limit-in").value = data.max_inbound;
|
||||
}
|
||||
|
||||
// The Groups card's membership picker. Driven by the same merged extension
|
||||
// list the table above renders, so a group can never offer an extension the
|
||||
// table doesn't show (or miss one it does).
|
||||
@@ -3726,7 +3725,7 @@ document.getElementById("grp-save").addEventListener("click", async () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(data.message || (data.ok ? "Group saved" : "Failed"), data.ok ? "ok" : "err");
|
||||
if (data.ok && pstnInstalled) markPstnDirty();
|
||||
if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); }
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
@@ -3738,7 +3737,7 @@ async function applyGroupMessaging(name, enabled) {
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(data.message || (data.ok ? "Applied to group" : "Failed"), data.ok ? "ok" : "err");
|
||||
if (data.ok && pstnInstalled) markPstnDirty();
|
||||
if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); }
|
||||
loadExtensions();
|
||||
}
|
||||
|
||||
@@ -3750,23 +3749,10 @@ async function deleteGroup(name) {
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(data.message || (data.ok ? "Group deleted" : "Failed"), data.ok ? "ok" : "err");
|
||||
if (data.ok && pstnInstalled) markPstnDirty();
|
||||
if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); }
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
document.getElementById("limits-save").addEventListener("click", async () => {
|
||||
const maxOut = document.getElementById("limit-out").value;
|
||||
const maxIn = document.getElementById("limit-in").value;
|
||||
const res = await fetch("/api/pstn-limits", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({max_outbound: maxOut, max_inbound: maxIn}),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(data.message || (data.ok ? "Caps saved" : "Failed"), data.ok ? "ok" : "err");
|
||||
if (data.ok) markPstnDirty();
|
||||
loadPstnLimits();
|
||||
});
|
||||
|
||||
// "Commit Changes" — see restart_asterisk_container()'s comment in app.py
|
||||
// for why this button exists: AST_CONFIG() live-reads of these PSTN config
|
||||
// files have been confirmed to sometimes stay stale (returning what was on
|
||||
@@ -3789,7 +3775,7 @@ window.addEventListener("beforeunload", (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
});
|
||||
document.getElementById("pstn-restart-btn").addEventListener("click", async () => {
|
||||
async function commitPstnRestart() {
|
||||
const msg = document.getElementById("pstn-restart-msg");
|
||||
msg.textContent = "Restarting Asterisk…";
|
||||
const res = await fetch("/api/asterisk-restart", { method: "POST" });
|
||||
@@ -3799,7 +3785,24 @@ document.getElementById("pstn-restart-btn").addEventListener("click", async () =
|
||||
pstnDirty = false;
|
||||
document.getElementById("pstn-restart-banner").style.display = "none";
|
||||
}
|
||||
});
|
||||
return data.ok;
|
||||
}
|
||||
document.getElementById("pstn-restart-btn").addEventListener("click", commitPstnRestart);
|
||||
|
||||
// The banner above is easy to miss (confirmed live — the actual "phantom"
|
||||
// this was built for: an admin saves a permission change, tests a call
|
||||
// minutes later against Asterisk's still-stale read, and has no reason to
|
||||
// suspect the save itself). Ask right at the moment of save instead of only
|
||||
// leaving a passive banner — restart is still opt-in per prompt (not
|
||||
// automatic) since it drops any calls in progress right now, which a save
|
||||
// action alone never does. Call this once per logical user action (a
|
||||
// single save/delete, or one whole batch), never inside a per-row loop, or
|
||||
// multiple saves in one batch would each pop their own dialog.
|
||||
async function offerPstnRestart() {
|
||||
if (confirm("Saved. Asterisk won't use this change until it restarts, which will hang up any calls in progress right now. Restart Asterisk now?")) {
|
||||
await commitPstnRestart();
|
||||
}
|
||||
}
|
||||
|
||||
// The personal-DID owner picker spans both lists, so it re-renders from
|
||||
// whichever of the two finished last rather than being owned by either.
|
||||
@@ -3879,6 +3882,7 @@ document.getElementById("pd-save").addEventListener("click", async () => {
|
||||
markPstnDirty();
|
||||
}
|
||||
loadPersonalDids();
|
||||
if (data.ok) await offerPstnRestart();
|
||||
});
|
||||
|
||||
async function removePersonalDid(did) {
|
||||
@@ -3889,7 +3893,7 @@ async function removePersonalDid(did) {
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(data.message || (data.ok ? "Number removed" : "Failed"), data.ok ? "ok" : "err");
|
||||
if (data.ok) markPstnDirty();
|
||||
if (data.ok) { markPstnDirty(); await offerPstnRestart(); }
|
||||
loadPersonalDids();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user