diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh
index 200f5d0..e165a5b 100644
--- a/services/security-dashboard.sh
+++ b/services/security-dashboard.sh
@@ -2430,6 +2430,17 @@ INDEX_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 = """
Changes may not be live yet.
- 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.
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 is evaluated live against current membership on every call.
+
Not the same thing as a Room 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.
@@ -2806,18 +2822,6 @@ INDEX_HTML = """
-
- Concurrent-call caps
-
-
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.
-
-
-
-
-
-
-
-
Personal numbers
@@ -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();
}