VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
CI / test-bookworm (push) Failing after 11s
CI / test-latest (push) Successful in 1m27s

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:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

+206
View File
@@ -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));
})();