Remove Categories management from dashboard, keep Rooms
Categories was a whole CRUD system (its own file, its own card, its
own API routes) for something that only ever had one functional
effect: tagging a device "mobile" to enable RTP NAT-keepalive tuning,
plus a per-category auto-answer default. Replaced with a single
"Mobile/cellular device" checkbox directly on the add-extension form
and each extension row, writing the same underlying category string
Easy Asterisk's device format already expects ("mobile" or
"standard") without a separate category registry to manage. Removes
ea_list_categories/ea_create_category/ea_delete_category/
ea_rename_category, their /api/ea-categories* routes, the Categories
card, and the now-unused categories.conf sudoers grant.
Rooms stays as-is — unlike Categories, it's the only actually-dialable
ring/page group in this dashboard (a real Easy Asterisk extension that
rings or pages members live), which the dashboard-only Groups feature
cannot replace.
This commit is contained in:
+16
-260
@@ -498,7 +498,6 @@ _secdash_write_sudoers() {
|
|||||||
# edits without a full container restart).
|
# edits without a full container restart).
|
||||||
if [ -n "$_ea_container" ]; then
|
if [ -n "$_ea_container" ]; then
|
||||||
_ea_lines="$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/asterisk/pjsip.conf
|
_ea_lines="$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/asterisk/pjsip.conf
|
||||||
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/categories.conf
|
|
||||||
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/rooms.conf
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/rooms.conf
|
||||||
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ res_pjsip.so
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ res_pjsip.so
|
||||||
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx pjsip\ show\ endpoints
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx pjsip\ show\ endpoints
|
||||||
@@ -1727,10 +1726,8 @@ ASTERISK_EA_CONFIG_DIR = os.environ.get("ASTERISK_EA_CONFIG_DIR", "")
|
|||||||
ASTERISK_EA_CONTAINER = os.environ.get("ASTERISK_EA_CONTAINER", "")
|
ASTERISK_EA_CONTAINER = os.environ.get("ASTERISK_EA_CONTAINER", "")
|
||||||
|
|
||||||
EA_PJSIP_CONTAINER_PATH = "/etc/asterisk/pjsip.conf"
|
EA_PJSIP_CONTAINER_PATH = "/etc/asterisk/pjsip.conf"
|
||||||
EA_CATEGORIES_CONTAINER_PATH = "/etc/easy-asterisk/categories.conf"
|
|
||||||
EA_ROOMS_CONTAINER_PATH = "/etc/easy-asterisk/rooms.conf"
|
EA_ROOMS_CONTAINER_PATH = "/etc/easy-asterisk/rooms.conf"
|
||||||
EA_EXT_RE = re.compile(r"^\d{1,10}$")
|
EA_EXT_RE = re.compile(r"^\d{1,10}$")
|
||||||
EA_CATID_RE = re.compile(r"^[a-z0-9]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def ea_installed():
|
def ea_installed():
|
||||||
@@ -1741,10 +1738,6 @@ def _ea_pjsip_host_path():
|
|||||||
return os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf") if ASTERISK_CONFIG_DIR else None
|
return os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf") if ASTERISK_CONFIG_DIR else None
|
||||||
|
|
||||||
|
|
||||||
def _ea_categories_host_path():
|
|
||||||
return os.path.join(ASTERISK_EA_CONFIG_DIR, "categories.conf") if ASTERISK_EA_CONFIG_DIR else None
|
|
||||||
|
|
||||||
|
|
||||||
def _ea_rooms_host_path():
|
def _ea_rooms_host_path():
|
||||||
return os.path.join(ASTERISK_EA_CONFIG_DIR, "rooms.conf") if ASTERISK_EA_CONFIG_DIR else None
|
return os.path.join(ASTERISK_EA_CONFIG_DIR, "rooms.conf") if ASTERISK_EA_CONFIG_DIR else None
|
||||||
|
|
||||||
@@ -2103,99 +2096,6 @@ def ea_change_device_category(extension, new_category):
|
|||||||
return True, "Category changed"
|
return True, "Category changed"
|
||||||
|
|
||||||
|
|
||||||
def ea_list_categories():
|
|
||||||
path = _ea_categories_host_path()
|
|
||||||
categories = []
|
|
||||||
if not path or not os.path.isfile(path):
|
|
||||||
return categories
|
|
||||||
with open(path) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if line and not line.startswith("#"):
|
|
||||||
parts = line.split("|")
|
|
||||||
if len(parts) >= 3:
|
|
||||||
categories.append({"id": parts[0], "name": parts[1], "auto_answer": parts[2],
|
|
||||||
"description": parts[3] if len(parts) > 3 else ""})
|
|
||||||
return categories
|
|
||||||
|
|
||||||
|
|
||||||
def ea_create_category(cat_id, name, auto_answer="", description=""):
|
|
||||||
path = _ea_categories_host_path()
|
|
||||||
if not path:
|
|
||||||
return False, "No Asterisk install detected on this box"
|
|
||||||
cat_id = (cat_id or "").strip().lower()
|
|
||||||
name = (name or "").strip()
|
|
||||||
if not EA_CATID_RE.match(cat_id):
|
|
||||||
return False, "Category ID must be lowercase letters/digits only"
|
|
||||||
if not name:
|
|
||||||
return False, "Name required"
|
|
||||||
|
|
||||||
current = ""
|
|
||||||
if os.path.isfile(path):
|
|
||||||
with open(path) as f:
|
|
||||||
current = f.read()
|
|
||||||
else:
|
|
||||||
current = "# Format: id|name|auto_answer|description\n"
|
|
||||||
|
|
||||||
for line in current.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line and not line.startswith("#") and line.split("|")[0] == cat_id:
|
|
||||||
return False, "Category ID already exists"
|
|
||||||
|
|
||||||
if not current.endswith("\n"):
|
|
||||||
current += "\n"
|
|
||||||
new_content = current + "%s|%s|%s|%s\n" % (cat_id, name, auto_answer, description)
|
|
||||||
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, new_content)
|
|
||||||
return (True, "Category created") if ok else (False, err)
|
|
||||||
|
|
||||||
|
|
||||||
def ea_delete_category(cat_id):
|
|
||||||
path = _ea_categories_host_path()
|
|
||||||
if not path or not os.path.isfile(path):
|
|
||||||
return False, "Categories file not found"
|
|
||||||
with open(path) as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
new_lines = []
|
|
||||||
found = False
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped and not stripped.startswith("#") and stripped.split("|")[0] == cat_id:
|
|
||||||
found = True
|
|
||||||
continue
|
|
||||||
new_lines.append(line)
|
|
||||||
if not found:
|
|
||||||
return False, "Category not found"
|
|
||||||
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, "".join(new_lines))
|
|
||||||
return (True, "Category deleted") if ok else (False, err)
|
|
||||||
|
|
||||||
|
|
||||||
def ea_rename_category(cat_id, new_name):
|
|
||||||
path = _ea_categories_host_path()
|
|
||||||
if not path or not os.path.isfile(path):
|
|
||||||
return False, "Categories file not found"
|
|
||||||
new_name = (new_name or "").strip()
|
|
||||||
if not new_name:
|
|
||||||
return False, "Name required"
|
|
||||||
with open(path) as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
new_lines = []
|
|
||||||
found = False
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped and not stripped.startswith("#"):
|
|
||||||
parts = stripped.split("|")
|
|
||||||
if len(parts) >= 2 and parts[0] == cat_id:
|
|
||||||
parts[1] = new_name
|
|
||||||
new_lines.append("|".join(parts) + "\n")
|
|
||||||
found = True
|
|
||||||
continue
|
|
||||||
new_lines.append(line)
|
|
||||||
if not found:
|
|
||||||
return False, "Category not found"
|
|
||||||
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, "".join(new_lines))
|
|
||||||
return (True, "Category renamed") if ok else (False, err)
|
|
||||||
|
|
||||||
|
|
||||||
def ea_list_rooms():
|
def ea_list_rooms():
|
||||||
path = _ea_rooms_host_path()
|
path = _ea_rooms_host_path()
|
||||||
rooms = []
|
rooms = []
|
||||||
@@ -2691,23 +2591,22 @@ INDEX_HTML = """<!doctype html>
|
|||||||
<div class="add-form ea-only" id="ext-add-form">
|
<div class="add-form ea-only" id="ext-add-form">
|
||||||
<input type="text" id="ea-dev-name" placeholder="Name, e.g. Front Desk" style="width:11rem">
|
<input type="text" id="ea-dev-name" placeholder="Name, e.g. Front Desk" style="width:11rem">
|
||||||
<input type="text" id="ea-dev-ext" placeholder="Extension, e.g. 202" style="width:9rem">
|
<input type="text" id="ea-dev-ext" placeholder="Extension, e.g. 202" style="width:9rem">
|
||||||
<select id="ea-dev-category"></select>
|
|
||||||
<select id="ea-dev-conn">
|
<select id="ea-dev-conn">
|
||||||
<option value="lan">LAN (UDP)</option>
|
<option value="lan">LAN (UDP)</option>
|
||||||
<option value="fqdn">Remote/FQDN (TLS)</option>
|
<option value="fqdn">Remote/FQDN (TLS)</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ea-dev-aa">
|
<select id="ea-dev-aa">
|
||||||
<option value="">Auto-answer: category default</option>
|
|
||||||
<option value="yes">Auto-answer: yes</option>
|
|
||||||
<option value="no">Auto-answer: no</option>
|
<option value="no">Auto-answer: no</option>
|
||||||
|
<option value="yes">Auto-answer: yes</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="muted" style="white-space:nowrap"><input type="checkbox" id="ea-dev-mobile"> Mobile/cellular device</label>
|
||||||
<button class="primary" id="ea-dev-save">Add</button>
|
<button class="primary" id="ea-dev-save">Add</button>
|
||||||
<button class="action" id="ext-add-cancel">Cancel</button>
|
<button class="action" id="ext-add-cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="help">
|
<details class="help">
|
||||||
<summary>What these columns mean</summary>
|
<summary>What these columns mean</summary>
|
||||||
<p class="muted"><b>Name</b> and <b>Category</b> are editable in place — click, type, then Save. Adding an extension generates a random password and reloads PJSIP + rebuilds the dialplan automatically; the password is shown once, at the top of this card.</p>
|
<p class="muted"><b>Name</b> is editable in place — click, type, then Save. Adding an extension generates a random password and reloads PJSIP + rebuilds the dialplan automatically; the password is shown once, at the top of this card. <b>Mobile</b> tags a device as a cellular/off-LAN softphone (like a phone app over wifi/data) so Asterisk sends RTP NAT-keepalive traffic to keep it reachable — leave it off for anything on the LAN.</p>
|
||||||
<p class="muted pstn-only"><b>PSTN</b> sets how the outside phone network reaches this extension, and the <b>Whitelist</b> beside it is the one list of numbers that mode applies to:</p>
|
<p class="muted pstn-only"><b>PSTN</b> sets how the outside phone network reaches this extension, and the <b>Whitelist</b> beside it is the one list of numbers that mode applies to:</p>
|
||||||
<ul class="muted pstn-only" style="margin:0 0 var(--sp-2); padding-left:1.2rem">
|
<ul class="muted pstn-only" style="margin:0 0 var(--sp-2); padding-left:1.2rem">
|
||||||
<li><b>full</b> — dial anyone, anyone can call. No whitelist.</li>
|
<li><b>full</b> — dial anyone, anyone can call. No whitelist.</li>
|
||||||
@@ -2724,7 +2623,7 @@ INDEX_HTML = """<!doctype html>
|
|||||||
<table id="ext-table"><thead><tr>
|
<table id="ext-table"><thead><tr>
|
||||||
<th class="sortable" data-sort="ext">Ext</th>
|
<th class="sortable" data-sort="ext">Ext</th>
|
||||||
<th class="sortable" data-sort="name">Name</th>
|
<th class="sortable" data-sort="name">Name</th>
|
||||||
<th class="ea-only">Category</th>
|
<th class="ea-only">Mobile</th>
|
||||||
<th class="sortable ea-only" data-sort="status">Status</th>
|
<th class="sortable ea-only" data-sort="status">Status</th>
|
||||||
<th class="ea-only">Transport</th>
|
<th class="ea-only">Transport</th>
|
||||||
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
|
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
|
||||||
@@ -2744,32 +2643,6 @@ INDEX_HTML = """<!doctype html>
|
|||||||
|
|
||||||
<div class="section-head ea-only">Easy Asterisk device setup</div>
|
<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>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="muted">Device profiles — an auto-answer default and a description, assignable to any extension above.</p>
|
|
||||||
<div class="row" style="margin-bottom:var(--sp-3)">
|
|
||||||
<input type="text" id="ea-cat-id" placeholder="ID, e.g. desk (lowercase)" style="width:12rem">
|
|
||||||
<input type="text" id="ea-cat-name" placeholder="Display name" style="width:10rem">
|
|
||||||
<select id="ea-cat-aa">
|
|
||||||
<option value="">Auto-answer default: no</option>
|
|
||||||
<option value="yes">Auto-answer default: yes</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" id="ea-cat-desc" placeholder="Description (optional)" style="width:12rem">
|
|
||||||
<button class="action" id="ea-cat-save">Add category</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="ea-cat-table"><thead><tr>
|
|
||||||
<th class="sortable" data-sort="id">ID</th>
|
|
||||||
<th class="sortable" data-sort="name">Name</th>
|
|
||||||
<th>Auto-answer</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th></th>
|
|
||||||
</tr></thead><tbody></tbody></table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="card ea-only" id="card-rooms">
|
<details class="card ea-only" id="card-rooms">
|
||||||
<summary>Rooms (ring groups) <span class="count" id="room-count"></span></summary>
|
<summary>Rooms (ring groups) <span class="count" id="room-count"></span></summary>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -3052,9 +2925,8 @@ async function loadCrowdsecStatus() {
|
|||||||
document.getElementById("crowdsec-tab-btn").style.display = data.installed ? "" : "none";
|
document.getElementById("crowdsec-tab-btn").style.display = data.installed ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extensions tab (extensions + categories + rooms + groups + trunk) ──────
|
// ── Extensions tab (extensions + rooms + groups + trunk) ───────────────────
|
||||||
let eaDevices = [], eaCategories = [], eaRooms = [], eaStatusMap = {};
|
let eaDevices = [], eaRooms = [], eaStatusMap = {};
|
||||||
let eaCatSort = { key: null, dir: 1 };
|
|
||||||
let eaRoomSort = { key: null, dir: 1 };
|
let eaRoomSort = { key: null, dir: 1 };
|
||||||
|
|
||||||
async function initExtensionsTab() {
|
async function initExtensionsTab() {
|
||||||
@@ -3069,116 +2941,16 @@ async function initExtensionsTab() {
|
|||||||
await refreshExtensionsTab();
|
await refreshExtensionsTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequenced (not parallel) — extension rows render a category <select> that
|
// Sequenced (not parallel) — room rows render a member-add picker that needs
|
||||||
// needs eaCategories already populated, room rows render a member-add picker
|
// eaDevices (populated by loadExtensions), and the personal-DID owner picker
|
||||||
// that needs eaDevices (populated by loadExtensions), and the personal-DID
|
// needs both extensions and groups.
|
||||||
// owner picker needs both extensions and groups.
|
|
||||||
async function refreshExtensionsTab() {
|
async function refreshExtensionsTab() {
|
||||||
if (eaInstalled) await loadEaCategories();
|
|
||||||
await loadExtensions();
|
await loadExtensions();
|
||||||
if (eaInstalled) await loadEaRooms();
|
if (eaInstalled) await loadEaRooms();
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
if (pstnInstalled) await loadPersonalDids();
|
if (pstnInstalled) await loadPersonalDids();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEaDeviceCategoryOptions() {
|
|
||||||
const sel = document.getElementById("ea-dev-category");
|
|
||||||
sel.innerHTML = eaCategories.map(c => `<option value="${esc(c.id)}">${esc(c.name)}</option>`).join("")
|
|
||||||
|| '<option value="">No categories yet — add one below first</option>';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEaCategories() {
|
|
||||||
const res = await fetch("/api/ea-categories");
|
|
||||||
const data = await res.json();
|
|
||||||
eaCategories = data.categories || [];
|
|
||||||
renderEaCategories();
|
|
||||||
renderEaDeviceCategoryOptions();
|
|
||||||
if (extRows.length) renderExtensions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function eaCatSortValue(c, key) { return (c[key] || "").toString().toLowerCase(); }
|
|
||||||
|
|
||||||
function renderEaCategories() {
|
|
||||||
let rows = eaCategories.slice();
|
|
||||||
if (eaCatSort.key) {
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const av = eaCatSortValue(a, eaCatSort.key), bv = eaCatSortValue(b, eaCatSort.key);
|
|
||||||
if (av < bv) return -1 * eaCatSort.dir;
|
|
||||||
if (av > bv) return 1 * eaCatSort.dir;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.querySelectorAll("#ea-cat-table th.sortable .arrow").forEach(a => a.remove());
|
|
||||||
if (eaCatSort.key) {
|
|
||||||
const th = document.querySelector(`#ea-cat-table th[data-sort="${eaCatSort.key}"]`);
|
|
||||||
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${eaCatSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
||||||
}
|
|
||||||
setCount("cat-count", eaCategories.length);
|
|
||||||
const tbody = document.querySelector("#ea-cat-table tbody");
|
|
||||||
tbody.innerHTML = rows.map(c => `<tr data-id="${esc(c.id)}">
|
|
||||||
<td>${esc(c.id)}</td>
|
|
||||||
<td>${esc(c.name)}</td>
|
|
||||||
<td>${c.auto_answer === "yes" ? "yes" : (c.auto_answer === "no" ? "no" : "—")}</td>
|
|
||||||
<td>${esc(c.description)}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="action" onclick="renameEaCategory('${esc(c.id)}')">Rename</button>
|
|
||||||
<button class="action danger" onclick="deleteEaCategory('${esc(c.id)}')">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>`).join("") || '<tr><td colspan=5 class=empty>No categories yet.</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll("#ea-cat-table th.sortable").forEach(th => {
|
|
||||||
th.addEventListener("click", () => {
|
|
||||||
const key = th.dataset.sort;
|
|
||||||
eaCatSort.dir = (eaCatSort.key === key) ? -eaCatSort.dir : 1;
|
|
||||||
eaCatSort.key = key;
|
|
||||||
renderEaCategories();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("ea-cat-save").addEventListener("click", async () => {
|
|
||||||
const id = document.getElementById("ea-cat-id").value.trim();
|
|
||||||
const name = document.getElementById("ea-cat-name").value.trim();
|
|
||||||
const auto_answer = document.getElementById("ea-cat-aa").value;
|
|
||||||
const description = document.getElementById("ea-cat-desc").value.trim();
|
|
||||||
const res = await fetch("/api/ea-categories", {
|
|
||||||
method: "POST", headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({id, name, auto_answer, description}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
toast(data.message || (data.ok ? "Category added" : "Failed"), data.ok ? "ok" : "err");
|
|
||||||
if (data.ok) {
|
|
||||||
document.getElementById("ea-cat-id").value = "";
|
|
||||||
document.getElementById("ea-cat-name").value = "";
|
|
||||||
document.getElementById("ea-cat-desc").value = "";
|
|
||||||
}
|
|
||||||
loadEaCategories();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renameEaCategory(id) {
|
|
||||||
const cur = eaCategories.find(c => c.id === id);
|
|
||||||
const name = prompt("New name for category " + id + ":", cur ? cur.name : "");
|
|
||||||
if (name === null || !name.trim()) return;
|
|
||||||
const res = await fetch("/api/ea-categories/rename", {
|
|
||||||
method: "POST", headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({id, name: name.trim()}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
toast(data.message || (data.ok ? "Category renamed" : "Failed"), data.ok ? "ok" : "err");
|
|
||||||
loadEaCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEaCategory(id) {
|
|
||||||
if (!confirm("Delete category " + id + "? Devices already using it keep their current setting, but it won't be selectable for new ones.")) return;
|
|
||||||
const res = await fetch("/api/ea-categories/delete", {
|
|
||||||
method: "POST", headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({id}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
toast(data.message || (data.ok ? "Category deleted" : "Failed"), data.ok ? "ok" : "err");
|
|
||||||
loadEaCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toasts ────────────────────────────────────────────────────────────────
|
// ── Toasts ────────────────────────────────────────────────────────────────
|
||||||
// Transient feedback that doesn't push layout around. Anything the user must
|
// Transient feedback that doesn't push layout around. Anything the user must
|
||||||
// keep (a one-time device password) uses the persistent callout instead.
|
// keep (a one-time device password) uses the persistent callout instead.
|
||||||
@@ -3296,15 +3068,12 @@ function renderExtensions() {
|
|||||||
|
|
||||||
tbody.innerHTML = rows.map(e => {
|
tbody.innerHTML = rows.map(e => {
|
||||||
const status = e.status || "unknown";
|
const status = e.status || "unknown";
|
||||||
const catOptions = eaCategories.map(c =>
|
|
||||||
`<option value="${esc(c.id)}" ${c.id === e.category ? "selected" : ""}>${esc(c.name)}</option>`
|
|
||||||
).join("") || `<option value="${esc(e.category)}" selected>${esc(e.category)}</option>`;
|
|
||||||
// Rows for an extension the Easy Asterisk container doesn't know about
|
// Rows for an extension the Easy Asterisk container doesn't know about
|
||||||
// (in pjsip.conf but not its device list) still get tier/messaging —
|
// (in pjsip.conf but not its device list) still get tier/messaging —
|
||||||
// only the container-backed controls are inert, since every one of them
|
// only the container-backed controls are inert, since every one of them
|
||||||
// writes through `docker exec` into that container.
|
// writes through `docker exec` into that container.
|
||||||
const catCell = e.ea
|
const mobileCell = e.ea
|
||||||
? `<select class="ext-category">${catOptions}</select>`
|
? `<input type="checkbox" class="ext-mobile" ${e.category === "mobile" ? "checked" : ""} aria-label="Mobile/cellular device for extension ${esc(e.ext)}">`
|
||||||
: '<span class="empty">—</span>';
|
: '<span class="empty">—</span>';
|
||||||
const statusCell = e.ea
|
const statusCell = e.ea
|
||||||
? `<span class="pill ${status === "online" ? "online" : "offline"}">${esc(status)}</span>`
|
? `<span class="pill ${status === "online" ? "online" : "offline"}">${esc(status)}</span>`
|
||||||
@@ -3312,7 +3081,7 @@ function renderExtensions() {
|
|||||||
return `<tr data-ext="${esc(e.ext)}">
|
return `<tr data-ext="${esc(e.ext)}">
|
||||||
<td>${esc(e.ext)}</td>
|
<td>${esc(e.ext)}</td>
|
||||||
<td><input type="text" class="inline-edit ext-name" value="${esc(e.name)}" ${e.ea ? "" : "disabled"} aria-label="Name for extension ${esc(e.ext)}"></td>
|
<td><input type="text" class="inline-edit ext-name" value="${esc(e.name)}" ${e.ea ? "" : "disabled"} aria-label="Name for extension ${esc(e.ext)}"></td>
|
||||||
<td class="ea-only">${catCell}</td>
|
<td class="ea-only" style="text-align:center">${mobileCell}</td>
|
||||||
<td class="ea-only">${statusCell}</td>
|
<td class="ea-only">${statusCell}</td>
|
||||||
<td class="ea-only muted">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td>
|
<td class="ea-only muted">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td>
|
||||||
<td class="pstn-only">${restrictSelect(e.restrict)}</td>
|
<td class="pstn-only">${restrictSelect(e.restrict)}</td>
|
||||||
@@ -3348,13 +3117,13 @@ function rowEdits(tr) {
|
|||||||
const model = extRows.find(e => e.ext === ext);
|
const model = extRows.find(e => e.ext === ext);
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const nameEl = tr.querySelector(".ext-name");
|
const nameEl = tr.querySelector(".ext-name");
|
||||||
const catEl = tr.querySelector(".ext-category");
|
const mobileEl = tr.querySelector(".ext-mobile");
|
||||||
const modeEl = tr.querySelector(".ext-restrict");
|
const modeEl = tr.querySelector(".ext-restrict");
|
||||||
const numsEl = tr.querySelector(".ext-numbers");
|
const numsEl = tr.querySelector(".ext-numbers");
|
||||||
const msgEl = tr.querySelector(".ext-messaging");
|
const msgEl = tr.querySelector(".ext-messaging");
|
||||||
const edits = {};
|
const edits = {};
|
||||||
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== model.name) edits.name = nameEl.value.trim();
|
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== model.name) edits.name = nameEl.value.trim();
|
||||||
if (catEl && catEl.value !== model.category) edits.category = catEl.value;
|
if (mobileEl && mobileEl.checked !== (model.category === "mobile")) edits.category = mobileEl.checked ? "mobile" : "standard";
|
||||||
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
|
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
|
||||||
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
||||||
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
|
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
|
||||||
@@ -3481,7 +3250,7 @@ document.getElementById("ea-dev-save").addEventListener("click", async () => {
|
|||||||
const data = await postJSON("/api/ea-devices", {
|
const data = await postJSON("/api/ea-devices", {
|
||||||
name,
|
name,
|
||||||
extension,
|
extension,
|
||||||
category: document.getElementById("ea-dev-category").value,
|
category: document.getElementById("ea-dev-mobile").checked ? "mobile" : "standard",
|
||||||
conn_type: document.getElementById("ea-dev-conn").value,
|
conn_type: document.getElementById("ea-dev-conn").value,
|
||||||
auto_answer: document.getElementById("ea-dev-aa").value || null,
|
auto_answer: document.getElementById("ea-dev-aa").value || null,
|
||||||
});
|
});
|
||||||
@@ -3493,6 +3262,7 @@ document.getElementById("ea-dev-save").addEventListener("click", async () => {
|
|||||||
document.getElementById("ext-password-callout").classList.add("show");
|
document.getElementById("ext-password-callout").classList.add("show");
|
||||||
document.getElementById("ea-dev-name").value = "";
|
document.getElementById("ea-dev-name").value = "";
|
||||||
document.getElementById("ea-dev-ext").value = "";
|
document.getElementById("ea-dev-ext").value = "";
|
||||||
|
document.getElementById("ea-dev-mobile").checked = false;
|
||||||
extAddForm.classList.remove("open");
|
extAddForm.classList.remove("open");
|
||||||
} else {
|
} else {
|
||||||
toast(data.message || "Failed to add extension", "err");
|
toast(data.message || "Failed to add extension", "err");
|
||||||
@@ -3971,8 +3741,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json({"installed": ea_installed()})
|
self._json({"installed": ea_installed()})
|
||||||
elif self.path == "/api/ea-devices":
|
elif self.path == "/api/ea-devices":
|
||||||
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
||||||
elif self.path == "/api/ea-categories":
|
|
||||||
self._json({"categories": ea_list_categories()})
|
|
||||||
elif self.path == "/api/ea-rooms":
|
elif self.path == "/api/ea-rooms":
|
||||||
self._json({"rooms": ea_list_rooms()})
|
self._json({"rooms": ea_list_rooms()})
|
||||||
else:
|
else:
|
||||||
@@ -4043,18 +3811,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif self.path == "/api/ea-devices/category":
|
elif self.path == "/api/ea-devices/category":
|
||||||
ok, message = ea_change_device_category(payload.get("extension", ""), payload.get("category", ""))
|
ok, message = ea_change_device_category(payload.get("extension", ""), payload.get("category", ""))
|
||||||
self._json({"ok": ok, "message": message})
|
self._json({"ok": ok, "message": message})
|
||||||
elif self.path == "/api/ea-categories":
|
|
||||||
ok, message = ea_create_category(
|
|
||||||
payload.get("id", ""), payload.get("name", ""),
|
|
||||||
payload.get("auto_answer", ""), payload.get("description", "")
|
|
||||||
)
|
|
||||||
self._json({"ok": ok, "message": message})
|
|
||||||
elif self.path == "/api/ea-categories/delete":
|
|
||||||
ok, message = ea_delete_category(payload.get("id", ""))
|
|
||||||
self._json({"ok": ok, "message": message})
|
|
||||||
elif self.path == "/api/ea-categories/rename":
|
|
||||||
ok, message = ea_rename_category(payload.get("id", ""), payload.get("name", ""))
|
|
||||||
self._json({"ok": ok, "message": message})
|
|
||||||
elif self.path == "/api/ea-rooms":
|
elif self.path == "/api/ea-rooms":
|
||||||
ok, message = ea_create_room(
|
ok, message = ea_create_room(
|
||||||
payload.get("extension", ""), payload.get("name", ""),
|
payload.get("extension", ""), payload.get("name", ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user