(() => {
const PAGE_SIZE = 10;
const ICONS = {
io: ``,
edit: ``,
delete: ``,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("ioModuleList");
const emptyEl = el("ioModuleListEmpty");
const tableEl = el("ioModulesTable");
const filterInputEl = el("ioModulesFilterInput");
const filterCountEl = el("ioModulesFilterCount");
const pageLabelEl = el("ioModulesPageLabel");
const dialogEl = el("ioModuleEditDialog");
const formEl = el("ioModuleEditForm");
const titleEl = el("ioModuleEditTitle");
const deleteBtnEl = el("ioModuleEditDeleteBtn");
const typeHintEl = el("ioModuleEditTypeHint");
const deleteConfirmDialogEl = el("ioModuleDeleteConfirmDialog");
const deleteConfirmTextEl = el("ioModuleDeleteConfirmText");
const fields = {
site: el("ioModuleEditSite"),
name: el("ioModuleEditName"),
type: el("ioModuleEditType"),
ip: el("ioModuleEditIp"),
};
const store = {
modules: [],
sites: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("integrations");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function defaultSiteId() {
return store.sites[0]?.id || "site_configuration";
}
function typeLabel(type) {
if (type === "bluetooth") return t("ioModules.type.bluetooth");
if (type === "wise") return t("ioModules.type.wise");
return type || "—";
}
function statusLabel(mod) {
return mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected");
}
function updateTypeHint() {
if (!typeHintEl || !fields.type) return;
const type = fields.type.value;
typeHintEl.textContent =
type === "wise" ? t("ioModules.typeHint.wise") : t("ioModules.typeHint.bluetooth");
}
async function refreshAll() {
const [sitesData, modulesData] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/io_modules"),
]);
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
store.modules = Array.isArray(modulesData.io_modules) ? modulesData.io_modules : [];
return store.modules;
}
function moduleById(id) {
return store.modules.find((m) => m.id === id) || null;
}
function filteredModules() {
const q = store.filter.trim().toLowerCase();
let items = [...store.modules].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((m) => {
const name = (m.name || "").toLowerCase();
const ip = (m.ip_address || "").toLowerCase();
const type = (m.module_type || "").toLowerCase();
return name.includes(q) || ip.includes(q) || type.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("ioModules.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("ioModules.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("ioModulesPageFirst")?.toggleAttribute("disabled", atStart);
el("ioModulesPagePrev")?.toggleAttribute("disabled", atStart);
el("ioModulesPageNext")?.toggleAttribute("disabled", atEnd);
el("ioModulesPageLast")?.toggleAttribute("disabled", atEnd);
}
function fillSiteSelect(selectedId) {
if (!fields.site) return;
fields.site.innerHTML = "";
store.sites.forEach((site) => {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
if (site.id === selectedId) opt.selected = true;
fields.site.appendChild(opt);
});
if (!fields.site.value && store.sites[0]) fields.site.value = store.sites[0].id;
}
function renderList() {
if (!listEl) return;
const items = filteredModules();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("ioModules.emptyFilter") : t("ioModules.empty");
}
pageItems.forEach((mod) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow ioModulesMirRow";
tr.dataset.id = mod.id;
const actions = canWrite()
? `
`
: "";
const statusClass = mod.connected ? "ioModulesMirStatus--on" : "ioModulesMirStatus--off";
tr.innerHTML = `
${ICONS.io} |
|
${escapeHtml(typeLabel(mod.module_type))} |
${escapeHtml(mod.ip_address || "—")} |
${escapeHtml(statusLabel(mod))} |
${actions} |
`;
listEl.appendChild(tr);
});
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("ioModules.createTitle");
fillSiteSelect(defaultSiteId());
if (fields.name) fields.name.value = "";
if (fields.type) fields.type.value = "bluetooth";
if (fields.ip) fields.ip.value = "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function openEditDialog(id) {
const mod = moduleById(id);
if (!mod) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("ioModules.editTitle");
fillSiteSelect(mod.site_id || defaultSiteId());
if (fields.name) fields.name.value = mod.name || "";
if (fields.type) fields.type.value = mod.module_type || "bluetooth";
if (fields.ip) fields.ip.value = mod.ip_address || "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", false);
dialogEl?.showModal();
}
async function saveModule(evt) {
evt?.preventDefault();
const payload = {
site_id: fields.site?.value || defaultSiteId(),
name: (fields.name?.value || "").trim(),
module_type: fields.type?.value || "bluetooth",
ip_address: (fields.ip?.value || "").trim(),
};
if (!payload.name || !payload.ip_address) {
alert(t("ioModules.error.missing"));
return;
}
try {
if (store.editingId) {
await apiJson(`/api/io_modules/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/io_modules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const mod = moduleById(id);
if (!mod) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("ioModules.deleteConfirmText", { name: mod.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include" });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
if (err.usages) msg += "\n" + JSON.stringify(err.usages, null, 2);
} catch {
/* ignore */
}
throw new Error(msg);
}
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("ioModuleCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveModule);
el("ioModuleEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", () => {
if (store.editingId) openDeleteConfirm(store.editingId);
});
fields.type?.addEventListener("change", updateTypeHint);
el("ioModuleTestBtn")?.addEventListener("click", async () => {
const ip = (fields.ip?.value || "").trim();
if (!ip) {
alert(t("ioModules.error.missing"));
return;
}
try {
await apiJson("/api/io_modules/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip_address: ip, port: 502 }),
});
alert(t("ioModules.testOk"));
} catch (e) {
alert(e.message);
}
});
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit && canWrite()) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("ioModulesClearFiltersBtn")?.addEventListener("click", clearFilters);
el("ioModulesPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("ioModulesPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("ioModulesPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("ioModulesPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredModules().length);
renderList();
});
el("ioModulesHelpBtn")?.addEventListener("click", () => alert(t("ioModules.helpBody")));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("io-modules")) return;
document.body.classList.toggle("auth-readonly-io-modules", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.IoModulesCatalog = {
getModules: () => [...store.modules],
getNames: () => store.modules.map((m) => m.name).filter(Boolean),
refresh: refreshAll,
};
window.IoModulesApp = { onPageShow, onPageHide, refreshAll, getModules: () => [...store.modules] };
})();