Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web interface on port 8080 (kiosk, system and network management), a restricted root helper, network changes with automatic rollback, a healthcheck timer, install/update/uninstall scripts and a pi-gen image build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.2 KiB
JavaScript
207 lines
8.2 KiB
JavaScript
/* VoxIP Kiosk Manager — progressive enhancements. Every page works without JS. */
|
|
(function () {
|
|
"use strict";
|
|
|
|
const csrf = () => document.querySelector('meta[name="csrf-token"]').content;
|
|
const $ = (sel, root = document) => root.querySelector(sel);
|
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
|
|
|
// Mobile navigation
|
|
const toggle = $("[data-nav-toggle]");
|
|
if (toggle) {
|
|
toggle.addEventListener("click", () => {
|
|
const nav = $("#mainnav");
|
|
const open = nav.classList.toggle("open");
|
|
toggle.setAttribute("aria-expanded", String(open));
|
|
});
|
|
}
|
|
|
|
// Confirmation dialog for dangerous actions (forms with data-confirm)
|
|
const dialog = $("#confirm-dialog");
|
|
$$("form[data-confirm]").forEach((form) => {
|
|
form.addEventListener("submit", (event) => {
|
|
if (form.dataset.confirmed === "1" || !dialog || typeof dialog.showModal !== "function") return;
|
|
event.preventDefault();
|
|
$("[data-confirm-title]", dialog).textContent = form.dataset.confirmTitle || "Weet je het zeker?";
|
|
$("[data-confirm-text]", dialog).textContent = form.dataset.confirm;
|
|
dialog.returnValue = "";
|
|
dialog.showModal();
|
|
dialog.addEventListener("close", function onClose() {
|
|
dialog.removeEventListener("close", onClose);
|
|
if (dialog.returnValue === "ok") {
|
|
form.dataset.confirmed = "1";
|
|
form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Disable buttons while a form submits, to prevent double actions
|
|
$$("form").forEach((form) => {
|
|
form.addEventListener("submit", (event) => {
|
|
if (event.defaultPrevented || form.method.toLowerCase() !== "post") return;
|
|
setTimeout(() => $$("button[type=submit], button:not([type])", form).forEach((b) => (b.disabled = true)), 0);
|
|
});
|
|
});
|
|
|
|
// Meters (width set here because the CSP forbids inline styles)
|
|
function setMeter(el, pct) {
|
|
const value = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
el.style.width = value + "%";
|
|
el.classList.toggle("is-high", value >= 90);
|
|
el.classList.toggle("is-mid", value >= 75 && value < 90);
|
|
}
|
|
$$("[data-meter]").forEach((el) => setMeter(el, el.dataset.pct));
|
|
|
|
// Local time for epoch timestamps
|
|
$$("time[data-epoch]").forEach((el) => {
|
|
const d = new Date(Number(el.dataset.epoch) * 1000);
|
|
el.dateTime = d.toISOString();
|
|
el.textContent = d.toLocaleString("nl-NL", { dateStyle: "short", timeStyle: "medium" });
|
|
});
|
|
|
|
// Live dashboard
|
|
const live = $("[data-live-status]");
|
|
if (live) {
|
|
const get = (obj, path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), obj);
|
|
const refresh = async () => {
|
|
try {
|
|
const res = await fetch(live.dataset.liveStatus, { headers: { Accept: "application/json" }, cache: "no-store" });
|
|
if (res.status === 401) return location.reload();
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
$$("[data-field]").forEach((el) => {
|
|
const value = get(data, el.dataset.field);
|
|
if (value !== undefined && value !== null) el.textContent = value;
|
|
});
|
|
$$("[data-meter]").forEach((el) => setMeter(el, data[el.dataset.meter]));
|
|
$$("[data-badge]").forEach((el) => {
|
|
const key = el.dataset.badge;
|
|
let status, label;
|
|
if (key === "temperature") { status = data.temperature_status; label = data.temperature; }
|
|
else if (data[key]) { status = data[key].status; label = data[key].label; }
|
|
if (!status) return;
|
|
el.className = "badge badge-" + status;
|
|
$("[data-badge-label]", el).textContent = label;
|
|
});
|
|
} catch (e) { /* device rebooting or offline: try again next round */ }
|
|
};
|
|
setInterval(refresh, 5000);
|
|
}
|
|
|
|
// Screenshot
|
|
const shotBtn = $("[data-screenshot-btn]");
|
|
if (shotBtn) {
|
|
const box = $("[data-screenshot]");
|
|
const img = $("img", box);
|
|
const msg = $("[data-screenshot-msg]", box);
|
|
shotBtn.addEventListener("click", async () => {
|
|
shotBtn.disabled = true;
|
|
msg.textContent = "Screenshot maken…";
|
|
msg.hidden = false;
|
|
try {
|
|
const res = await fetch(box.dataset.screenshot + "?t=" + Date.now(), { cache: "no-store" });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const blob = await res.blob();
|
|
if (img.src) URL.revokeObjectURL(img.src);
|
|
img.src = URL.createObjectURL(blob);
|
|
img.hidden = false;
|
|
msg.hidden = true;
|
|
} catch (e) {
|
|
img.hidden = true;
|
|
msg.textContent = e.message || "Screenshot niet beschikbaar.";
|
|
} finally {
|
|
shotBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Static IP fields only when "Statisch" is selected
|
|
$$("form[data-ipform]").forEach((form) => {
|
|
const fields = $("[data-static-fields]", form);
|
|
const update = () => {
|
|
const method = $('input[name="method"]:checked', form);
|
|
fields.hidden = !method || method.value !== "static";
|
|
};
|
|
$$('input[name="method"]', form).forEach((r) => r.addEventListener("change", update));
|
|
update();
|
|
});
|
|
|
|
// "Scherm altijd aan" hides the timeout field
|
|
$$("[data-toggle-target]").forEach((box) => {
|
|
const target = $(box.dataset.toggleTarget);
|
|
const update = () => { if (target) target.hidden = box.checked; };
|
|
box.addEventListener("change", update);
|
|
update();
|
|
});
|
|
|
|
// Wifi scan
|
|
const scanBtn = $("[data-wifi-scan]");
|
|
if (scanBtn) {
|
|
const form = scanBtn.closest("form");
|
|
const list = $("[data-scan-results]", form);
|
|
const datalist = $("#wifi-networks");
|
|
scanBtn.addEventListener("click", async () => {
|
|
scanBtn.disabled = true;
|
|
scanBtn.textContent = "Scannen…";
|
|
list.hidden = false;
|
|
list.replaceChildren(Object.assign(document.createElement("li"), { textContent: "Netwerken zoeken…", className: "muted" }));
|
|
try {
|
|
const res = await fetch(scanBtn.dataset.wifiScan, { method: "POST", headers: { "X-CSRF-Token": csrf(), Accept: "application/json" } });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Scannen mislukt");
|
|
list.replaceChildren();
|
|
datalist.replaceChildren();
|
|
if (!data.networks.length) list.append(Object.assign(document.createElement("li"), { textContent: "Geen netwerken gevonden.", className: "muted" }));
|
|
data.networks.forEach((n) => {
|
|
const li = document.createElement("li");
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
const name = document.createElement("span");
|
|
name.textContent = n.ssid + (n.in_use ? " (verbonden)" : "");
|
|
const meta = document.createElement("span");
|
|
meta.className = "muted";
|
|
meta.textContent = n.signal + "% · " + (n.security === "open" ? "open" : n.security);
|
|
b.append(name, meta);
|
|
b.addEventListener("click", () => {
|
|
$('input[name="ssid"]', form).value = n.ssid;
|
|
$('input[name="open"]', form).checked = n.security === "open";
|
|
list.hidden = true;
|
|
$('input[name="psk"]', form).focus();
|
|
});
|
|
li.append(b);
|
|
list.append(li);
|
|
datalist.append(Object.assign(document.createElement("option"), { value: n.ssid }));
|
|
});
|
|
} catch (e) {
|
|
list.replaceChildren(Object.assign(document.createElement("li"), { textContent: e.message, className: "muted" }));
|
|
} finally {
|
|
scanBtn.disabled = false;
|
|
scanBtn.textContent = "Scannen";
|
|
}
|
|
});
|
|
}
|
|
|
|
// Pending network change countdown
|
|
const pending = $("[data-pending]");
|
|
if (pending) {
|
|
let remaining = Number(pending.dataset.remaining) || 0;
|
|
const counter = $("[data-countdown]", pending);
|
|
const tick = setInterval(() => {
|
|
remaining = Math.max(0, remaining - 1);
|
|
counter.textContent = remaining;
|
|
if (remaining === 0) {
|
|
clearInterval(tick);
|
|
setTimeout(() => location.reload(), 5000);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
// Redirect after the admin interface moved
|
|
const moved = $("[data-redirect]");
|
|
if (moved) setTimeout(() => (location.href = moved.dataset.redirect), Number(moved.dataset.redirectAfter) * 1000);
|
|
|
|
// Scroll log views to the newest line
|
|
$$("[data-scroll-bottom]").forEach((el) => (el.scrollTop = el.scrollHeight));
|
|
})();
|