VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
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>
This commit is contained in:
commit
a33c4822bf
96 files changed
+7535
No files matched your search
@@ -0,0 +1,206 @@
|
||||
/* 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));
|
||||
})();
|
||||
Reference in new issue
Block a user